@wdio/nightwatch-devtools 1.3.0 → 1.5.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 CHANGED
@@ -17,7 +17,72 @@ import {
17
17
  // src/index.ts
18
18
  import { fileURLToPath } from "url";
19
19
 
20
- // ../core/src/action-mapping.ts
20
+ // ../shared/src/files.ts
21
+ var SPEC_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$/i;
22
+
23
+ // ../shared/src/routes.ts
24
+ var WS_PATHS = {
25
+ /** Adapter session upgrade endpoint. One socket per running adapter. */
26
+ worker: "/worker",
27
+ /** App/UI client upgrade endpoint. Multiple browser tabs may connect. */
28
+ client: "/client"
29
+ };
30
+ var WS_SCOPE = {
31
+ /** Backend → worker: a dashboard client has subscribed. Wakes up the
32
+ * adapter's `await UI ready` gate before tests start. */
33
+ clientConnected: "clientConnected",
34
+ /** Backend → worker: the last dashboard tab closed. Triggers the
35
+ * interactive shutdown flow (close WS, exit, pkill the dashboard). */
36
+ clientDisconnected: "clientDisconnected",
37
+ /** Worker → backend → clients (or app-local): wipe the visualization data
38
+ * for a specific test/suite uid (or the whole tree). */
39
+ clearExecutionData: "clearExecutionData",
40
+ /** Worker → backend: clear-by-test-uid request (drives clearExecutionData). */
41
+ clearCommands: "clearCommands",
42
+ /** Backend → clients: signal that the active run was stopped by the UI. */
43
+ testStopped: "testStopped",
44
+ /** Worker → backend → clients: swap an earlier captured command for an
45
+ * updated entry (used when a retry coalesces commands). */
46
+ replaceCommand: "replaceCommand",
47
+ /** Worker → backend: register the config file path so reruns can spawn
48
+ * with the same config. */
49
+ config: "config"
50
+ };
51
+
52
+ // ../shared/src/runner.ts
53
+ var REUSE_ENV = {
54
+ REUSE: "DEVTOOLS_APP_REUSE",
55
+ HOST: "DEVTOOLS_APP_HOST",
56
+ PORT: "DEVTOOLS_APP_PORT",
57
+ RERUN_LABEL: "DEVTOOLS_RERUN_LABEL",
58
+ RERUN_ENTRY_TYPE: "DEVTOOLS_RERUN_ENTRY_TYPE"
59
+ };
60
+
61
+ // ../shared/src/timing.ts
62
+ var TIMING_BASE = {
63
+ /** ms to let the dashboard render between rapid state updates. */
64
+ UI_RENDER_DELAY: 150,
65
+ /** ms to wait after a test starts before flushing initial state. */
66
+ TEST_START_DELAY: 100,
67
+ /** ms gap between consecutive suite-finalize broadcasts. */
68
+ SUITE_COMPLETE_DELAY: 200,
69
+ /** ms allowed for the browser-close handshake before forcing teardown. */
70
+ BROWSER_CLOSE_WAIT: 2e3,
71
+ /** ms before first worker WS connection attempt — lets backend bind. */
72
+ INITIAL_CONNECTION_WAIT: 500,
73
+ /** ms between browser-window-alive polls. */
74
+ BROWSER_POLL_INTERVAL: 1e3
75
+ };
76
+ var DEFAULTS_BASE = {
77
+ /** Synthetic capability ID — present on every CommandLog / TestStats. */
78
+ CID: "0-0",
79
+ /** TestStats.retries default — adapters that surface retry counts override. */
80
+ RETRIES: 0,
81
+ /** _duration placeholder for not-yet-finalized stats. */
82
+ DURATION: 0
83
+ };
84
+
85
+ // ../shared/src/trace-actions.ts
21
86
  var ACTION_MAP = {
22
87
  // WDIO browser-level
23
88
  url: { class: "Page", method: "navigate" },
@@ -48,6 +113,25 @@ var ACTION_MAP = {
48
113
  switchToFrame: { class: "Frame", method: "goto" },
49
114
  touchAction: { class: "Element", method: "tap" }
50
115
  };
116
+
117
+ // ../shared/src/types.ts
118
+ var TEST_STATE = {
119
+ PENDING: "pending",
120
+ RUNNING: "running",
121
+ PASSED: "passed",
122
+ FAILED: "failed",
123
+ SKIPPED: "skipped"
124
+ };
125
+ var SCREENCAST_DEFAULTS = {
126
+ enabled: false,
127
+ captureFormat: "jpeg",
128
+ quality: 70,
129
+ maxWidth: 1280,
130
+ maxHeight: 720,
131
+ pollIntervalMs: 200
132
+ };
133
+
134
+ // ../core/src/action-mapping.ts
51
135
  function mapCommandToAction(command) {
52
136
  return ACTION_MAP[command] ?? null;
53
137
  }
@@ -1206,21 +1290,8 @@ function serializeWebSnapshot(nodes, context, options = {}) {
1206
1290
  }
1207
1291
  const indent = " ".repeat(node.depth + 1);
1208
1292
  const isInteractive = INTERACTIVE_ROLES.has(node.role);
1209
- if (node.role === "statictext" && node.name) {
1210
- let echoedByParent = false;
1211
- for (let j = i - 1; j >= 0; j--) {
1212
- if (nodes[j].depth < node.depth) {
1213
- const parentRole = nodes[j].role;
1214
- const parentName = nodes[j].name;
1215
- if (INTERACTIVE_ROLES.has(parentRole) && parentName && parentName.includes(node.name)) {
1216
- echoedByParent = true;
1217
- }
1218
- break;
1219
- }
1220
- }
1221
- if (echoedByParent) {
1222
- continue;
1223
- }
1293
+ if (isStatictextEchoedByParent(nodes, i)) {
1294
+ continue;
1224
1295
  }
1225
1296
  const roleLabel = node.role === "heading" && node.level ? `heading[${node.level}]` : node.role;
1226
1297
  if (isInteractive) {
@@ -1245,6 +1316,22 @@ function serializeWebSnapshot(nodes, context, options = {}) {
1245
1316
  }
1246
1317
  return lines.join("\n");
1247
1318
  }
1319
+ function isStatictextEchoedByParent(nodes, index) {
1320
+ const node = nodes[index];
1321
+ if (node.role !== "statictext" || !node.name) {
1322
+ return false;
1323
+ }
1324
+ for (let j = index - 1; j >= 0; j--) {
1325
+ if (nodes[j].depth < node.depth) {
1326
+ const parent = nodes[j];
1327
+ if (INTERACTIVE_ROLES.has(parent.role) && parent.name && parent.name.includes(node.name)) {
1328
+ return true;
1329
+ }
1330
+ break;
1331
+ }
1332
+ }
1333
+ return false;
1334
+ }
1248
1335
  function simplifyTag(tagName) {
1249
1336
  const dot = tagName.lastIndexOf(".");
1250
1337
  if (dot !== -1) {
@@ -1412,7 +1499,8 @@ function collectMobileNodes(element, platform, depth, nodes, walkOpts) {
1412
1499
  depth,
1413
1500
  isInteractive: false,
1414
1501
  isExplicitInteractive: false,
1415
- isInViewport: false
1502
+ isInViewport: false,
1503
+ tagName: element.tagName
1416
1504
  });
1417
1505
  return;
1418
1506
  }
@@ -1445,7 +1533,8 @@ function collectMobileNodes(element, platform, depth, nodes, walkOpts) {
1445
1533
  depth,
1446
1534
  isInteractive: interactive,
1447
1535
  isExplicitInteractive: explicit,
1448
- isInViewport: inViewport
1536
+ isInViewport: inViewport,
1537
+ tagName: element.tagName
1449
1538
  });
1450
1539
  for (const child of element.children || []) {
1451
1540
  collectMobileNodes(child, platform, depth + 1, nodes, walkOpts);
@@ -2060,15 +2149,46 @@ function buildContextOptions(trace, contextId, wallTime) {
2060
2149
  }
2061
2150
  };
2062
2151
  }
2063
- function buildActionEvents(commands, pageId, wallTime) {
2152
+ function buildActionEvents(commands, pageId, wallTime, testMetadata) {
2064
2153
  const events = [];
2065
2154
  let prevEndMs = 0;
2066
2155
  let callCounter = 0;
2156
+ let lastTestUid;
2157
+ let groupCallId;
2067
2158
  for (const cmd of commands) {
2068
2159
  const action = mapCommandToAction(cmd.command);
2069
2160
  if (!action) {
2070
2161
  continue;
2071
2162
  }
2163
+ if (cmd.testUid && cmd.testUid !== lastTestUid) {
2164
+ if (lastTestUid && groupCallId) {
2165
+ callCounter++;
2166
+ events.push({
2167
+ type: "after",
2168
+ callId: groupCallId,
2169
+ endTime: prevEndMs
2170
+ });
2171
+ }
2172
+ callCounter++;
2173
+ groupCallId = `call@${callCounter}`;
2174
+ const meta = testMetadata?.get(cmd.testUid);
2175
+ const groupName = meta?.title ?? cmd.testUid;
2176
+ events.push({
2177
+ type: "before",
2178
+ callId: groupCallId,
2179
+ startTime: Math.max(
2180
+ prevEndMs,
2181
+ (cmd.startTime ?? cmd.timestamp) - wallTime
2182
+ ),
2183
+ class: "Tracing",
2184
+ method: "tracingGroup",
2185
+ pageId,
2186
+ params: { name: groupName },
2187
+ title: groupName,
2188
+ apiName: "tracing.tracingGroup"
2189
+ });
2190
+ lastTestUid = cmd.testUid;
2191
+ }
2072
2192
  callCounter++;
2073
2193
  const callId = `call@${callCounter}`;
2074
2194
  const rawStartMs = (cmd.startTime ?? cmd.timestamp) - wallTime;
@@ -2098,7 +2218,8 @@ function buildActionEvents(commands, pageId, wallTime) {
2098
2218
  pageId,
2099
2219
  params,
2100
2220
  title: formatActionTitle(action, cmd.args, params),
2101
- apiName: `${action.class.toLowerCase()}.${action.method}`
2221
+ apiName: `${action.class.toLowerCase()}.${action.method}`,
2222
+ parentId: groupCallId
2102
2223
  });
2103
2224
  const afterEvent = {
2104
2225
  type: "after",
@@ -2112,6 +2233,14 @@ function buildActionEvents(commands, pageId, wallTime) {
2112
2233
  events.push(afterEvent);
2113
2234
  prevEndMs = endMs;
2114
2235
  }
2236
+ if (lastTestUid && groupCallId) {
2237
+ callCounter++;
2238
+ events.push({
2239
+ type: "after",
2240
+ callId: groupCallId,
2241
+ endTime: prevEndMs
2242
+ });
2243
+ }
2115
2244
  return events;
2116
2245
  }
2117
2246
  function buildNetworkNdjson(requests, wallTime, pageId) {
@@ -2268,7 +2397,7 @@ function buildTraceBundle(trace, opts = {}) {
2268
2397
  wallTime,
2269
2398
  viewport
2270
2399
  ),
2271
- ...buildActionEvents(trace.commands, pageId, wallTime)
2400
+ ...buildActionEvents(trace.commands, pageId, wallTime, opts.testMetadata)
2272
2401
  );
2273
2402
  events.sort(compareEvents);
2274
2403
  const ctxBName = ctxOptions.title;
@@ -2326,7 +2455,8 @@ async function writeTraceZip(capturer, opts) {
2326
2455
  await fs.mkdir(opts.outputDir, { recursive: true });
2327
2456
  const exportOpts = {
2328
2457
  sessionId: opts.sessionId,
2329
- wallTimeOverride: capturer.startWallTime
2458
+ wallTimeOverride: capturer.startWallTime,
2459
+ testMetadata: opts.testMetadata
2330
2460
  };
2331
2461
  if (opts.format === "ndjson-directory") {
2332
2462
  const dir = path.join(opts.outputDir, `trace-${opts.sessionId}`);
@@ -2424,6 +2554,15 @@ function runFfmpeg(ffmpeg, manifestPath, outputPath) {
2424
2554
  "cfr",
2425
2555
  "-r",
2426
2556
  "10",
2557
+ // Force a keyframe every frame. VP8 can only seek to keyframes, and
2558
+ // libvpx's default GOP places a single keyframe on near-static
2559
+ // screencast content — so scrubber/marker seeks snap back to the
2560
+ // start. At 10fps over short recordings, all-intra keeps seeking
2561
+ // frame-accurate at an acceptable size.
2562
+ "-g",
2563
+ "1",
2564
+ "-keyint_min",
2565
+ "1",
2427
2566
  "-auto-alt-ref",
2428
2567
  "0",
2429
2568
  "-disposition:v",
@@ -2487,7 +2626,8 @@ async function finalizeScreencast({
2487
2626
  videoPath,
2488
2627
  videoFile: fileName,
2489
2628
  frameCount: frames.length,
2490
- duration: recorder.duration
2629
+ duration: recorder.duration,
2630
+ startTime: frames[0]?.timestamp
2491
2631
  });
2492
2632
  } catch (err) {
2493
2633
  log12("warn", `Screencast encode failed: ${errorMessage(err)}`);
@@ -2644,88 +2784,6 @@ var RetryTracker = class {
2644
2784
  }
2645
2785
  };
2646
2786
 
2647
- // ../shared/src/files.ts
2648
- var SPEC_FILE_RE = /\.(?:test|spec)\.[cm]?[jt]sx?$/i;
2649
-
2650
- // ../shared/src/routes.ts
2651
- var WS_PATHS = {
2652
- /** Adapter session upgrade endpoint. One socket per running adapter. */
2653
- worker: "/worker",
2654
- /** App/UI client upgrade endpoint. Multiple browser tabs may connect. */
2655
- client: "/client"
2656
- };
2657
- var WS_SCOPE = {
2658
- /** Backend → worker: a dashboard client has subscribed. Wakes up the
2659
- * adapter's `await UI ready` gate before tests start. */
2660
- clientConnected: "clientConnected",
2661
- /** Backend → worker: the last dashboard tab closed. Triggers the
2662
- * interactive shutdown flow (close WS, exit, pkill the dashboard). */
2663
- clientDisconnected: "clientDisconnected",
2664
- /** Worker → backend → clients (or app-local): wipe the visualization data
2665
- * for a specific test/suite uid (or the whole tree). */
2666
- clearExecutionData: "clearExecutionData",
2667
- /** Worker → backend: clear-by-test-uid request (drives clearExecutionData). */
2668
- clearCommands: "clearCommands",
2669
- /** Backend → clients: signal that the active run was stopped by the UI. */
2670
- testStopped: "testStopped",
2671
- /** Worker → backend → clients: swap an earlier captured command for an
2672
- * updated entry (used when a retry coalesces commands). */
2673
- replaceCommand: "replaceCommand",
2674
- /** Worker → backend: register the config file path so reruns can spawn
2675
- * with the same config. */
2676
- config: "config"
2677
- };
2678
-
2679
- // ../shared/src/runner.ts
2680
- var REUSE_ENV = {
2681
- REUSE: "DEVTOOLS_APP_REUSE",
2682
- HOST: "DEVTOOLS_APP_HOST",
2683
- PORT: "DEVTOOLS_APP_PORT",
2684
- RERUN_LABEL: "DEVTOOLS_RERUN_LABEL",
2685
- RERUN_ENTRY_TYPE: "DEVTOOLS_RERUN_ENTRY_TYPE"
2686
- };
2687
-
2688
- // ../shared/src/timing.ts
2689
- var TIMING_BASE = {
2690
- /** ms to let the dashboard render between rapid state updates. */
2691
- UI_RENDER_DELAY: 150,
2692
- /** ms to wait after a test starts before flushing initial state. */
2693
- TEST_START_DELAY: 100,
2694
- /** ms gap between consecutive suite-finalize broadcasts. */
2695
- SUITE_COMPLETE_DELAY: 200,
2696
- /** ms allowed for the browser-close handshake before forcing teardown. */
2697
- BROWSER_CLOSE_WAIT: 2e3,
2698
- /** ms before first worker WS connection attempt — lets backend bind. */
2699
- INITIAL_CONNECTION_WAIT: 500,
2700
- /** ms between browser-window-alive polls. */
2701
- BROWSER_POLL_INTERVAL: 1e3
2702
- };
2703
- var DEFAULTS_BASE = {
2704
- /** Synthetic capability ID — present on every CommandLog / TestStats. */
2705
- CID: "0-0",
2706
- /** TestStats.retries default — adapters that surface retry counts override. */
2707
- RETRIES: 0,
2708
- /** _duration placeholder for not-yet-finalized stats. */
2709
- DURATION: 0
2710
- };
2711
-
2712
- // ../shared/src/types.ts
2713
- var TEST_STATE = {
2714
- PENDING: "pending",
2715
- RUNNING: "running",
2716
- PASSED: "passed",
2717
- FAILED: "failed",
2718
- SKIPPED: "skipped"
2719
- };
2720
- var SCREENCAST_DEFAULTS = {
2721
- enabled: false,
2722
- captureFormat: "jpeg",
2723
- quality: 70,
2724
- maxWidth: 1280,
2725
- maxHeight: 720,
2726
- pollIntervalMs: 200
2727
- };
2728
-
2729
2787
  // ../core/src/screencast.ts
2730
2788
  var ScreencastRecorderBase = class {
2731
2789
  buffer = [];
@@ -2951,9 +3009,12 @@ var SessionCapturerBase = class {
2951
3009
  metadata;
2952
3010
  // ── Construction ────────────────────────────────────────────────────────
2953
3011
  constructor(opts = {}) {
2954
- const { hostname, port } = opts;
3012
+ const { hostname, port, reconnect } = opts;
2955
3013
  if (hostname && port) {
2956
- this.ws = new WebSocket(`ws://${hostname}:${port}${WS_PATHS.worker}`);
3014
+ const query = reconnect ? "?reconnect=1" : "";
3015
+ this.ws = new WebSocket(
3016
+ `ws://${hostname}:${port}${WS_PATHS.worker}${query}`
3017
+ );
2957
3018
  this.ws.on("open", () => {
2958
3019
  this.#hasConnected = true;
2959
3020
  this.onWsOpen();
@@ -3285,6 +3346,100 @@ function safeStringify(value) {
3285
3346
  }
3286
3347
  }
3287
3348
 
3349
+ // ../core/src/spec-trace-helpers.ts
3350
+ function sanitizeSpecName(specFile) {
3351
+ return specFile.replace(/^.*[/\\]/, "").replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]/g, "_").replace(/^_+|_+$/g, "") || "unknown-spec";
3352
+ }
3353
+ function buildSpecSessionId(specFile, sessionId) {
3354
+ const base = sanitizeSpecName(specFile);
3355
+ const hash = deterministicUid(specFile).split("-").pop().slice(0, 8);
3356
+ return `${base}-${hash}-${sessionId.slice(0, 8)}`;
3357
+ }
3358
+ function buildSpecCapturer(capturer, range, nextRange) {
3359
+ const end = nextRange ? {
3360
+ commands: nextRange.commandStartIdx,
3361
+ console: nextRange.consoleStartIdx,
3362
+ network: nextRange.networkStartIdx,
3363
+ mutations: nextRange.mutationStartIdx,
3364
+ traceLogs: nextRange.traceLogStartIdx
3365
+ } : void 0;
3366
+ return {
3367
+ mutations: capturer.mutations.slice(range.mutationStartIdx, end?.mutations),
3368
+ traceLogs: capturer.traceLogs.slice(range.traceLogStartIdx, end?.traceLogs),
3369
+ consoleLogs: capturer.consoleLogs.slice(
3370
+ range.consoleStartIdx,
3371
+ end?.console
3372
+ ),
3373
+ networkRequests: capturer.networkRequests.slice(
3374
+ range.networkStartIdx,
3375
+ end?.network
3376
+ ),
3377
+ commandsLog: capturer.commandsLog.slice(
3378
+ range.commandStartIdx,
3379
+ end?.commands
3380
+ ),
3381
+ sources: new Map(capturer.sources),
3382
+ metadata: capturer.metadata,
3383
+ startWallTime: capturer.startWallTime
3384
+ };
3385
+ }
3386
+ function filterTestMetadataBySpec(allMetadata, specFile) {
3387
+ const filtered = /* @__PURE__ */ new Map();
3388
+ for (const [uid, meta] of allMetadata) {
3389
+ if (meta.specFile === specFile) {
3390
+ filtered.set(uid, meta);
3391
+ }
3392
+ }
3393
+ return filtered;
3394
+ }
3395
+ function recordSpecBoundary(ctx, specFile, traceGranularity) {
3396
+ if (traceGranularity !== "spec") {
3397
+ return null;
3398
+ }
3399
+ const lastRange = ctx.specRanges[ctx.specRanges.length - 1];
3400
+ if (lastRange && lastRange.specFile === specFile) {
3401
+ return null;
3402
+ }
3403
+ const prevRange = lastRange && !ctx.flushedSpecs.has(lastRange.specFile) ? lastRange : null;
3404
+ ctx.specRanges.push({
3405
+ specFile,
3406
+ commandStartIdx: ctx.capturer.commandsLog.length,
3407
+ consoleStartIdx: ctx.capturer.consoleLogs.length,
3408
+ networkStartIdx: ctx.capturer.networkRequests.length,
3409
+ mutationStartIdx: ctx.capturer.mutations.length,
3410
+ traceLogStartIdx: ctx.capturer.traceLogs.length,
3411
+ snapshotCount: ctx.actionSnapshots.length
3412
+ });
3413
+ return prevRange;
3414
+ }
3415
+ async function writeSpecTrace(input) {
3416
+ const specCapturer = buildSpecCapturer(
3417
+ input.capturer,
3418
+ input.range,
3419
+ input.nextRange
3420
+ );
3421
+ const specSnapshots = input.actionSnapshots.slice(
3422
+ input.range.snapshotCount,
3423
+ input.nextRange?.snapshotCount ?? input.actionSnapshots.length
3424
+ );
3425
+ const specSessionId = buildSpecSessionId(
3426
+ input.range.specFile,
3427
+ input.sessionId
3428
+ );
3429
+ const testMetadata = filterTestMetadataBySpec(
3430
+ input.testMetadata,
3431
+ input.range.specFile
3432
+ );
3433
+ return writeTraceZip(specCapturer, {
3434
+ outputDir: input.outputDir,
3435
+ sessionId: specSessionId,
3436
+ capabilities: input.capabilities,
3437
+ actionSnapshots: specSnapshots.length > 0 ? specSnapshots : void 0,
3438
+ format: input.format,
3439
+ testMetadata
3440
+ });
3441
+ }
3442
+
3288
3443
  // ../core/src/suite-helpers.ts
3289
3444
  function createSuiteStats(init) {
3290
3445
  return {
@@ -3824,6 +3979,13 @@ async function openDevtoolsBrowser(ctx, url) {
3824
3979
  "--no-first-run",
3825
3980
  "--no-default-browser-check"
3826
3981
  ]
3982
+ },
3983
+ // Dashboard uses the Puppeteer-based 'devtools' protocol; drop the
3984
+ // "controlled by automated test software" infobar by ignoring
3985
+ // Puppeteer's --enable-automation default. (Runtime-valid capability
3986
+ // not present in the type.)
3987
+ "wdio:devtoolsOptions": {
3988
+ ignoreDefaultArgs: ["--enable-automation"]
3827
3989
  }
3828
3990
  }
3829
3991
  });
@@ -4515,18 +4677,22 @@ function applyResponseReceived(p, response) {
4515
4677
  p.mimeType = response.mimeType;
4516
4678
  p.type = getRequestType(p.url, response.mimeType);
4517
4679
  }
4680
+ function eventTimeMs(params, entry) {
4681
+ return typeof params.timestamp === "number" ? Math.round(params.timestamp * 1e3) : entry.timestamp;
4682
+ }
4518
4683
  function startPendingRequest(params, entry, pending) {
4519
4684
  const { requestId, request: req, timestamp } = params;
4520
4685
  if (!req || typeof timestamp !== "number") {
4521
4686
  return;
4522
4687
  }
4688
+ const startTime = eventTimeMs(params, entry);
4523
4689
  pending.set(requestId, {
4524
4690
  id: `${entry.timestamp}-${requestId}`,
4525
4691
  url: req.url,
4526
4692
  method: req.method,
4527
4693
  requestHeaders: req.headers ?? {},
4528
- timestamp: Math.round(timestamp * 1e3),
4529
- startTime: entry.timestamp
4694
+ timestamp: startTime,
4695
+ startTime
4530
4696
  });
4531
4697
  }
4532
4698
  function handlePerfLogEvent(method, params, entry, pending, completed) {
@@ -4545,8 +4711,8 @@ function handlePerfLogEvent(method, params, entry, pending, completed) {
4545
4711
  const p = pending.get(params.requestId);
4546
4712
  if (p && p.status !== void 0) {
4547
4713
  p.size = params.encodedDataLength;
4548
- p.endTime = entry.timestamp;
4549
- p.time = entry.timestamp - p.startTime;
4714
+ p.endTime = eventTimeMs(params, entry);
4715
+ p.time = Math.max(0, p.endTime - p.startTime);
4550
4716
  completed.push({ ...p });
4551
4717
  pending.delete(params.requestId);
4552
4718
  }
@@ -4556,8 +4722,8 @@ function handlePerfLogEvent(method, params, entry, pending, completed) {
4556
4722
  const p = pending.get(params.requestId);
4557
4723
  if (p) {
4558
4724
  p.error = params.errorText;
4559
- p.endTime = entry.timestamp;
4560
- p.time = entry.timestamp - p.startTime;
4725
+ p.endTime = eventTimeMs(params, entry);
4726
+ p.time = Math.max(0, p.endTime - p.startTime);
4561
4727
  completed.push({ ...p });
4562
4728
  pending.delete(params.requestId);
4563
4729
  }
@@ -5754,7 +5920,7 @@ async function tryAttachBidi(ctx, browser) {
5754
5920
  log10.warn("bidi:true set but browser.driver unavailable \u2014 skipping");
5755
5921
  return;
5756
5922
  }
5757
- const { attachBidiHandlers, buildBidiSinks } = await import("./bidi-OH4ZFDN5.js");
5923
+ const { attachBidiHandlers, buildBidiSinks } = await import("./bidi-FIHBBGT4.js");
5758
5924
  const ok = await attachBidiHandlers(
5759
5925
  driver,
5760
5926
  buildBidiSinks(ctx.sessionCapturer)
@@ -5790,7 +5956,13 @@ async function ensureSessionInitialized(ctx, browser, finalizeCurrentScreencast2
5790
5956
  (resolve2) => setTimeout(resolve2, TIMING.INITIAL_CONNECTION_WAIT)
5791
5957
  );
5792
5958
  ctx.sessionCapturer = new SessionCapturer(
5793
- ctx.mode === "trace" ? {} : { port: ctx.port, hostname: ctx.hostname },
5959
+ ctx.mode === "trace" ? {} : {
5960
+ port: ctx.port,
5961
+ hostname: ctx.hostname,
5962
+ // A session change reopens the WS mid-run — tell the backend to keep
5963
+ // the accumulated run state so earlier tests' commands survive.
5964
+ reconnect: Boolean(isSessionChange)
5965
+ },
5794
5966
  browser
5795
5967
  );
5796
5968
  ctx.sessionCapturer.traceMode = ctx.mode;
@@ -5851,6 +6023,10 @@ var NightwatchDevToolsPlugin = class {
5851
6023
  #skipCount = 0;
5852
6024
  #configPath;
5853
6025
  #srcFolders = [];
6026
+ /** Index ranges into the session capturer's flat arrays, one per spec file. */
6027
+ #specRanges = [];
6028
+ /** Set of spec files already flushed to disk. */
6029
+ #flushedSpecs = /* @__PURE__ */ new Set();
5854
6030
  #getRerunLabel() {
5855
6031
  return process.env[REUSE_ENV.RERUN_ENTRY_TYPE] === "test" ? process.env[REUSE_ENV.RERUN_LABEL]?.trim() : void 0;
5856
6032
  }
@@ -5872,7 +6048,8 @@ var NightwatchDevToolsPlugin = class {
5872
6048
  screencast,
5873
6049
  bidi: options.bidi ?? false,
5874
6050
  mode,
5875
- traceFormat: options.traceFormat ?? "zip"
6051
+ traceFormat: options.traceFormat ?? "zip",
6052
+ traceGranularity: options.traceGranularity ?? "session"
5876
6053
  };
5877
6054
  this.#screencastOptions = { ...SCREENCAST_DEFAULTS, ...screencast };
5878
6055
  this.#bidiEnabled = options.bidi === true;
@@ -6130,6 +6307,25 @@ var NightwatchDevToolsPlugin = class {
6130
6307
  this.sessionCapturer.captureSource(fullPath).catch(() => {
6131
6308
  });
6132
6309
  }
6310
+ if (fullPath) {
6311
+ const prevRange = recordSpecBoundary(
6312
+ {
6313
+ specRanges: this.#specRanges,
6314
+ flushedSpecs: this.#flushedSpecs,
6315
+ capturer: this.sessionCapturer,
6316
+ actionSnapshots: this.sessionCapturer.actionSnapshots
6317
+ },
6318
+ fullPath,
6319
+ this.options.traceGranularity
6320
+ );
6321
+ if (prevRange) {
6322
+ void this.#flushSpecTrace(prevRange).catch(
6323
+ (err) => log11.warn(
6324
+ `Failed to flush trace for spec "${prevRange.specFile}": ${errorMessage(err)}`
6325
+ )
6326
+ );
6327
+ }
6328
+ }
6133
6329
  await this.#closePreviousRunningTest(currentSuite, testFile, currentTest);
6134
6330
  const processedTests = this.testManager.getProcessedTests(testFile);
6135
6331
  const currentTestName = this.#pickCurrentTestName(
@@ -6187,6 +6383,42 @@ var NightwatchDevToolsPlugin = class {
6187
6383
  #logRunSummary() {
6188
6384
  logRunSummary(this.#getInternals());
6189
6385
  }
6386
+ async #flushSpecTrace(range, nextRange) {
6387
+ if (this.#flushedSpecs.has(range.specFile)) {
6388
+ return void 0;
6389
+ }
6390
+ this.#flushedSpecs.add(range.specFile);
6391
+ const sessionId = this.sessionCapturer.metadata?.sessionId;
6392
+ if (!sessionId) {
6393
+ return void 0;
6394
+ }
6395
+ const allMetadata = /* @__PURE__ */ new Map();
6396
+ if (this.suiteManager) {
6397
+ for (const suite of this.suiteManager.getAllSuites().values()) {
6398
+ for (const entry of suite.tests) {
6399
+ if (typeof entry === "string") {
6400
+ continue;
6401
+ }
6402
+ allMetadata.set(entry.uid, {
6403
+ title: entry.fullTitle,
6404
+ specFile: entry.file
6405
+ });
6406
+ }
6407
+ }
6408
+ }
6409
+ const tracePath = await writeSpecTrace({
6410
+ range,
6411
+ nextRange,
6412
+ capturer: this.sessionCapturer,
6413
+ actionSnapshots: this.sessionCapturer.actionSnapshots,
6414
+ sessionId,
6415
+ outputDir: resolveAdapterOutputDir({ configPath: this.#configPath }),
6416
+ format: this.options.traceFormat,
6417
+ testMetadata: allMetadata
6418
+ });
6419
+ log11.info(`Trace for spec "${range.specFile}" saved to ${tracePath}`);
6420
+ return tracePath;
6421
+ }
6190
6422
  async #writeTraceZipIfNeeded() {
6191
6423
  if (this.options.mode !== "trace" || !this.sessionCapturer) {
6192
6424
  return;
@@ -6199,6 +6431,28 @@ var NightwatchDevToolsPlugin = class {
6199
6431
  if (this.sessionCapturer.snapshotCaptures.length) {
6200
6432
  await Promise.allSettled(this.sessionCapturer.snapshotCaptures);
6201
6433
  }
6434
+ if (this.options.traceGranularity === "spec") {
6435
+ for (const range of this.#specRanges) {
6436
+ if (!this.#flushedSpecs.has(range.specFile)) {
6437
+ await this.#flushSpecTrace(range);
6438
+ }
6439
+ }
6440
+ return;
6441
+ }
6442
+ const testMetadata = /* @__PURE__ */ new Map();
6443
+ if (this.suiteManager) {
6444
+ for (const suite of this.suiteManager.getAllSuites().values()) {
6445
+ for (const entry of suite.tests) {
6446
+ if (typeof entry === "string") {
6447
+ continue;
6448
+ }
6449
+ testMetadata.set(entry.uid, {
6450
+ title: entry.fullTitle,
6451
+ specFile: entry.file
6452
+ });
6453
+ }
6454
+ }
6455
+ }
6202
6456
  const snapshots = this.sessionCapturer.actionSnapshots;
6203
6457
  const tracePath = await writeTraceZip(this.sessionCapturer, {
6204
6458
  outputDir: resolveAdapterOutputDir({
@@ -6206,7 +6460,8 @@ var NightwatchDevToolsPlugin = class {
6206
6460
  }),
6207
6461
  sessionId,
6208
6462
  actionSnapshots: snapshots.length ? snapshots : void 0,
6209
- format: this.options.traceFormat
6463
+ format: this.options.traceFormat,
6464
+ testMetadata
6210
6465
  });
6211
6466
  log11.info(`Trace saved to ${tracePath}`);
6212
6467
  } catch (err) {