@wdio/nightwatch-devtools 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,796 @@
1
+ /**
2
+ * Nightwatch DevTools Plugin
3
+ *
4
+ * Integrates Nightwatch with WebdriverIO DevTools following the WDIO service pattern.
5
+ * Captures commands, network requests, and console logs during test execution in real-time.
6
+ */
7
+ import * as fs from 'node:fs';
8
+ import * as path from 'node:path';
9
+ import * as os from 'node:os';
10
+ import { fileURLToPath } from 'node:url';
11
+ import { start, stop } from '@wdio/devtools-backend';
12
+ import logger from '@wdio/logger';
13
+ import { remote } from 'webdriverio';
14
+ import { SessionCapturer } from './session.js';
15
+ import { TestReporter } from './reporter.js';
16
+ import { TestManager } from './helpers/testManager.js';
17
+ import { SuiteManager } from './helpers/suiteManager.js';
18
+ import { BrowserProxy } from './helpers/browserProxy.js';
19
+ import { TraceType } from './types.js';
20
+ import { determineTestState, findTestFileFromStack, deterministicUid, extractTestMetadata, parseCucumberScenario, findStepDefinitionLine, findFreePort, resolveNightwatchConfig } from './helpers/utils.js';
21
+ import { DEFAULTS, TIMING, TEST_STATE, PLUGIN_GLOBAL_KEY } from './constants.js';
22
+ const log = logger('@wdio/nightwatch-devtools');
23
+ class NightwatchDevToolsPlugin {
24
+ options;
25
+ sessionCapturer;
26
+ testReporter;
27
+ testManager;
28
+ suiteManager;
29
+ browserProxy;
30
+ isScriptInjected = false;
31
+ #currentTest = null;
32
+ #currentScenarioSuite = null;
33
+ #currentStep = null;
34
+ #lastSessionId = null;
35
+ #devtoolsBrowser;
36
+ #userDataDir;
37
+ #isCucumberRunner = false;
38
+ #passCount = 0;
39
+ #failCount = 0;
40
+ #skipCount = 0;
41
+ #configPath;
42
+ #srcFolders = [];
43
+ #getRerunLabel() {
44
+ return process.env.DEVTOOLS_RERUN_ENTRY_TYPE === 'test'
45
+ ? process.env.DEVTOOLS_RERUN_LABEL?.trim()
46
+ : undefined;
47
+ }
48
+ constructor(options = {}) {
49
+ this.options = {
50
+ port: options.port ?? 3000,
51
+ hostname: options.hostname ?? 'localhost'
52
+ };
53
+ }
54
+ async before() {
55
+ // When relaunched by the DevTools UI rerun button the backend is already
56
+ // running — skip startup and just connect the WebSocket worker.
57
+ const isReuse = process.env.DEVTOOLS_APP_REUSE === '1' &&
58
+ process.env.DEVTOOLS_APP_HOST &&
59
+ process.env.DEVTOOLS_APP_PORT;
60
+ if (isReuse) {
61
+ this.options.hostname = process.env.DEVTOOLS_APP_HOST;
62
+ this.options.port = Number(process.env.DEVTOOLS_APP_PORT);
63
+ log.info(`♻ Reusing DevTools backend at ${this.options.hostname}:${this.options.port}`);
64
+ // Clear execution data from the previous run when rerunning
65
+ // This ensures test names cache and suites are fresh for the new run
66
+ if (this.testReporter) {
67
+ this.testReporter.clearExecutionData();
68
+ this.suiteManager.clearExecutionData();
69
+ // Reset counters for fresh run
70
+ this.#passCount = 0;
71
+ this.#failCount = 0;
72
+ this.#skipCount = 0;
73
+ log.info('Cleared execution data for rerun');
74
+ }
75
+ }
76
+ this.#configPath = resolveNightwatchConfig();
77
+ if (this.#configPath) {
78
+ log.info(`✓ Config: ${this.#configPath}`);
79
+ }
80
+ else {
81
+ log.warn('Could not find nightwatch config — test rerun will be unavailable');
82
+ }
83
+ if (isReuse) {
84
+ // Register the plugin instance so Cucumber hooks can call back into it.
85
+ ;
86
+ globalThis[PLUGIN_GLOBAL_KEY] = this;
87
+ return;
88
+ }
89
+ try {
90
+ this.options.port = await findFreePort(this.options.port, this.options.hostname);
91
+ log.info('🚀 Starting DevTools backend...');
92
+ await start(this.options);
93
+ const url = `http://${this.options.hostname}:${this.options.port}`;
94
+ log.info(`✓ Backend started on port ${this.options.port}`);
95
+ log.info(` DevTools UI: ${url}`);
96
+ try {
97
+ // Create unique user data directory for this instance to prevent conflicts
98
+ this.#userDataDir = path.join(os.tmpdir(), `nightwatch-devtools-${this.options.port}-${Date.now()}`);
99
+ if (!fs.existsSync(this.#userDataDir)) {
100
+ fs.mkdirSync(this.#userDataDir, { recursive: true });
101
+ }
102
+ this.#devtoolsBrowser = await remote({
103
+ logLevel: 'info',
104
+ automationProtocol: 'devtools',
105
+ capabilities: {
106
+ browserName: 'chrome',
107
+ 'goog:chromeOptions': {
108
+ args: [
109
+ '--window-size=1600,1200',
110
+ `--user-data-dir=${this.#userDataDir}`,
111
+ '--no-first-run',
112
+ '--no-default-browser-check'
113
+ ]
114
+ }
115
+ }
116
+ });
117
+ await this.#devtoolsBrowser.url(url);
118
+ }
119
+ catch (err) {
120
+ log.error(`Failed to open DevTools UI: ${err.message}`);
121
+ log.info(`Please manually open: ${url}`);
122
+ }
123
+ await new Promise((resolve) => setTimeout(resolve, TIMING.UI_CONNECTION_WAIT));
124
+ globalThis[PLUGIN_GLOBAL_KEY] = this;
125
+ }
126
+ catch (err) {
127
+ log.error(`Failed to start backend: ${err.message}`);
128
+ throw err;
129
+ }
130
+ }
131
+ async #ensureSessionInitialized(browser) {
132
+ const currentSessionId = browser.sessionId;
133
+ const isSessionChange = currentSessionId &&
134
+ this.#lastSessionId &&
135
+ currentSessionId !== this.#lastSessionId;
136
+ if (isSessionChange) {
137
+ log.info('Browser session changed — reconnecting WebSocket only');
138
+ this.isScriptInjected = false;
139
+ this.sessionCapturer?.cleanup();
140
+ this.sessionCapturer = null;
141
+ }
142
+ this.#lastSessionId = currentSessionId ?? null;
143
+ if (this.sessionCapturer) {
144
+ return;
145
+ }
146
+ await new Promise((resolve) => setTimeout(resolve, TIMING.INITIAL_CONNECTION_WAIT));
147
+ this.sessionCapturer = new SessionCapturer({ port: this.options.port, hostname: this.options.hostname }, browser);
148
+ const connected = await this.sessionCapturer.waitForConnection(3000);
149
+ if (!connected) {
150
+ log.error('❌ Worker WebSocket failed to connect!');
151
+ }
152
+ if (!this.testReporter) {
153
+ // First-time setup: create reporter chain once for the entire run.
154
+ // These must NOT be recreated on session change — doing so generates a
155
+ // new feature suite with a fresh start timestamp, which DataManager sees
156
+ // as a new run and wipes all accumulated commands.
157
+ this.testReporter = new TestReporter((suitesData) => {
158
+ if (this.sessionCapturer) {
159
+ this.sessionCapturer.sendUpstream('suites', suitesData);
160
+ }
161
+ });
162
+ this.testManager = new TestManager(this.testReporter);
163
+ this.suiteManager = new SuiteManager(this.testReporter);
164
+ this.browserProxy = new BrowserProxy(this.sessionCapturer, this.testManager, () => this.#currentTest ?? this.#currentScenarioSuite);
165
+ }
166
+ else {
167
+ // Session change: update the reporter's upstream callback to use the new
168
+ // WebSocket, update the proxy's capturer reference (avoids re-wrapping
169
+ // already-wrapped browser methods which would double-capture commands),
170
+ // then replay current suite state to the newly-connected UI.
171
+ this.testReporter.updateUpstream((suitesData) => {
172
+ if (this.sessionCapturer) {
173
+ this.sessionCapturer.sendUpstream('suites', suitesData);
174
+ }
175
+ });
176
+ this.browserProxy.updateSessionCapturer(this.sessionCapturer);
177
+ this.testReporter.updateSuites();
178
+ }
179
+ const capabilities = browser.capabilities || {};
180
+ const desiredCapabilities = browser.desiredCapabilities || {};
181
+ const sessionId = browser.sessionId;
182
+ const opts = browser.options || {};
183
+ // Capture src_folders once so beforeEach can resolve test file paths
184
+ if (this.#srcFolders.length === 0) {
185
+ const sf = opts.src_folders;
186
+ this.#srcFolders = Array.isArray(sf) ? sf : sf ? [sf] : [];
187
+ }
188
+ this.sessionCapturer.sendUpstream('metadata', {
189
+ type: TraceType.Testrunner,
190
+ capabilities,
191
+ desiredCapabilities,
192
+ sessionId,
193
+ testEnv: opts.testEnv,
194
+ host: opts.webdriver?.host,
195
+ options: this.#buildMetadataOptions(),
196
+ url: ''
197
+ });
198
+ const browserName = capabilities.browserName || desiredCapabilities.browserName || 'unknown';
199
+ const browserVersion = capabilities.browserVersion || capabilities.version || '';
200
+ log.info(`✓ Browser: ${browserName}${browserVersion ? ' ' + browserVersion : ''} (session: ${sessionId})`);
201
+ const loggingPrefs = capabilities['goog:loggingPrefs'] ||
202
+ desiredCapabilities['goog:loggingPrefs'] ||
203
+ {};
204
+ if (!loggingPrefs.performance) {
205
+ log.warn("⚠ Network tab will be empty — add 'goog:loggingPrefs': { performance: 'ALL' } to your capabilities");
206
+ }
207
+ }
208
+ async cucumberBefore(browser, pickle) {
209
+ this.#isCucumberRunner = true;
210
+ await this.#initCucumberScenario(browser, pickle);
211
+ }
212
+ async cucumberAfter(browser, result, pickle) {
213
+ await this.#finalizeCucumberScenario(browser, result, pickle);
214
+ }
215
+ /** Called from Cucumber Before hook (order:1000) — one call per scenario. */
216
+ async #initCucumberScenario(browser, pickle) {
217
+ await this.#ensureSessionInitialized(browser);
218
+ const featureUri = pickle.uri ?? 'unknown.feature';
219
+ const scenarioName = pickle.name ?? 'Unknown Scenario';
220
+ // Derive the feature name from the "Feature: <name>" header in the file,
221
+ // falling back to the filename (e.g. "login") only if the file can't be read.
222
+ let featureName = path.basename(featureUri, '.feature');
223
+ let featureContent = '';
224
+ const featureAbsPath = path.resolve(process.cwd(), featureUri);
225
+ const stepDefFiles = [];
226
+ if (featureUri !== 'unknown.feature' && fs.existsSync(featureAbsPath)) {
227
+ featureContent = fs.readFileSync(featureAbsPath, 'utf-8');
228
+ const match = featureContent.match(/^\s*Feature:\s*(.+)/m);
229
+ if (match) {
230
+ featureName = match[1].trim();
231
+ }
232
+ this.sessionCapturer.captureSource(featureAbsPath).catch(() => { });
233
+ // Capture step definitions from sibling directories
234
+ const featureDir = path.dirname(featureAbsPath);
235
+ const stepDirCandidates = ['step_definitions', 'steps', 'support'];
236
+ for (const candidate of stepDirCandidates) {
237
+ const stepDir = path.join(featureDir, candidate);
238
+ if (fs.existsSync(stepDir) && fs.statSync(stepDir).isDirectory()) {
239
+ for (const entry of fs.readdirSync(stepDir)) {
240
+ if (/\.(js|ts|mjs|cjs)$/.test(entry)) {
241
+ const stepFilePath = path.join(stepDir, entry);
242
+ this.sessionCapturer.captureSource(stepFilePath).catch(() => { });
243
+ try {
244
+ stepDefFiles.push({
245
+ filePath: stepFilePath,
246
+ content: fs.readFileSync(stepFilePath, 'utf-8')
247
+ });
248
+ }
249
+ catch {
250
+ // skip unreadable files
251
+ }
252
+ }
253
+ }
254
+ }
255
+ }
256
+ }
257
+ // Get or create the feature-level suite (no individual test names — scenarios go into suites[])
258
+ const featureSuite = this.suiteManager.getOrCreateSuite(featureUri, featureName, featureUri, []);
259
+ this.suiteManager.markSuiteAsRunning(featureSuite);
260
+ // Parse step keywords from the feature file
261
+ const steps = pickle.steps ?? [];
262
+ // Parse line numbers and keywords for TestLens navigation and step labels
263
+ const { featureLine, scenarioLine, stepLines, stepKeywords } = parseCucumberScenario(featureContent, scenarioName, steps.map((s) => s.text));
264
+ if (featureAbsPath && featureLine > 0) {
265
+ featureSuite.callSource = `${featureAbsPath}:${featureLine}`;
266
+ }
267
+ // Create a scenario sub-suite (child of feature suite).
268
+ // Use deterministicUid (no counter) so the SAME scenario always maps to the
269
+ // SAME uid across retries, enabling correct retry detection below.
270
+ const scenarioUid = deterministicUid(featureUri, `scenario:${scenarioName}`);
271
+ const scenarioSuite = {
272
+ uid: scenarioUid,
273
+ cid: DEFAULTS.CID,
274
+ title: scenarioName,
275
+ fullTitle: `${featureName} ${scenarioName}`,
276
+ parent: featureSuite.uid,
277
+ type: 'suite',
278
+ file: featureUri,
279
+ start: new Date(),
280
+ state: 'running',
281
+ end: null,
282
+ tests: [],
283
+ suites: [],
284
+ hooks: [],
285
+ _duration: 0,
286
+ callSource: featureAbsPath && scenarioLine > 0
287
+ ? `${featureAbsPath}:${scenarioLine}`
288
+ : undefined
289
+ };
290
+ // Create a TestStats entry for each step
291
+ steps.forEach((step, i) => {
292
+ const keyword = stepKeywords[i] || '';
293
+ const stepLabel = keyword ? `${keyword} ${step.text}` : step.text;
294
+ const stepUid = deterministicUid(featureUri, `step:${scenarioName}:${step.text}`);
295
+ scenarioSuite.tests.push({
296
+ uid: stepUid,
297
+ cid: DEFAULTS.CID,
298
+ title: stepLabel,
299
+ fullTitle: `${scenarioName} ${stepLabel}`,
300
+ parent: scenarioUid,
301
+ state: 'pending',
302
+ start: new Date(),
303
+ end: null,
304
+ type: 'test',
305
+ file: featureUri,
306
+ retries: 0,
307
+ _duration: 0,
308
+ hooks: [],
309
+ callSource: (() => {
310
+ const loc = findStepDefinitionLine(stepDefFiles, step.text);
311
+ if (loc) {
312
+ return `${loc.filePath}:${loc.line}`;
313
+ }
314
+ if (featureAbsPath && stepLines[i] > 0) {
315
+ return `${featureAbsPath}:${stepLines[i]}`;
316
+ }
317
+ return undefined;
318
+ })()
319
+ });
320
+ });
321
+ // Add scenario sub-suite to the feature suite.
322
+ // If a suite with this uid already exists it means this is a RETRY of the same
323
+ // scenario — clear execution data so only the latest attempt's commands are shown.
324
+ const existingIdx = featureSuite.suites.findIndex((s) => s.uid === scenarioUid);
325
+ const isRetry = existingIdx !== -1;
326
+ if (isRetry) {
327
+ featureSuite.suites[existingIdx] = scenarioSuite;
328
+ // Pass the specific scenario uid so only this scenario's execution data
329
+ // is reset — a uid-less clearExecutionData would mark ALL suites as
330
+ // running, destroying the previous terminal states of sibling scenarios.
331
+ this.sessionCapturer.sendUpstream('clearExecutionData', {
332
+ uid: scenarioUid,
333
+ entryType: 'suite'
334
+ });
335
+ }
336
+ else {
337
+ featureSuite.suites.push(scenarioSuite);
338
+ }
339
+ this.#currentScenarioSuite = scenarioSuite;
340
+ this.#currentStep = null;
341
+ this.#currentTest = null;
342
+ this.testReporter.updateSuites();
343
+ if (!this.isScriptInjected) {
344
+ this.browserProxy.wrapUrlMethod(browser);
345
+ this.isScriptInjected = true;
346
+ }
347
+ this.browserProxy.wrapBrowserCommands(browser);
348
+ this.browserProxy.resetCommandTracking();
349
+ log.info(`🥒 Scenario: ${scenarioName}`);
350
+ }
351
+ /** Called from Cucumber After hook (order:1000) — one call per scenario. */
352
+ async #finalizeCucumberScenario(browser, result, pickle) {
353
+ try {
354
+ const status = String(result?.status ?? 'UNKNOWN').toUpperCase();
355
+ const scenarioState = status === 'PASSED'
356
+ ? TEST_STATE.PASSED
357
+ : status === 'SKIPPED'
358
+ ? TEST_STATE.SKIPPED
359
+ : TEST_STATE.FAILED;
360
+ if (this.#currentScenarioSuite) {
361
+ const duration = Date.now() -
362
+ (this.#currentScenarioSuite.start?.getTime() ?? Date.now());
363
+ this.#currentScenarioSuite.state = scenarioState;
364
+ this.#currentScenarioSuite.end = new Date();
365
+ this.#currentScenarioSuite._duration = duration;
366
+ // Ensure any still-running or pending steps are marked appropriately
367
+ for (const step of this.#currentScenarioSuite.tests) {
368
+ if (typeof step !== 'string' &&
369
+ (step.state === 'running' || step.state === 'pending')) {
370
+ step.state =
371
+ scenarioState === TEST_STATE.PASSED
372
+ ? TEST_STATE.PASSED
373
+ : TEST_STATE.FAILED;
374
+ step.end = new Date();
375
+ }
376
+ }
377
+ const featureUri = pickle?.uri ?? 'unknown.feature';
378
+ this.testManager.markTestAsProcessed(featureUri, pickle?.name ?? '');
379
+ const featureSuite = this.suiteManager.getSuite(featureUri);
380
+ if (featureSuite) {
381
+ // Finalize is not called until all scenarios are done — just update state
382
+ this.suiteManager.finalizeSuiteState(featureSuite);
383
+ }
384
+ this.#incrementCount(scenarioState);
385
+ const icon = this.#testIcon(scenarioState);
386
+ const durationSec = (duration / 1000).toFixed(2);
387
+ log.info(` ${icon} ${pickle?.name ?? 'Unknown'} (${durationSec}s)`);
388
+ this.testReporter.updateSuites();
389
+ this.#currentScenarioSuite = null;
390
+ this.#currentStep = null;
391
+ this.#currentTest = null;
392
+ }
393
+ await this.sessionCapturer.captureTrace(browser);
394
+ }
395
+ catch (err) {
396
+ log.error(`Failed to finalize Cucumber scenario: ${err.message}`);
397
+ }
398
+ }
399
+ /** Called from Cucumber BeforeStep hook — marks the step as running. */
400
+ async cucumberBeforeStep(browser, pickleStep, _pickle) {
401
+ if (!this.#currentScenarioSuite) {
402
+ return;
403
+ }
404
+ // Reset per-step dedup tracking so commands in step N are never
405
+ // mistaken for retries of identically-signatured commands from step N-1.
406
+ this.browserProxy?.resetCommandTracking();
407
+ const stepText = pickleStep?.text ?? '';
408
+ const step = this.#currentScenarioSuite.tests.find((t) => typeof t !== 'string' &&
409
+ (t.title.endsWith(stepText) || t.title === stepText));
410
+ if (step) {
411
+ step.state = TEST_STATE.RUNNING;
412
+ step.start = new Date();
413
+ step.end = null;
414
+ this.#currentStep = step;
415
+ this.testReporter.updateSuites();
416
+ }
417
+ }
418
+ /** Called from Cucumber AfterStep hook — records the step result. */
419
+ async cucumberAfterStep(_browser, result, pickleStep, _pickle) {
420
+ const step = this.#currentStep;
421
+ if (!step) {
422
+ return;
423
+ }
424
+ const status = String(result?.status ?? 'UNKNOWN').toUpperCase();
425
+ const stepState = status === 'PASSED'
426
+ ? TEST_STATE.PASSED
427
+ : status === 'SKIPPED'
428
+ ? TEST_STATE.SKIPPED
429
+ : TEST_STATE.FAILED;
430
+ step.state = stepState;
431
+ step.end = new Date();
432
+ step._duration = Date.now() - (step.start?.getTime() ?? Date.now());
433
+ this.#currentStep = null;
434
+ this.testReporter.updateSuites();
435
+ void pickleStep; // used by BeforeStep to find the step
436
+ }
437
+ async beforeEach(browser) {
438
+ if (this.#isCucumberRunner) {
439
+ return;
440
+ }
441
+ await this.#ensureSessionInitialized(browser);
442
+ const currentTest = browser.currentTest;
443
+ if (!currentTest) {
444
+ return;
445
+ }
446
+ const testFile = (currentTest.module || '').split('/').pop() ||
447
+ currentTest.module ||
448
+ DEFAULTS.FILE_NAME;
449
+ let fullPath = findTestFileFromStack() || null;
450
+ const cachedPath = this.browserProxy.getCurrentTestFullPath();
451
+ if (!fullPath && cachedPath && cachedPath.includes(testFile)) {
452
+ fullPath = cachedPath;
453
+ }
454
+ if (!fullPath && testFile) {
455
+ const workspaceRoot = process.cwd();
456
+ // currentTest.module is the path relative to a src_folder, e.g. "basic/ecosia"
457
+ // So we must try: path.join(cwd, srcFolder, module + '.js') for each src_folder
458
+ const modulePath = (currentTest.module || '').replace(/\\/g, '/');
459
+ const srcFolderPaths = this.#srcFolders.flatMap((sf) => modulePath
460
+ ? [
461
+ path.join(workspaceRoot, sf, modulePath + '.js'),
462
+ path.join(workspaceRoot, sf, modulePath + '.ts'),
463
+ path.join(workspaceRoot, sf, modulePath + '.cjs'),
464
+ path.join(workspaceRoot, sf, modulePath)
465
+ ]
466
+ : []);
467
+ const possiblePaths = [
468
+ // Highest priority: expand module path via each configured src_folder
469
+ ...srcFolderPaths,
470
+ // Fallback: treat module path as relative to cwd (works when src_folders isn't nested)
471
+ ...(modulePath
472
+ ? [
473
+ path.join(workspaceRoot, modulePath + '.js'),
474
+ path.join(workspaceRoot, modulePath + '.ts'),
475
+ path.join(workspaceRoot, modulePath + '.cjs'),
476
+ path.join(workspaceRoot, modulePath)
477
+ ]
478
+ : []),
479
+ path.join(workspaceRoot, 'example/tests', testFile + '.js'),
480
+ path.join(workspaceRoot, 'example/tests', testFile),
481
+ path.join(workspaceRoot, 'tests', testFile + '.js'),
482
+ path.join(workspaceRoot, 'test', testFile + '.js'),
483
+ path.join(workspaceRoot, testFile + '.js')
484
+ ];
485
+ for (const possiblePath of possiblePaths) {
486
+ if (fs.existsSync(possiblePath)) {
487
+ fullPath = possiblePath;
488
+ break;
489
+ }
490
+ }
491
+ }
492
+ // Extract suite title and test metadata
493
+ let suiteTitle = testFile;
494
+ if (!fullPath) {
495
+ log.warn(`[beforeEach] Could not resolve file path for "${testFile}" — source view will be unavailable`);
496
+ }
497
+ let testNames = [];
498
+ let suiteLine = null;
499
+ let testLines = [];
500
+ if (fullPath) {
501
+ const { suiteTitle: parsedTitle, testNames: parsedNames, suiteLine: parsedSuiteLine, testLines: parsedTestLines } = extractTestMetadata(fullPath);
502
+ if (parsedTitle) {
503
+ suiteTitle = parsedTitle;
504
+ }
505
+ testNames = parsedNames;
506
+ suiteLine = parsedSuiteLine;
507
+ testLines = parsedTestLines;
508
+ }
509
+ const rerunLabel = this.#getRerunLabel();
510
+ if (rerunLabel) {
511
+ const targetIndex = testNames.findIndex((name) => name === rerunLabel);
512
+ if (targetIndex !== -1) {
513
+ testNames = [testNames[targetIndex]];
514
+ testLines = testLines[targetIndex] ? [testLines[targetIndex]] : [];
515
+ }
516
+ }
517
+ // Get or create suite for this test file
518
+ const currentSuite = this.suiteManager.getOrCreateSuite(testFile, suiteTitle, fullPath, testNames, suiteLine, testLines);
519
+ // Capture source file for display
520
+ if (fullPath && fullPath.includes('/')) {
521
+ this.sessionCapturer.captureSource(fullPath).catch(() => { });
522
+ }
523
+ const runningTest = currentSuite.tests.find((t) => typeof t !== 'string' && t.state === TEST_STATE.RUNNING);
524
+ if (runningTest) {
525
+ const testcases = currentTest?.results?.testcases || {};
526
+ if (testcases[runningTest.title]) {
527
+ const testcase = testcases[runningTest.title];
528
+ const testState = determineTestState(testcase);
529
+ runningTest.state = testState;
530
+ runningTest.end = new Date();
531
+ runningTest._duration = parseFloat(testcase.time || '0') * 1000;
532
+ this.testManager.updateTestState(runningTest, testState);
533
+ this.testManager.markTestAsProcessed(testFile, runningTest.title);
534
+ this.#incrementCount(testState);
535
+ const prevIcon = this.#testIcon(testState);
536
+ log.info(` ${prevIcon} ${runningTest.title} (${(runningTest._duration / 1000).toFixed(2)}s)`);
537
+ }
538
+ else {
539
+ const endTime = new Date();
540
+ const duration = endTime.getTime() - (runningTest.start?.getTime() || 0);
541
+ this.testManager.updateTestState(runningTest, TEST_STATE.PASSED, endTime, duration);
542
+ this.testManager.markTestAsProcessed(testFile, runningTest.title);
543
+ this.#passCount++;
544
+ log.info(` ✅ ${runningTest.title} (${(duration / 1000).toFixed(2)}s)`);
545
+ }
546
+ await new Promise((resolve) => setTimeout(resolve, TIMING.UI_RENDER_DELAY));
547
+ }
548
+ const processedTests = this.testManager.getProcessedTests(testFile);
549
+ const runtimeTestName = typeof currentTest?.name === 'string'
550
+ ? currentTest.name.trim()
551
+ : undefined;
552
+ const matchedRuntimeTestName = runtimeTestName
553
+ ? testNames.find((name) => runtimeTestName === name || runtimeTestName.endsWith(` ${name}`))
554
+ : undefined;
555
+ const currentTestName = matchedRuntimeTestName ||
556
+ testNames.find((name) => !processedTests.has(name));
557
+ if (currentTestName) {
558
+ if (processedTests.size === 0) {
559
+ this.suiteManager.markSuiteAsRunning(currentSuite);
560
+ }
561
+ const test = this.testManager.findTestInSuite(currentSuite, currentTestName);
562
+ if (test) {
563
+ test.state = TEST_STATE.RUNNING;
564
+ test.start = new Date();
565
+ test.end = null;
566
+ this.testReporter.onTestStart(test);
567
+ this.#currentTest = test;
568
+ log.info(` ▶ ${currentTestName}`);
569
+ await new Promise((resolve) => setTimeout(resolve, TIMING.TEST_START_DELAY));
570
+ }
571
+ else {
572
+ log.warn(`Test "${currentTestName}" not found in suite "${currentSuite.title}"`);
573
+ this.#currentTest = null;
574
+ }
575
+ }
576
+ if (!this.isScriptInjected) {
577
+ this.browserProxy.wrapUrlMethod(browser);
578
+ this.isScriptInjected = true;
579
+ }
580
+ this.browserProxy.resetCommandTracking();
581
+ this.browserProxy.wrapBrowserCommands(browser);
582
+ }
583
+ async afterEach(browser) {
584
+ // Cucumber runner manages its own lifecycle via cucumberHooks.cjs
585
+ if (this.#isCucumberRunner) {
586
+ return;
587
+ }
588
+ if (browser && this.sessionCapturer) {
589
+ try {
590
+ const currentTest = browser.currentTest;
591
+ const results = currentTest?.results || {};
592
+ const testFile = (currentTest.module || '').split('/').pop() || DEFAULTS.FILE_NAME;
593
+ const testcases = results.testcases || {};
594
+ const testcaseNames = Object.keys(testcases);
595
+ const currentSuite = this.suiteManager.getSuite(testFile);
596
+ if (currentSuite) {
597
+ const processedTests = this.testManager.getProcessedTests(testFile);
598
+ if (testcaseNames.length === 0) {
599
+ const runningTest = currentSuite.tests.find((t) => typeof t !== 'string' && t.state === TEST_STATE.RUNNING);
600
+ if (runningTest && !processedTests.has(runningTest.title)) {
601
+ const testState = results.errors > 0 || results.failed > 0
602
+ ? TEST_STATE.FAILED
603
+ : TEST_STATE.PASSED;
604
+ const endTime = new Date();
605
+ const duration = endTime.getTime() - (runningTest.start?.getTime() || 0);
606
+ this.testManager.updateTestState(runningTest, testState, endTime, duration);
607
+ this.testManager.markTestAsProcessed(testFile, runningTest.title);
608
+ this.#incrementCount(testState);
609
+ const icon = this.#testIcon(testState);
610
+ log.info(` ${icon} ${runningTest.title} (${(duration / 1000).toFixed(2)}s)`);
611
+ }
612
+ }
613
+ else {
614
+ const unprocessedTests = testcaseNames.filter((name) => !processedTests.has(name));
615
+ for (const currentTestName of unprocessedTests) {
616
+ const testcase = testcases[currentTestName];
617
+ const testState = determineTestState(testcase);
618
+ const test = this.testManager.findTestInSuite(currentSuite, currentTestName);
619
+ if (test) {
620
+ const dur = parseFloat(testcase.time || '0') * 1000;
621
+ this.testManager.updateTestState(test, testState, new Date(), dur);
622
+ this.#incrementCount(testState);
623
+ const icon = this.#testIcon(testState);
624
+ log.info(` ${icon} ${currentTestName} (${(dur / 1000).toFixed(2)}s)`);
625
+ }
626
+ this.testManager.markTestAsProcessed(testFile, currentTestName);
627
+ }
628
+ if (processedTests.size === testcaseNames.length) {
629
+ this.suiteManager.finalizeSuite(currentSuite);
630
+ await new Promise((resolve) => setTimeout(resolve, TIMING.SUITE_COMPLETE_DELAY));
631
+ }
632
+ }
633
+ }
634
+ await this.sessionCapturer.captureTrace(browser);
635
+ }
636
+ catch (err) {
637
+ log.error(`Failed to capture trace: ${err.message}`);
638
+ }
639
+ }
640
+ }
641
+ async after(browser) {
642
+ try {
643
+ const currentTest = browser?.currentTest;
644
+ const testcases = currentTest?.results?.testcases || {};
645
+ for (const [, suite] of (this.suiteManager?.getAllSuites() ?? new Map()).entries()) {
646
+ this.testManager.finalizeSuiteTests(suite, testcases);
647
+ await new Promise((resolve) => setTimeout(resolve, TIMING.SUITE_COMPLETE_DELAY));
648
+ this.suiteManager.finalizeSuite(suite);
649
+ }
650
+ await new Promise((resolve) => setTimeout(resolve, TIMING.SUITE_COMPLETE_DELAY));
651
+ const summary = [
652
+ this.#passCount > 0 ? `${this.#passCount} passed` : null,
653
+ this.#failCount > 0 ? `${this.#failCount} failed` : null,
654
+ this.#skipCount > 0 ? `${this.#skipCount} skipped` : null
655
+ ]
656
+ .filter(Boolean)
657
+ .join(' ');
658
+ const totalFailed = this.#failCount;
659
+ log.info(`${totalFailed > 0 ? '❌' : '✅'} Tests complete! ${summary}`);
660
+ log.info(` DevTools UI: http://${this.options.hostname}:${this.options.port}`);
661
+ if (!this.#devtoolsBrowser) {
662
+ // Reuse mode: force one final suites broadcast so the UI reflects the
663
+ // actual outcome before the process exits.
664
+ this.testReporter?.updateSuites();
665
+ log.info('♻ Rerun complete — flushing WebSocket...');
666
+ await this.sessionCapturer?.closeWebSocket();
667
+ return;
668
+ }
669
+ log.info('💡 Please close the DevTools browser window to finish...');
670
+ if (this.#devtoolsBrowser) {
671
+ ;
672
+ logger.setLevel('devtools', 'warn');
673
+ let exitBySignal = false;
674
+ const signalHandler = () => {
675
+ exitBySignal = true;
676
+ log.info('\n✓ Exiting... Browser window will remain open');
677
+ process.exit(0);
678
+ };
679
+ process.once('SIGINT', signalHandler);
680
+ process.once('SIGTERM', signalHandler);
681
+ while (true) {
682
+ try {
683
+ await this.#devtoolsBrowser.getTitle();
684
+ await new Promise((res) => setTimeout(res, TIMING.BROWSER_POLL_INTERVAL));
685
+ }
686
+ catch {
687
+ if (!exitBySignal) {
688
+ log.info('Browser window closed, stopping DevTools app');
689
+ break;
690
+ }
691
+ }
692
+ }
693
+ if (!exitBySignal) {
694
+ process.removeListener('SIGINT', signalHandler);
695
+ process.removeListener('SIGTERM', signalHandler);
696
+ logger.setLevel('devtools', 'info');
697
+ try {
698
+ await this.#devtoolsBrowser.deleteSession();
699
+ }
700
+ catch {
701
+ // session already closed
702
+ }
703
+ await stop();
704
+ process.exit(0);
705
+ }
706
+ }
707
+ }
708
+ catch (err) {
709
+ log.error(`Failed to stop backend: ${err.message}`);
710
+ }
711
+ }
712
+ #buildMetadataOptions() {
713
+ return {
714
+ framework: this.#isCucumberRunner ? 'nightwatch-cucumber' : 'nightwatch',
715
+ configFile: this.#configPath,
716
+ baseDir: process.cwd(),
717
+ runCapabilities: {
718
+ canRunSuites: true,
719
+ canRunTests: !this.#isCucumberRunner,
720
+ canRunAll: false
721
+ }
722
+ };
723
+ }
724
+ #incrementCount(state) {
725
+ if (state === TEST_STATE.PASSED) {
726
+ this.#passCount++;
727
+ }
728
+ else if (state === TEST_STATE.SKIPPED) {
729
+ this.#skipCount++;
730
+ }
731
+ else {
732
+ this.#failCount++;
733
+ }
734
+ }
735
+ #testIcon(state) {
736
+ return state === TEST_STATE.PASSED
737
+ ? '✅'
738
+ : state === TEST_STATE.SKIPPED
739
+ ? '⏭'
740
+ : '❌';
741
+ }
742
+ registerEventHandlers(eventHub) {
743
+ this.#isCucumberRunner = eventHub.runner === 'cucumber';
744
+ if (this.#isCucumberRunner) {
745
+ log.info('✓ Cucumber runner detected via NightwatchEventHub');
746
+ }
747
+ log.info('✓ NightwatchEventHub registered — enriched metadata enabled');
748
+ const handleSessionMetadata = (data) => {
749
+ try {
750
+ const { sessionCapabilities, sessionId, testEnv, host, modulePath } = data?.metadata ?? {};
751
+ if (this.sessionCapturer && (sessionCapabilities || sessionId)) {
752
+ this.sessionCapturer.sendUpstream('metadata', {
753
+ type: TraceType.Testrunner,
754
+ capabilities: sessionCapabilities ?? {},
755
+ sessionId,
756
+ testEnv,
757
+ host,
758
+ modulePath,
759
+ options: this.#buildMetadataOptions()
760
+ });
761
+ }
762
+ }
763
+ catch (err) {
764
+ log.error(`Error in event handler: ${err.message}`);
765
+ }
766
+ };
767
+ eventHub.on('TestSuiteStarted', handleSessionMetadata);
768
+ eventHub.on('TestRunStarted', handleSessionMetadata);
769
+ }
770
+ }
771
+ export const cucumberHooksPath = fileURLToPath(new URL('./helpers/cucumberHooks.cjs', import.meta.url));
772
+ export default function createNightwatchDevTools(options) {
773
+ const plugin = new NightwatchDevToolsPlugin(options);
774
+ return {
775
+ // Set long timeout to allow user to review DevTools UI
776
+ // The after() hook waits for the browser window to be closed
777
+ asyncHookTimeout: 3600000,
778
+ before: async function () {
779
+ await plugin.before();
780
+ },
781
+ beforeEach: async function (browser) {
782
+ await plugin.beforeEach(browser);
783
+ },
784
+ afterEach: async function (browser) {
785
+ await plugin.afterEach(browser);
786
+ },
787
+ after: async function () {
788
+ await plugin.after();
789
+ },
790
+ registerEventHandlers: function (eventHub) {
791
+ plugin.registerEventHandlers(eventHub);
792
+ }
793
+ };
794
+ }
795
+ export { NightwatchDevToolsPlugin };
796
+ //# sourceMappingURL=index.js.map