Skip to content

Commit 796544c

Browse files
committed
Test rerun feature intial commit
1 parent d6b08ef commit 796544c

7 files changed

Lines changed: 292 additions & 33 deletions

File tree

packages/app/src/components/sidebar/constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,6 @@ export const DEFAULT_CAPABILITIES: RunCapabilities = {
1414
}
1515

1616
export const FRAMEWORK_CAPABILITIES: Record<string, RunCapabilities> = {
17-
cucumber: { canRunSuites: true, canRunTests: false }
17+
cucumber: { canRunSuites: true, canRunTests: false },
18+
'nightwatch-cucumber': { canRunSuites: true, canRunTests: false }
1819
}

packages/app/src/components/sidebar/explorer.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -369,19 +369,23 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
369369
}
370370

371371
#computeEntryState(entry: TestStats | SuiteStats): TestState {
372+
// For suites, check running state from children FIRST — this ensures that
373+
// a rerun (which clears end times) shows the spinner immediately, even if
374+
// the suite still has a cached 'passed'/'failed' state from the previous run.
375+
if ('tests' in entry && this.#isRunning(entry)) {
376+
return TestState.RUNNING
377+
}
378+
372379
const state = (entry as any).state
373380

374-
// Check explicit state first
381+
// Check explicit state
375382
const mappedState = STATE_MAP[state]
376383
if (mappedState) {
377384
return mappedState
378385
}
379386

