@wdio/nightwatch-devtools 1.3.0 → 1.4.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.
@@ -71,6 +71,15 @@ async function attachLogInspector(driver, factory, sinks, log2) {
71
71
  return false;
72
72
  }
73
73
  }
74
+ var isFiniteNumber = (value) => typeof value === "number" && Number.isFinite(value);
75
+ function bidiResponseTiming(timings, startTime, timestamp) {
76
+ if (timings && isFiniteNumber(timings.requestTime) && isFiniteNumber(timings.responseEnd) && timings.responseEnd > timings.requestTime) {
77
+ const time = Math.round(timings.responseEnd - timings.requestTime);
78
+ return { endTime: startTime + time, time };
79
+ }
80
+ const endTime = Number(timestamp ?? Date.now());
81
+ return { endTime, time: Math.max(0, endTime - startTime) };
82
+ }
74
83
  function handleBidiRequestSent(rawEvent, pending, sinks, log2) {
75
84
  const event = rawEvent;
76
85
  try {
@@ -101,14 +110,19 @@ function handleBidiResponseCompleted(rawEvent, pending, sinks, log2) {
101
110
  if (!previous) {
102
111
  return;
103
112
  }
113
+ const { endTime, time } = bidiResponseTiming(
114
+ event?.request?.timings,
115
+ previous.startTime,
116
+ event?.timestamp
117
+ );
104
118
  const finalized = {
105
119
  ...previous,
106
120
  status: Number(event?.response?.status) || previous.status,
107
121
  statusText: event?.response?.statusText ?? previous.statusText,
108
122
  responseHeaders: arrayHeadersToObject(event?.response?.headers),
109
123
  type: getRequestType(previous.url, event?.response?.mimeType),
110
- endTime: Number(event?.timestamp ?? Date.now()),
111
- time: Number(event?.timestamp ?? Date.now()) - previous.startTime,
124
+ endTime,
125
+ time,
112
126
  size: Number(event?.response?.bytesReceived) || void 0
113
127
  };
114
128
  pending.delete(requestId);
@@ -212,4 +226,4 @@ export {
212
226
  attachBidiHandlers2 as attachBidiHandlers,
213
227
  buildBidiSinks
214
228
  };
215
- //# sourceMappingURL=bidi-OH4ZFDN5.js.map
229
+ //# sourceMappingURL=bidi-FIHBBGT4.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/bidi.ts","../../core/src/bidi.ts"],"sourcesContent":["import logger from '@wdio/logger'\nimport {\n type BidiHandlerSinks,\n attachBidiHandlers as attachBidiHandlersCore\n} from '@wdio/devtools-core'\nimport type { SessionCapturer } from './session.js'\n\nconst log = logger('@wdio/nightwatch-devtools:bidi')\n\n/**\n * Nightwatch wrapper around the core BiDi attach helper. Nightwatch ships\n * selenium-webdriver under the hood (via chromedriver), so the same\n * `selenium-webdriver/bidi` inspectors selenium-devtools uses are available\n * whenever the user has set `webSocketUrl: true` in their capabilities.\n *\n * Opt-in via the plugin's `bidi: true` option. When attached, the per-command\n * Chrome performance-log network capture is gated off to avoid duplicate\n * entries in the dashboard.\n */\nexport async function attachBidiHandlers(\n driver: unknown,\n sinks: BidiHandlerSinks\n): Promise<boolean> {\n return attachBidiHandlersCore(driver, sinks, (level, message) =>\n log[level](message)\n )\n}\n\n/**\n * Build sinks that route BiDi events into the SessionCapturer's local arrays\n * and broadcast them upstream. Mirrors selenium-devtools' `buildBidiSinks` —\n * separate per-adapter because the SessionCapturer concrete types differ.\n */\nexport function buildBidiSinks(capturer: SessionCapturer): BidiHandlerSinks {\n return {\n pushConsoleLog: (entry) => {\n capturer.consoleLogs.push(entry)\n capturer.sendUpstream('consoleLogs', [entry])\n },\n pushNetworkRequest: (entry) => {\n capturer.networkRequests.push(entry)\n capturer.sendUpstream('networkRequests', [entry])\n },\n replaceNetworkRequest: (id, entry) => {\n const idx = capturer.networkRequests.findIndex(\n (r: { id?: string }) => r.id === id\n )\n if (idx !== -1) {\n capturer.networkRequests[idx] = entry\n } else {\n capturer.networkRequests.push(entry)\n }\n capturer.sendUpstream('networkRequests', [entry])\n }\n }\n}\n","import { createRequire } from 'node:module'\nimport type {\n ConsoleLog,\n LogLevel,\n NetworkRequest\n} from '@wdio/devtools-shared'\nimport {\n LOG_SOURCES,\n chromeLogLevelToLogLevel,\n type LogSource\n} from './console.js'\nimport { errorMessage } from './error.js'\nimport { getRequestType } from './net.js'\n\n/**\n * Generic sinks the BiDi handlers push into. Each adapter wires these to its\n * own SessionCapturer state — selenium's `buildBidiSinks` is the canonical\n * example; nightwatch can mirror the pattern when it wires up BiDi.\n */\nexport interface BidiHandlerSinks {\n pushConsoleLog: (entry: ConsoleLog) => void\n pushNetworkRequest: (entry: NetworkRequest) => void\n replaceNetworkRequest: (id: string, entry: NetworkRequest) => void\n}\n\n/**\n * Resolve a `selenium-webdriver/<subpath>` module from the user's install\n * (preferred) or the package's local install (fallback). Returns `null` if\n * neither resolves — caller should treat as \"BiDi not available on this\n * runtime\" and degrade gracefully.\n *\n * Used by both selenium-devtools and (when wired up) nightwatch-devtools —\n * both ship selenium-webdriver-style drivers under the hood.\n */\nexport function loadSeleniumSubmodule<T = unknown>(subpath: string): T | null {\n try {\n const userRequire = createRequire(`${process.cwd()}/`)\n return userRequire(`selenium-webdriver/${subpath}`) as T\n } catch {\n try {\n const localRequire = createRequire(import.meta.url)\n return localRequire(`selenium-webdriver/${subpath}`) as T\n } catch {\n return null\n }\n }\n}\n\ntype BidiLogger = (level: 'info' | 'warn', message: string) => void\ntype InspectorFactory = (driver: unknown) => Promise<unknown>\n\ninterface LogInspector {\n onConsoleEntry: (cb: (entry: unknown) => void) => Promise<void>\n onJavascriptException: (cb: (exc: unknown) => void) => Promise<void>\n}\n\ninterface NetworkInspector {\n beforeRequestSent: (cb: (e: unknown) => void) => Promise<void>\n responseCompleted: (cb: (e: unknown) => void) => Promise<void>\n}\n\nexport function handleBidiConsoleEntry(\n rawEntry: unknown,\n sinks: BidiHandlerSinks,\n log: BidiLogger\n): void {\n const entry = rawEntry as {\n level?: string\n type?: string\n text?: string\n message?: string\n timestamp?: number\n }\n try {\n const level = (entry?.level ?? entry?.type ?? 'info').toString()\n const text = entry?.text ?? entry?.message ?? ''\n sinks.pushConsoleLog({\n timestamp: Number(entry?.timestamp) || Date.now(),\n type: chromeLogLevelToLogLevel(level) as LogLevel,\n args: [text],\n source: LOG_SOURCES.BROWSER as LogSource\n })\n } catch (err) {\n log('warn', `onConsoleEntry handler threw: ${errorMessage(err)}`)\n }\n}\n\nexport function handleBidiJsException(\n rawExc: unknown,\n sinks: BidiHandlerSinks,\n log: BidiLogger\n): void {\n const exception = rawExc as { text?: string; message?: string }\n try {\n const text = exception?.text ?? exception?.message ?? String(rawExc)\n const trimmed = String(text).replace(/\\s+/g, ' ').slice(0, 200)\n log(\n 'warn',\n `🐛 JS error in page: ${trimmed}${String(text).length > 200 ? '…' : ''}`\n )\n sinks.pushConsoleLog({\n timestamp: Date.now(),\n type: 'error',\n args: [text],\n source: LOG_SOURCES.BROWSER as LogSource\n })\n } catch (err) {\n log('warn', `onJavascriptException handler threw: ${errorMessage(err)}`)\n }\n}\n\nasync function attachLogInspector(\n driver: unknown,\n factory: InspectorFactory,\n sinks: BidiHandlerSinks,\n log: BidiLogger\n): Promise<boolean> {\n try {\n const inspector = (await factory(driver)) as LogInspector\n await inspector.onConsoleEntry((e) => handleBidiConsoleEntry(e, sinks, log))\n await inspector.onJavascriptException((e) =>\n handleBidiJsException(e, sinks, log)\n )\n log('info', '✓ BiDi LogInspector attached (console + JS exceptions)')\n return true\n } catch (err) {\n log('warn', `BiDi LogInspector attach failed: ${errorMessage(err)}`)\n return false\n }\n}\n\n/** Subset of WebDriver BiDi `FetchTimingInfo` (selenium exposes these via\n * getters). Values are ms offsets from the request baseline `requestTime`\n * (usually 0), so `responseEnd - requestTime` is the request duration. */\ninterface FetchTimings {\n requestTime?: number\n responseEnd?: number\n}\n\nconst isFiniteNumber = (value: unknown): value is number =>\n typeof value === 'number' && Number.isFinite(value)\n\ninterface BeforeRequestSentEvent {\n request?: {\n request?: string\n url?: string\n method?: string\n headers?: unknown\n timings?: FetchTimings\n }\n id?: string\n timestamp?: number\n}\n\ninterface ResponseCompletedEvent {\n request?: { request?: string; timings?: FetchTimings }\n id?: string\n timestamp?: number\n response?: {\n status?: number\n statusText?: string\n headers?: unknown\n mimeType?: string\n bytesReceived?: number\n }\n}\n\n/** Request duration (ms) from the browser's FetchTimingInfo, plus a derived\n * endTime anchored to `startTime`. The browser timings are immune to BiDi\n * events arriving batched in one tick (which collapses the event-level\n * `timestamp`/`Date.now()` to a single value and yields 0-duration requests).\n * Falls back to the timestamp diff only when timings are unavailable. */\nfunction bidiResponseTiming(\n timings: FetchTimings | undefined,\n startTime: number,\n timestamp?: number\n): { endTime: number; time: number } {\n if (\n timings &&\n isFiniteNumber(timings.requestTime) &&\n isFiniteNumber(timings.responseEnd) &&\n timings.responseEnd > timings.requestTime\n ) {\n const time = Math.round(timings.responseEnd - timings.requestTime)\n return { endTime: startTime + time, time }\n }\n const endTime = Number(timestamp ?? Date.now())\n return { endTime, time: Math.max(0, endTime - startTime) }\n}\n\nexport function handleBidiRequestSent(\n rawEvent: unknown,\n pending: Map<string, NetworkRequest>,\n sinks: BidiHandlerSinks,\n log: BidiLogger\n): void {\n const event = rawEvent as BeforeRequestSentEvent\n try {\n const requestId = String(event?.request?.request ?? event?.id ?? '')\n if (!requestId) {\n return\n }\n const entry: NetworkRequest = {\n id: requestId,\n url: event?.request?.url ?? '',\n method: event?.request?.method ?? 'GET',\n requestHeaders: arrayHeadersToObject(event?.request?.headers),\n timestamp: Date.now(),\n startTime: Number(event?.timestamp ?? Date.now()),\n type: getRequestType(event?.request?.url ?? '')\n }\n pending.set(requestId, entry)\n sinks.pushNetworkRequest(entry)\n } catch (err) {\n log('warn', `beforeRequestSent threw: ${errorMessage(err)}`)\n }\n}\n\nexport function handleBidiResponseCompleted(\n rawEvent: unknown,\n pending: Map<string, NetworkRequest>,\n sinks: BidiHandlerSinks,\n log: BidiLogger\n): void {\n const event = rawEvent as ResponseCompletedEvent\n try {\n const requestId = String(event?.request?.request ?? event?.id ?? '')\n const previous = pending.get(requestId)\n if (!previous) {\n return\n }\n const { endTime, time } = bidiResponseTiming(\n event?.request?.timings,\n previous.startTime,\n event?.timestamp\n )\n const finalized: NetworkRequest = {\n ...previous,\n status: Number(event?.response?.status) || previous.status,\n statusText: event?.response?.statusText ?? previous.statusText,\n responseHeaders: arrayHeadersToObject(event?.response?.headers),\n type: getRequestType(previous.url, event?.response?.mimeType),\n endTime,\n time,\n size: Number(event?.response?.bytesReceived) || undefined\n }\n pending.delete(requestId)\n sinks.replaceNetworkRequest(requestId, finalized)\n } catch (err) {\n log('warn', `responseCompleted threw: ${errorMessage(err)}`)\n }\n}\n\nasync function attachNetworkInspector(\n driver: unknown,\n factory: InspectorFactory,\n sinks: BidiHandlerSinks,\n log: BidiLogger\n): Promise<boolean> {\n try {\n const inspector = (await factory(driver)) as NetworkInspector\n const pending = new Map<string, NetworkRequest>()\n await inspector.beforeRequestSent((e) =>\n handleBidiRequestSent(e, pending, sinks, log)\n )\n await inspector.responseCompleted((e) =>\n handleBidiResponseCompleted(e, pending, sinks, log)\n )\n log('info', '✓ BiDi NetworkInspector attached (request + response)')\n return true\n } catch (err) {\n log('warn', `BiDi NetworkInspector attach failed: ${errorMessage(err)}`)\n return false\n }\n}\n\n/**\n * Attach the selenium-webdriver BiDi LogInspector + NetworkInspector to a\n * driver and route their events into the given sinks. Returns `true` when at\n * least one inspector connected — caller can disable the equivalent\n * script-injection collectors to avoid duplicates.\n *\n * Tolerant of older / non-BiDi runtimes: if either submodule fails to load\n * or the inspector factory throws (driver session doesn't have webSocketUrl\n * capability set, etc.), the corresponding stream is silently skipped and\n * the function returns false.\n *\n * @param onLog Optional callback for adapter-side logging. Receives ('info' |\n * 'warn', message) on lifecycle events. Default: silent — adapters wire their\n * own logger when they want visibility into BiDi attach state.\n */\nexport async function attachBidiHandlers(\n driver: unknown,\n sinks: BidiHandlerSinks,\n onLog?: (level: 'info' | 'warn', message: string) => void\n): Promise<boolean> {\n const log: BidiLogger = (level, message) => onLog?.(level, message)\n const logFactory =\n loadSeleniumSubmodule<InspectorFactory>('bidi/logInspector')\n const networkFactory = loadSeleniumSubmodule<InspectorFactory>(\n 'bidi/networkInspector'\n )\n\n let attached = 0\n if (typeof logFactory === 'function') {\n if (await attachLogInspector(driver, logFactory, sinks, log)) {\n attached++\n }\n } else {\n log('info', 'selenium-webdriver/bidi/logInspector not available — skipping')\n }\n if (typeof networkFactory === 'function') {\n if (await attachNetworkInspector(driver, networkFactory, sinks, log)) {\n attached++\n }\n } else {\n log(\n 'info',\n 'selenium-webdriver/bidi/networkInspector not available — skipping'\n )\n }\n return attached > 0\n}\n\n/**\n * Flatten BiDi's `Array<{name, value:{value|type}}>` header shape to a\n * lowercased `Record<string, string>`. Exported so adapter-side helpers can\n * reuse it for their own header normalization.\n */\nexport function arrayHeadersToObject(\n headers: unknown\n): Record<string, string> | undefined {\n if (!Array.isArray(headers)) {\n return undefined\n }\n const out: Record<string, string> = {}\n for (const h of headers as Array<{\n name?: string\n value?: string | { value?: string; type?: string }\n }>) {\n const name = String(h?.name ?? '').toLowerCase()\n if (!name) {\n continue\n }\n const v = h?.value\n out[name] =\n typeof v === 'string'\n ? v\n : typeof (v as { value?: string })?.value === 'string'\n ? (v as { value: string }).value\n : JSON.stringify(v ?? '')\n }\n return out\n}\n"],"mappings":"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","names":["log","attachBidiHandlers"]}
package/dist/index.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { ScreencastOptions, DevToolsMode, TraceFormat } from '@wdio/devtools-shared';
1
+ import { ScreencastOptions, DevToolsMode, TraceFormat, TraceGranularity } from '@wdio/devtools-shared';
2
2
 
3
3
  /** Nightwatch `eventHub` shape — only `runner` + `on()` are documented; the
4
4
  * rest of the public surface is `unknown` to us. */
@@ -30,6 +30,10 @@ interface DevToolsOptions {
30
30
  /** Trace output layout — `zip` (default) writes a single archive,
31
31
  * `ndjson-directory` unpacks into `trace-<id>/`. Only applies in trace mode. */
32
32
  traceFormat?: TraceFormat;
33
+ /** Trace output granularity — `session` (default) writes one trace per
34
+ * worker session; `spec` writes one trace per spec file. Only applies in
35
+ * trace mode. */
36
+ traceGranularity?: TraceGranularity;
33
37
  }
34
38
  interface NightwatchBrowser {
35
39
  url: (url: string) => Promise<unknown>;
package/dist/index.js CHANGED
@@ -2060,15 +2060,46 @@ function buildContextOptions(trace, contextId, wallTime) {
2060
2060
  }
2061
2061
  };
2062
2062
  }
2063
- function buildActionEvents(commands, pageId, wallTime) {
2063
+ function buildActionEvents(commands, pageId, wallTime, testMetadata) {
2064
2064
  const events = [];
2065
2065
  let prevEndMs = 0;
2066
2066
  let callCounter = 0;
2067
+ let lastTestUid;
2068
+ let groupCallId;
2067
2069
  for (const cmd of commands) {
2068
2070
  const action = mapCommandToAction(cmd.command);
2069
2071
  if (!action) {
2070
2072
  continue;
2071
2073
  }
2074
+ if (cmd.testUid && cmd.testUid !== lastTestUid) {
2075
+ if (lastTestUid && groupCallId) {
2076
+ callCounter++;
2077
+ events.push({
2078
+ type: "after",
2079
+ callId: groupCallId,
2080
+ endTime: prevEndMs
2081
+ });
2082
+ }
2083
+ callCounter++;
2084
+ groupCallId = `call@${callCounter}`;
2085
+ const meta = testMetadata?.get(cmd.testUid);
2086
+ const groupName = meta?.title ?? cmd.testUid;
2087
+ events.push({
2088
+ type: "before",
2089
+ callId: groupCallId,
2090
+ startTime: Math.max(
2091
+ prevEndMs,
2092
+ (cmd.startTime ?? cmd.timestamp) - wallTime
2093
+ ),
2094
+ class: "Tracing",
2095
+ method: "tracingGroup",
2096
+ pageId,
2097
+ params: { name: groupName },
2098
+ title: groupName,
2099
+ apiName: "tracing.tracingGroup"
2100
+ });
2101
+ lastTestUid = cmd.testUid;
2102
+ }
2072
2103
  callCounter++;
2073
2104
  const callId = `call@${callCounter}`;
2074
2105
  const rawStartMs = (cmd.startTime ?? cmd.timestamp) - wallTime;
@@ -2098,7 +2129,8 @@ function buildActionEvents(commands, pageId, wallTime) {
2098
2129
  pageId,
2099
2130
  params,
2100
2131
  title: formatActionTitle(action, cmd.args, params),
2101
- apiName: `${action.class.toLowerCase()}.${action.method}`
2132
+ apiName: `${action.class.toLowerCase()}.${action.method}`,
2133
+ parentId: groupCallId
2102
2134
  });
2103
2135
  const afterEvent = {
2104
2136
  type: "after",
@@ -2112,6 +2144,14 @@ function buildActionEvents(commands, pageId, wallTime) {
2112
2144
  events.push(afterEvent);
2113
2145
  prevEndMs = endMs;
2114
2146
  }
2147
+ if (lastTestUid && groupCallId) {
2148
+ callCounter++;
2149
+ events.push({
2150
+ type: "after",
2151
+ callId: groupCallId,
2152
+ endTime: prevEndMs
2153
+ });
2154
+ }
2115
2155
  return events;
2116
2156
  }
2117
2157
  function buildNetworkNdjson(requests, wallTime, pageId) {
@@ -2268,7 +2308,7 @@ function buildTraceBundle(trace, opts = {}) {
2268
2308
  wallTime,
2269
2309
  viewport
2270
2310
  ),
2271
- ...buildActionEvents(trace.commands, pageId, wallTime)
2311
+ ...buildActionEvents(trace.commands, pageId, wallTime, opts.testMetadata)
2272
2312
  );
2273
2313
  events.sort(compareEvents);
2274
2314
  const ctxBName = ctxOptions.title;
@@ -2326,7 +2366,8 @@ async function writeTraceZip(capturer, opts) {
2326
2366
  await fs.mkdir(opts.outputDir, { recursive: true });
2327
2367
  const exportOpts = {
2328
2368
  sessionId: opts.sessionId,
2329
- wallTimeOverride: capturer.startWallTime
2369
+ wallTimeOverride: capturer.startWallTime,
2370
+ testMetadata: opts.testMetadata
2330
2371
  };
2331
2372
  if (opts.format === "ndjson-directory") {
2332
2373
  const dir = path.join(opts.outputDir, `trace-${opts.sessionId}`);
@@ -2424,6 +2465,15 @@ function runFfmpeg(ffmpeg, manifestPath, outputPath) {
2424
2465
  "cfr",
2425
2466
  "-r",
2426
2467
  "10",
2468
+ // Force a keyframe every frame. VP8 can only seek to keyframes, and
2469
+ // libvpx's default GOP places a single keyframe on near-static
2470
+ // screencast content — so scrubber/marker seeks snap back to the
2471
+ // start. At 10fps over short recordings, all-intra keeps seeking
2472
+ // frame-accurate at an acceptable size.
2473
+ "-g",
2474
+ "1",
2475
+ "-keyint_min",
2476
+ "1",
2427
2477
  "-auto-alt-ref",
2428
2478
  "0",
2429
2479
  "-disposition:v",
@@ -2487,7 +2537,8 @@ async function finalizeScreencast({
2487
2537
  videoPath,
2488
2538
  videoFile: fileName,
2489
2539
  frameCount: frames.length,
2490
- duration: recorder.duration
2540
+ duration: recorder.duration,
2541
+ startTime: frames[0]?.timestamp
2491
2542
  });
2492
2543
  } catch (err) {
2493
2544
  log12("warn", `Screencast encode failed: ${errorMessage(err)}`);
@@ -2951,9 +3002,12 @@ var SessionCapturerBase = class {
2951
3002
  metadata;
2952
3003
  // ── Construction ────────────────────────────────────────────────────────
2953
3004
  constructor(opts = {}) {
2954
- const { hostname, port } = opts;
3005
+ const { hostname, port, reconnect } = opts;
2955
3006
  if (hostname && port) {
2956
- this.ws = new WebSocket(`ws://${hostname}:${port}${WS_PATHS.worker}`);
3007
+ const query = reconnect ? "?reconnect=1" : "";
3008
+ this.ws = new WebSocket(
3009
+ `ws://${hostname}:${port}${WS_PATHS.worker}${query}`
3010
+ );
2957
3011
  this.ws.on("open", () => {
2958
3012
  this.#hasConnected = true;
2959
3013
  this.onWsOpen();
@@ -3285,6 +3339,100 @@ function safeStringify(value) {
3285
3339
  }
3286
3340
  }
3287
3341
 
3342
+ // ../core/src/spec-trace-helpers.ts
3343
+ function sanitizeSpecName(specFile) {
3344
+ return specFile.replace(/^.*[/\\]/, "").replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]/g, "_").replace(/^_+|_+$/g, "") || "unknown-spec";
3345
+ }
3346
+ function buildSpecSessionId(specFile, sessionId) {
3347
+ const base = sanitizeSpecName(specFile);
3348
+ const hash = deterministicUid(specFile).split("-").pop().slice(0, 8);
3349
+ return `${base}-${hash}-${sessionId.slice(0, 8)}`;
3350
+ }
3351
+ function buildSpecCapturer(capturer, range, nextRange) {
3352
+ const end = nextRange ? {
3353
+ commands: nextRange.commandStartIdx,
3354
+ console: nextRange.consoleStartIdx,
3355
+ network: nextRange.networkStartIdx,
3356
+ mutations: nextRange.mutationStartIdx,
3357
+ traceLogs: nextRange.traceLogStartIdx
3358
+ } : void 0;
3359
+ return {
3360
+ mutations: capturer.mutations.slice(range.mutationStartIdx, end?.mutations),
3361
+ traceLogs: capturer.traceLogs.slice(range.traceLogStartIdx, end?.traceLogs),
3362
+ consoleLogs: capturer.consoleLogs.slice(
3363
+ range.consoleStartIdx,
3364
+ end?.console
3365
+ ),
3366
+ networkRequests: capturer.networkRequests.slice(
3367
+ range.networkStartIdx,
3368
+ end?.network
3369
+ ),
3370
+ commandsLog: capturer.commandsLog.slice(
3371
+ range.commandStartIdx,
3372
+ end?.commands
3373
+ ),
3374
+ sources: new Map(capturer.sources),
3375
+ metadata: capturer.metadata,
3376
+ startWallTime: capturer.startWallTime
3377
+ };
3378
+ }
3379
+ function filterTestMetadataBySpec(allMetadata, specFile) {
3380
+ const filtered = /* @__PURE__ */ new Map();
3381
+ for (const [uid, meta] of allMetadata) {
3382
+ if (meta.specFile === specFile) {
3383
+ filtered.set(uid, meta);
3384
+ }
3385
+ }
3386
+ return filtered;
3387
+ }
3388
+ function recordSpecBoundary(ctx, specFile, traceGranularity) {
3389
+ if (traceGranularity !== "spec") {
3390
+ return null;
3391
+ }
3392
+ const lastRange = ctx.specRanges[ctx.specRanges.length - 1];
3393
+ if (lastRange && lastRange.specFile === specFile) {
3394
+ return null;
3395
+ }
3396
+ const prevRange = lastRange && !ctx.flushedSpecs.has(lastRange.specFile) ? lastRange : null;
3397
+ ctx.specRanges.push({
3398
+ specFile,
3399
+ commandStartIdx: ctx.capturer.commandsLog.length,
3400
+ consoleStartIdx: ctx.capturer.consoleLogs.length,
3401
+ networkStartIdx: ctx.capturer.networkRequests.length,
3402
+ mutationStartIdx: ctx.capturer.mutations.length,
3403
+ traceLogStartIdx: ctx.capturer.traceLogs.length,
3404
+ snapshotCount: ctx.actionSnapshots.length
3405
+ });
3406
+ return prevRange;
3407
+ }
3408
+ async function writeSpecTrace(input) {
3409
+ const specCapturer = buildSpecCapturer(
3410
+ input.capturer,
3411
+ input.range,
3412
+ input.nextRange
3413
+ );
3414
+ const specSnapshots = input.actionSnapshots.slice(
3415
+ input.range.snapshotCount,
3416
+ input.nextRange?.snapshotCount ?? input.actionSnapshots.length
3417
+ );
3418
+ const specSessionId = buildSpecSessionId(
3419
+ input.range.specFile,
3420
+ input.sessionId
3421
+ );
3422
+ const testMetadata = filterTestMetadataBySpec(
3423
+ input.testMetadata,
3424
+ input.range.specFile
3425
+ );
3426
+ return writeTraceZip(specCapturer, {
3427
+ outputDir: input.outputDir,
3428
+ sessionId: specSessionId,
3429
+ capabilities: input.capabilities,
3430
+ actionSnapshots: specSnapshots.length > 0 ? specSnapshots : void 0,
3431
+ format: input.format,
3432
+ testMetadata
3433
+ });
3434
+ }
3435
+
3288
3436
  // ../core/src/suite-helpers.ts
3289
3437
  function createSuiteStats(init) {
3290
3438
  return {
@@ -3824,6 +3972,13 @@ async function openDevtoolsBrowser(ctx, url) {
3824
3972
  "--no-first-run",
3825
3973
  "--no-default-browser-check"
3826
3974
  ]
3975
+ },
3976
+ // Dashboard uses the Puppeteer-based 'devtools' protocol; drop the
3977
+ // "controlled by automated test software" infobar by ignoring
3978
+ // Puppeteer's --enable-automation default. (Runtime-valid capability
3979
+ // not present in the type.)
3980
+ "wdio:devtoolsOptions": {
3981
+ ignoreDefaultArgs: ["--enable-automation"]
3827
3982
  }
3828
3983
  }
3829
3984
  });
@@ -4515,18 +4670,22 @@ function applyResponseReceived(p, response) {
4515
4670
  p.mimeType = response.mimeType;
4516
4671
  p.type = getRequestType(p.url, response.mimeType);
4517
4672
  }
4673
+ function eventTimeMs(params, entry) {
4674
+ return typeof params.timestamp === "number" ? Math.round(params.timestamp * 1e3) : entry.timestamp;
4675
+ }
4518
4676
  function startPendingRequest(params, entry, pending) {
4519
4677
  const { requestId, request: req, timestamp } = params;
4520
4678
  if (!req || typeof timestamp !== "number") {
4521
4679
  return;
4522
4680
  }
4681
+ const startTime = eventTimeMs(params, entry);
4523
4682
  pending.set(requestId, {
4524
4683
  id: `${entry.timestamp}-${requestId}`,
4525
4684
  url: req.url,
4526
4685
  method: req.method,
4527
4686
  requestHeaders: req.headers ?? {},
4528
- timestamp: Math.round(timestamp * 1e3),
4529
- startTime: entry.timestamp
4687
+ timestamp: startTime,
4688
+ startTime
4530
4689
  });
4531
4690
  }
4532
4691
  function handlePerfLogEvent(method, params, entry, pending, completed) {
@@ -4545,8 +4704,8 @@ function handlePerfLogEvent(method, params, entry, pending, completed) {
4545
4704
  const p = pending.get(params.requestId);
4546
4705
  if (p && p.status !== void 0) {
4547
4706
  p.size = params.encodedDataLength;
4548
- p.endTime = entry.timestamp;
4549
- p.time = entry.timestamp - p.startTime;
4707
+ p.endTime = eventTimeMs(params, entry);
4708
+ p.time = Math.max(0, p.endTime - p.startTime);
4550
4709
  completed.push({ ...p });
4551
4710
  pending.delete(params.requestId);
4552
4711
  }
@@ -4556,8 +4715,8 @@ function handlePerfLogEvent(method, params, entry, pending, completed) {
4556
4715
  const p = pending.get(params.requestId);
4557
4716
  if (p) {
4558
4717
  p.error = params.errorText;
4559
- p.endTime = entry.timestamp;
4560
- p.time = entry.timestamp - p.startTime;
4718
+ p.endTime = eventTimeMs(params, entry);
4719
+ p.time = Math.max(0, p.endTime - p.startTime);
4561
4720
  completed.push({ ...p });
4562
4721
  pending.delete(params.requestId);
4563
4722
  }
@@ -5754,7 +5913,7 @@ async function tryAttachBidi(ctx, browser) {
5754
5913
  log10.warn("bidi:true set but browser.driver unavailable \u2014 skipping");
5755
5914
  return;
5756
5915
  }
5757
- const { attachBidiHandlers, buildBidiSinks } = await import("./bidi-OH4ZFDN5.js");
5916
+ const { attachBidiHandlers, buildBidiSinks } = await import("./bidi-FIHBBGT4.js");
5758
5917
  const ok = await attachBidiHandlers(
5759
5918
  driver,
5760
5919
  buildBidiSinks(ctx.sessionCapturer)
@@ -5790,7 +5949,13 @@ async function ensureSessionInitialized(ctx, browser, finalizeCurrentScreencast2
5790
5949
  (resolve2) => setTimeout(resolve2, TIMING.INITIAL_CONNECTION_WAIT)
5791
5950
  );
5792
5951
  ctx.sessionCapturer = new SessionCapturer(
5793
- ctx.mode === "trace" ? {} : { port: ctx.port, hostname: ctx.hostname },
5952
+ ctx.mode === "trace" ? {} : {
5953
+ port: ctx.port,
5954
+ hostname: ctx.hostname,
5955
+ // A session change reopens the WS mid-run — tell the backend to keep
5956
+ // the accumulated run state so earlier tests' commands survive.
5957
+ reconnect: Boolean(isSessionChange)
5958
+ },
5794
5959
  browser
5795
5960
  );
5796
5961
  ctx.sessionCapturer.traceMode = ctx.mode;
@@ -5851,6 +6016,10 @@ var NightwatchDevToolsPlugin = class {
5851
6016
  #skipCount = 0;
5852
6017
  #configPath;
5853
6018
  #srcFolders = [];
6019
+ /** Index ranges into the session capturer's flat arrays, one per spec file. */
6020
+ #specRanges = [];
6021
+ /** Set of spec files already flushed to disk. */
6022
+ #flushedSpecs = /* @__PURE__ */ new Set();
5854
6023
  #getRerunLabel() {
5855
6024
  return process.env[REUSE_ENV.RERUN_ENTRY_TYPE] === "test" ? process.env[REUSE_ENV.RERUN_LABEL]?.trim() : void 0;
5856
6025
  }
@@ -5872,7 +6041,8 @@ var NightwatchDevToolsPlugin = class {
5872
6041
  screencast,
5873
6042
  bidi: options.bidi ?? false,
5874
6043
  mode,
5875
- traceFormat: options.traceFormat ?? "zip"
6044
+ traceFormat: options.traceFormat ?? "zip",
6045
+ traceGranularity: options.traceGranularity ?? "session"
5876
6046
  };
5877
6047
  this.#screencastOptions = { ...SCREENCAST_DEFAULTS, ...screencast };
5878
6048
  this.#bidiEnabled = options.bidi === true;
@@ -6130,6 +6300,25 @@ var NightwatchDevToolsPlugin = class {
6130
6300
  this.sessionCapturer.captureSource(fullPath).catch(() => {
6131
6301
  });
6132
6302
  }
6303
+ if (fullPath) {
6304
+ const prevRange = recordSpecBoundary(
6305
+ {
6306
+ specRanges: this.#specRanges,
6307
+ flushedSpecs: this.#flushedSpecs,
6308
+ capturer: this.sessionCapturer,
6309
+ actionSnapshots: this.sessionCapturer.actionSnapshots
6310
+ },
6311
+ fullPath,
6312
+ this.options.traceGranularity
6313
+ );
6314
+ if (prevRange) {
6315
+ void this.#flushSpecTrace(prevRange).catch(
6316
+ (err) => log11.warn(
6317
+ `Failed to flush trace for spec "${prevRange.specFile}": ${errorMessage(err)}`
6318
+ )
6319
+ );
6320
+ }
6321
+ }
6133
6322
  await this.#closePreviousRunningTest(currentSuite, testFile, currentTest);
6134
6323
  const processedTests = this.testManager.getProcessedTests(testFile);
6135
6324
  const currentTestName = this.#pickCurrentTestName(
@@ -6187,6 +6376,42 @@ var NightwatchDevToolsPlugin = class {
6187
6376
  #logRunSummary() {
6188
6377
  logRunSummary(this.#getInternals());
6189
6378
  }
6379
+ async #flushSpecTrace(range, nextRange) {
6380
+ if (this.#flushedSpecs.has(range.specFile)) {
6381
+ return void 0;
6382
+ }
6383
+ this.#flushedSpecs.add(range.specFile);
6384
+ const sessionId = this.sessionCapturer.metadata?.sessionId;
6385
+ if (!sessionId) {
6386
+ return void 0;
6387
+ }
6388
+ const allMetadata = /* @__PURE__ */ new Map();
6389
+ if (this.suiteManager) {
6390
+ for (const suite of this.suiteManager.getAllSuites().values()) {
6391
+ for (const entry of suite.tests) {
6392
+ if (typeof entry === "string") {
6393
+ continue;
6394
+ }
6395
+ allMetadata.set(entry.uid, {
6396
+ title: entry.fullTitle,
6397
+ specFile: entry.file
6398
+ });
6399
+ }
6400
+ }
6401
+ }
6402
+ const tracePath = await writeSpecTrace({
6403
+ range,
6404
+ nextRange,
6405
+ capturer: this.sessionCapturer,
6406
+ actionSnapshots: this.sessionCapturer.actionSnapshots,
6407
+ sessionId,
6408
+ outputDir: resolveAdapterOutputDir({ configPath: this.#configPath }),
6409
+ format: this.options.traceFormat,
6410
+ testMetadata: allMetadata
6411
+ });
6412
+ log11.info(`Trace for spec "${range.specFile}" saved to ${tracePath}`);
6413
+ return tracePath;
6414
+ }
6190
6415
  async #writeTraceZipIfNeeded() {
6191
6416
  if (this.options.mode !== "trace" || !this.sessionCapturer) {
6192
6417
  return;
@@ -6199,6 +6424,28 @@ var NightwatchDevToolsPlugin = class {
6199
6424
  if (this.sessionCapturer.snapshotCaptures.length) {
6200
6425
  await Promise.allSettled(this.sessionCapturer.snapshotCaptures);
6201
6426
  }
6427
+ if (this.options.traceGranularity === "spec") {
6428
+ for (const range of this.#specRanges) {
6429
+ if (!this.#flushedSpecs.has(range.specFile)) {
6430
+ await this.#flushSpecTrace(range);
6431
+ }
6432
+ }
6433
+ return;
6434
+ }
6435
+ const testMetadata = /* @__PURE__ */ new Map();
6436
+ if (this.suiteManager) {
6437
+ for (const suite of this.suiteManager.getAllSuites().values()) {
6438
+ for (const entry of suite.tests) {
6439
+ if (typeof entry === "string") {
6440
+ continue;
6441
+ }
6442
+ testMetadata.set(entry.uid, {
6443
+ title: entry.fullTitle,
6444
+ specFile: entry.file
6445
+ });
6446
+ }
6447
+ }
6448
+ }
6202
6449
  const snapshots = this.sessionCapturer.actionSnapshots;
6203
6450
  const tracePath = await writeTraceZip(this.sessionCapturer, {
6204
6451
  outputDir: resolveAdapterOutputDir({
@@ -6206,7 +6453,8 @@ var NightwatchDevToolsPlugin = class {
6206
6453
  }),
6207
6454
  sessionId,
6208
6455
  actionSnapshots: snapshots.length ? snapshots : void 0,
6209
- format: this.options.traceFormat
6456
+ format: this.options.traceFormat,
6457
+ testMetadata
6210
6458
  });
6211
6459
  log11.info(`Trace saved to ${tracePath}`);
6212
6460
  } catch (err) {