380387
// For suites, compute state from children
381388
if ('tests' in entry) {
382-
if (this.#isRunning(entry)) {
383-
return TestState.RUNNING
384-
}
385389
if (this.#hasFailed(entry)) {
386390
return TestState.FAILED
387391
}
@@ -395,6 +399,12 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
395399
#getTestEntry(entry: TestStats | SuiteStats): TestEntry {
396400
if ('tests' in entry) {
397401
const entries = [...entry.tests, ...entry.suites]
402+
// A suite whose children are themselves suites is a feature/file-level
403+
// container (Cucumber feature or test file). Tag it as 'feature' so the
404+
// backend runner can distinguish it from a scenario/spec-level suite and
405+
// avoid applying a --name filter that would match no scenarios.
406+
const hasChildSuites = entry.suites && entry.suites.length > 0
407+
const derivedType = hasChildSuites ? 'feature' : ((entry as any).type || 'suite')
398408
return {
399409
uid: entry.uid,
400410
label: entry.title,
@@ -405,7 +415,7 @@ export class DevtoolsSidebarExplorer extends CollapseableEntry {
405415
fullTitle: entry.title,
406416
featureFile: (entry as any).featureFile,
407417
featureLine: (entry as any).featureLine,
408-
suiteType: (entry as any).type,
418+
suiteType: derivedType,
409419
children: Object.values(entries)
410420
.map(this.#getTestEntry.bind(this))
411421
.filter(this.#filterEntry.bind(this))

packages/app/src/controller/DataManager.ts

Lines changed: 17 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -160,11 +160,11 @@ export class DataManagerController implements ReactiveController {
160160
start: new Date(),
161161
end: undefined,
162162
tests:
163-
s.tests?.map((test) => ({
163+
(s.tests?.map((test) => ({
164164
...test,
165165
start: new Date(),
166166
end: undefined
167-
})) || [],
167+
})) ?? []) as TestStatsFragment[],
168168
suites: s.suites?.map(markAllAsRunning) || []
169169
}
170170
}
@@ -198,17 +198,17 @@ export class DataManagerController implements ReactiveController {
198198
start: new Date(),
199199
end: undefined, // Clear end to mark as running
200200
tests:
201-
s.tests?.map((test) => ({
201+
(s.tests?.map((test) => ({
202202
...test,
203203
start: new Date(),
204204
end: undefined
205-
})) || [],
205+
})) ?? []) as TestStatsFragment[],
206206
suites: s.suites?.map(markAsRunning) || []
207207
}
208208
}
209209

210210
// Check if any child test matches
211-
const updatedTests = s.tests?.map((test) => {
211+
const updatedTests = (s.tests?.map((test) => {
212212
if (test.uid === uid) {
213213
return {
214214
...test,
@@ -217,7 +217,7 @@ export class DataManagerController implements ReactiveController {
217217
}
218218
}
219219
return test
220-
})
220+
}) ?? []) as TestStatsFragment[]
221221

222222
// Recursively check nested suites
223223
const updatedNestedSuites = s.suites?.map(markAsRunning)
@@ -641,7 +641,17 @@ export class DataManagerController implements ReactiveController {
641641
existing.start &&
642642
this.#getTimestamp(test.start) !== this.#getTimestamp(existing.start)
643643

644-
// Replace on rerun, merge on normal update
644+
if (isRerun && test.state === 'pending' && existing) {
645+
// The incoming suite structure marks all tests as "pending" at start.
646+
// Preserve the existing state (running/passed/failed) so that tests
647+
// not part of the current rerun keep their previous results visible.
648+
// When a test actually starts executing, its state changes to "running"
649+
// and that update correctly replaces the preserved state.
650+
map.set(test.uid, { ...test, state: existing.state, end: existing.end })
651+
return
652+
}
653+
654+
// Replace on rerun (non-pending incoming), merge on normal update
645655
map.set(
646656
test.uid,
647657
isRerun ? test : existing ? { ...existing, ...test } : test

packages/backend/src/runner.ts

Lines changed: 122 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import url from 'node:url'
55
import { createRequire } from 'node:module'
66
import kill from 'tree-kill'
77
import type { RunnerRequestBody } from './types.js'
8-
import { WDIO_CONFIG_FILENAMES } from './types.js'
8+
import { WDIO_CONFIG_FILENAMES, NIGHTWATCH_CONFIG_FILENAMES } from './types.js'
99

1010
const require = createRequire(import.meta.url)
1111
const wdioBin = resolveWdioBin()
@@ -104,25 +104,70 @@ const FRAMEWORK_FILTERS: Record<
104104
const DEFAULT_FILTERS = ({ specArg }: { specArg?: string }) =>
105105
specArg ? ['--spec', specArg] : []
106106

107+
// Nightwatch CLI: positional spec file + optional --testcase filter
108+
FRAMEWORK_FILTERS.nightwatch = ({ specArg, payload }: { specArg?: string; payload: RunnerRequestBody }) => {
109+
const filters: string[] = []
110+
if (specArg) {
111+
// Nightwatch doesn't support file:line — strip any trailing line number
112+
filters.push(specArg.split(':')[0])
113+
}
114+
if (payload.entryType === 'test' && payload.fullTitle) {
115+
filters.push('--testcase', payload.fullTitle)
116+
}
117+
return filters
118+
}
119+
120+
// Nightwatch + Cucumber: feature files are resolved via the config's feature_path.
121+
// Never pass .feature files as positional args — Nightwatch rejects them.
122+
// Nightwatch forwards --name and --tags to the underlying Cucumber runner.
123+
FRAMEWORK_FILTERS['nightwatch-cucumber'] = ({ payload }: { specArg?: string; payload: RunnerRequestBody }) => {
124+
const filters: string[] = []
125+
126+
// Only pass --name for scenario-level reruns. Feature/file-level suites
127+
// (suiteType === 'feature') run all their scenarios, so no --name filter.
128+
const isFeatureLevel = payload.suiteType === 'feature' || payload.runAll
129+
if (!isFeatureLevel && payload.fullTitle) {
130+
// Wrap as an anchored exact regex so "Scenario A" never also matches
131+
// "Scenario A-1" (Cucumber treats --name as a regex).
132+
const escaped = payload.fullTitle.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
133+
filters.push('--name', `^${escaped}$`)
134+
}
135+
return filters
136+
}
137+
107138
class TestRunner {
108139
#child?: ChildProcess
109140
#lastPayload?: RunnerRequestBody
110141
#baseDir = process.cwd()
111142

112143
async run(payload: RunnerRequestBody) {
113144
if (this.#child) {
114-
throw new Error('A test run is already in progress')
145+
// A run is already in progress — stop it first so the new one can start.
146+
this.stop()
147+
// Give the killed process a moment to release resources before spawning.
148+
await new Promise<void>((resolve) => setTimeout(resolve, 500))
115149
}
116150

151+
const isNightwatch = (payload.framework || '').toLowerCase().startsWith('nightwatch')
117152
const configPath = this.#resolveConfigPath(payload)
118153
this.#baseDir = process.env.DEVTOOLS_RUNNER_CWD || path.dirname(configPath)
119154

120-
const args = [
121-
wdioBin,
122-
'run',
123-
configPath,
124-
...this.#buildFilters(payload)
125-
].filter(Boolean)
155+
let args: string[]
156+
if (isNightwatch) {
157+
const nightwatchBin = resolveNightwatchBin(this.#baseDir)
158+
args = [
159+
nightwatchBin,
160+
'--config', configPath,
161+
...this.#buildFilters(payload)
162+
].filter(Boolean)
163+
} else {
164+
args = [
165+
wdioBin,
166+
'run',
167+
configPath,
168+
...this.#buildFilters(payload)
169+
].filter(Boolean)
170+
}
126171

127172
const childEnv = { ...process.env }
128173
if (payload.devtoolsHost && payload.devtoolsPort) {
@@ -250,14 +295,16 @@ class TestRunner {
250295
? path.dirname(this.#toFsPath(specCandidate))
251296
: undefined
252297

298+
const isNightwatch = (payload?.framework || '').toLowerCase().startsWith('nightwatch')
253299
const candidates = this.#dedupeCandidates([
254300
payload?.configFile,
255301
this.#lastPayload?.configFile,
256302
process.env.DEVTOOLS_WDIO_CONFIG,
257-
this.#findConfigFromSpec(specCandidate),
258-
...this.#expandDefaultConfigsFor(this.#baseDir),
259-
...this.#expandDefaultConfigsFor(path.resolve(this.#baseDir, 'example')),
260-
...this.#expandDefaultConfigsFor(specDir)
303+
process.env.DEVTOOLS_NIGHTWATCH_CONFIG,
304+
this.#findConfigFromSpec(specCandidate, isNightwatch),
305+
...this.#expandDefaultConfigsFor(this.#baseDir, isNightwatch),
306+
...this.#expandDefaultConfigsFor(path.resolve(this.#baseDir, 'example'), isNightwatch),
307+
...this.#expandDefaultConfigsFor(specDir, isNightwatch)
261308
])
262309

263310
for (const candidate of candidates) {
@@ -267,24 +314,28 @@ class TestRunner {
267314
}
268315
}
269316

317+
const runner = isNightwatch ? 'Nightwatch' : 'WDIO'
270318
throw new Error(
271-
`Cannot locate WDIO config. Tried:\n${candidates
319+
`Cannot locate ${runner} config. Tried:\n${candidates
272320
.map((c) => ` • ${this.#toFsPath(c)}`)
273321
.join('\n')}`
274322
)
275323
}
276324

277-
#findConfigFromSpec(specFile?: string) {
325+
#findConfigFromSpec(specFile?: string, nightwatch = false) {
278326
if (!specFile) {
279327
return undefined
280328
}
281329

330+
const filenames = nightwatch
331+
? [...NIGHTWATCH_CONFIG_FILENAMES, ...WDIO_CONFIG_FILENAMES]
332+
: WDIO_CONFIG_FILENAMES
282333
const fsSpec = this.#toFsPath(specFile)
283334
let dir = path.dirname(fsSpec)
284335
const root = path.parse(dir).root
285336

286337
while (dir && dir !== root) {
287-
for (const file of WDIO_CONFIG_FILENAMES) {
338+
for (const file of filenames) {
288339
const candidate = path.join(dir, file)
289340
if (fs.existsSync(candidate)) {
290341
return candidate
@@ -300,11 +351,14 @@ class TestRunner {
300351
return undefined
301352
}
302353

303-
#expandDefaultConfigsFor(baseDir?: string) {
354+
#expandDefaultConfigsFor(baseDir?: string, nightwatch = false) {
304355
if (!baseDir) {
305356
return []
306357
}
307-
return WDIO_CONFIG_FILENAMES.map((file) => path.resolve(baseDir, file))
358+
const filenames = nightwatch
359+
? [...NIGHTWATCH_CONFIG_FILENAMES, ...WDIO_CONFIG_FILENAMES]
360+
: WDIO_CONFIG_FILENAMES
361+
return filenames.map((file) => path.resolve(baseDir, file))
308362
}
309363

310364
#dedupeCandidates(values: Array<string | undefined>) {
@@ -319,6 +373,57 @@ class TestRunner {
319373
}
320374
}
321375

376+
function resolveNightwatchBin(baseDir: string): string {
377+
const envOverride = process.env.DEVTOOLS_NIGHTWATCH_BIN
378+
if (envOverride) {
379+
const resolved = path.isAbsolute(envOverride)
380+
? envOverride
381+
: path.resolve(process.cwd(), envOverride)
382+
if (fs.existsSync(resolved)) {
383+
return resolved
384+
}
385+
}
386+
387+
// Walk up from baseDir looking for node_modules/nightwatch/package.json
388+
// and resolve the actual JS entry (avoids running the shell-script wrapper
389+
// at node_modules/.bin/nightwatch directly via node).
390+
let dir = baseDir
391+
const root = path.parse(dir).root
392+
while (dir !== root) {
393+
const nightwatchPkgPath = path.join(
394+
dir,
395+
'node_modules',
396+
'nightwatch',
397+
'package.json'
398+
)
399+
if (fs.existsSync(nightwatchPkgPath)) {
400+
try {
401+
const pkg = JSON.parse(fs.readFileSync(nightwatchPkgPath, 'utf8'))
402+
const nightwatchDir = path.join(dir, 'node_modules', 'nightwatch')
403+
const binEntry =
404+
typeof pkg.bin === 'string'
405+
? pkg.bin
406+
: (pkg.bin?.nightwatch ?? pkg.bin?.nw)
407+
if (binEntry) {
408+
const jsPath = path.resolve(nightwatchDir, binEntry)
409+
if (fs.existsSync(jsPath)) {
410+
return jsPath
411+
}
412+
}
413+
} catch {
414+
// malformed package.json — continue walking
415+
}
416+
}
417+
const parent = path.dirname(dir)
418+
if (parent === dir) break
419+
dir = parent
420+
}
421+
422+
throw new Error(
423+
'Cannot find nightwatch binary. Install nightwatch locally or set DEVTOOLS_NIGHTWATCH_BIN env var.'
424+
)
425+
}
426+
322427
function resolveWdioBin() {
323428
const envOverride = process.env.DEVTOOLS_WDIO_BIN
324429
if (envOverride) {

packages/backend/src/types.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,14 @@ export const WDIO_CONFIG_FILENAMES = [
55
'wdio.conf.mjs'
66
] as const
77

8+
export const NIGHTWATCH_CONFIG_FILENAMES = [
9+
'nightwatch.conf.cjs',
10+
'nightwatch.conf.js',
11+
'nightwatch.conf.ts',
12+
'nightwatch.conf.mjs',
13+
'nightwatch.json'
14+
] as const
15+
816
export interface RunnerRequestBody {
917
uid: string
1018
entryType: 'suite' | 'test'

0 commit comments

Comments
 (0)