@wdio/nightwatch-devtools 1.5.1 → 1.6.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
@@ -12,7 +12,7 @@ import {
12
12
  serializeError,
13
13
  stripAnsi,
14
14
  toError
15
- } from "./chunk-DKSBYMPM.js";
15
+ } from "./chunk-DNCL4MP7.js";
16
16
 
17
17
  // src/index.ts
18
18
  import { fileURLToPath } from "url";
@@ -58,6 +58,12 @@ var REUSE_ENV = {
58
58
  RERUN_ENTRY_TYPE: "DEVTOOLS_RERUN_ENTRY_TYPE"
59
59
  };
60
60
 
61
+ // ../shared/src/snapshot-format.ts
62
+ var SNAPSHOT_INDENT_UNIT = " ";
63
+ var SNAPSHOT_PAGE_HEADER = "[Page";
64
+ var SNAPSHOT_LOCATOR_DELIM = "\u2192";
65
+ var SNAPSHOT_PURPOSE_TOKEN = "\u2208";
66
+
61
67
  // ../shared/src/timing.ts
62
68
  var TIMING_BASE = {
63
69
  /** ms to let the dashboard render between rapid state updates. */
@@ -83,6 +89,30 @@ var DEFAULTS_BASE = {
83
89
  };
84
90
 
85
91
  // ../shared/src/trace-actions.ts
92
+ var ASSERT_ACTION_CLASS = "Assert";
93
+ var TRACKED_ASSERT_METHODS = [
94
+ "equal",
95
+ "strictEqual",
96
+ "deepEqual",
97
+ "deepStrictEqual",
98
+ "notEqual",
99
+ "notStrictEqual",
100
+ "notDeepEqual",
101
+ "notDeepStrictEqual",
102
+ "ok",
103
+ "fail",
104
+ "throws",
105
+ "doesNotThrow",
106
+ "rejects",
107
+ "doesNotReject",
108
+ "match",
109
+ "doesNotMatch"
110
+ ];
111
+ var ASSERT_COMMAND_RE = /^(?:assert|verify|expect)\.(\w+)$/;
112
+ function mapAssertCommand(command) {
113
+ const match = ASSERT_COMMAND_RE.exec(command);
114
+ return match ? { class: ASSERT_ACTION_CLASS, method: match[1] } : null;
115
+ }
86
116
  var ACTION_MAP = {
87
117
  // WDIO browser-level
88
118
  url: { class: "Page", method: "navigate" },
@@ -111,8 +141,51 @@ var ACTION_MAP = {
111
141
  execute: { class: "Page", method: "evaluate" },
112
142
  executeAsync: { class: "Page", method: "evaluate" },
113
143
  switchToFrame: { class: "Frame", method: "goto" },
114
- touchAction: { class: "Element", method: "tap" }
144
+ touchAction: { class: "Element", method: "tap" },
145
+ // WDIO element reads — surfaced so query steps appear in the timeline the way
146
+ // locator queries do in standard trace viewers. Adapters already capture
147
+ // these; only the export allow-list kept them out.
148
+ getText: { class: "Element", method: "getText" },
149
+ getValue: { class: "Element", method: "getValue" },
150
+ getAttribute: { class: "Element", method: "getAttribute" },
151
+ getProperty: { class: "Element", method: "getProperty" },
152
+ getCSSProperty: { class: "Element", method: "getCSSProperty" },
153
+ getTagName: { class: "Element", method: "getTagName" },
154
+ getLocation: { class: "Element", method: "getLocation" },
155
+ getSize: { class: "Element", method: "getSize" },
156
+ isDisplayed: { class: "Element", method: "isDisplayed" },
157
+ isExisting: { class: "Element", method: "isExisting" },
158
+ isEnabled: { class: "Element", method: "isEnabled" },
159
+ isSelected: { class: "Element", method: "isSelected" },
160
+ isClickable: { class: "Element", method: "isClickable" },
161
+ isFocused: { class: "Element", method: "isFocused" },
162
+ // Explicit user-facing waits (not the internal polling loops behind them).
163
+ waitForDisplayed: { class: "Element", method: "waitForDisplayed" },
164
+ waitForExist: { class: "Element", method: "waitForExist" },
165
+ waitForEnabled: { class: "Element", method: "waitForEnabled" },
166
+ waitForClickable: { class: "Element", method: "waitForClickable" },
167
+ waitUntil: { class: "Browser", method: "waitForFunction" },
168
+ // WDIO page/browser reads
169
+ getTitle: { class: "Page", method: "getTitle" },
170
+ getUrl: { class: "Page", method: "getUrl" },
171
+ getPageSource: { class: "Page", method: "getPageSource" },
172
+ // Selenium read aliases — normalized onto the WDIO names above so both runners
173
+ // read identically. getText/getAttribute/getTagName/isDisplayed/isEnabled/
174
+ // isSelected share the command name across runners and need no alias.
175
+ getCssValue: { class: "Element", method: "getCSSProperty" },
176
+ getRect: { class: "Element", method: "getRect" },
177
+ getCurrentUrl: { class: "Page", method: "getUrl" }
115
178
  };
179
+ var POINTABLE_METHODS = /* @__PURE__ */ new Set([
180
+ "click",
181
+ "dblclick",
182
+ "hover",
183
+ "tap",
184
+ "dragTo",
185
+ "scrollIntoViewIfNeeded",
186
+ "selectOption",
187
+ "fill"
188
+ ]);
116
189
 
117
190
  // ../shared/src/types.ts
118
191
  var TEST_STATE = {
@@ -128,14 +201,37 @@ var SCREENCAST_DEFAULTS = {
128
201
  quality: 70,
129
202
  maxWidth: 1280,
130
203
  maxHeight: 720,
131
- pollIntervalMs: 200
204
+ pollIntervalMs: 200,
205
+ maxBufferFrames: 2e3
132
206
  };
133
207
 
134
208
  // ../core/src/action-mapping.ts
135
209
  function mapCommandToAction(command) {
136
- return ACTION_MAP[command] ?? null;
210
+ return ACTION_MAP[command] ?? mapAssertCommand(command);
211
+ }
212
+ var ASSERT_TITLE_VALUE_MAX = 40;
213
+ function formatAssertValue(value) {
214
+ let text;
215
+ if (typeof value === "object" && value !== null) {
216
+ try {
217
+ text = JSON.stringify(value);
218
+ } catch {
219
+ text = String(value);
220
+ }
221
+ } else {
222
+ text = typeof value === "string" ? JSON.stringify(value) : String(value);
223
+ }
224
+ return text.length > ASSERT_TITLE_VALUE_MAX ? `${text.slice(0, ASSERT_TITLE_VALUE_MAX - 1)}\u2026` : text;
137
225
  }
138
- function formatActionTitle(action, args, params) {
226
+ function formatAssertTitle(action, args, params, command) {
227
+ const values = args.length > 0 ? args.slice(0, 2) : [params?.actual, params?.expected];
228
+ const label = values.filter((value) => value !== void 0).map(formatAssertValue).join(", ");
229
+ return `${command ?? `assert.${action.method}`}(${label})`;
230
+ }
231
+ function formatActionTitle(action, args, params, command) {
232
+ if (action.class === ASSERT_ACTION_CLASS) {
233
+ return formatAssertTitle(action, args, params, command);
234
+ }
139
235
  const firstArg = args[0] ?? params?.selector;
140
236
  if (firstArg === void 0) {
141
237
  return `${action.class}.${action.method}()`;
@@ -521,14 +617,14 @@ function translateRecursively(domNode, parentPath = "", index = null) {
521
617
  }
522
618
  }
523
619
  }
524
- const path10 = index === null ? "" : `${parentPath ? parentPath + "." : ""}${index}`;
620
+ const path14 = index === null ? "" : `${parentPath ? parentPath + "." : ""}${index}`;
525
621
  return {
526
622
  children: childNodesOf(domNode).map(
527
- (childNode, childIndex) => translateRecursively(childNode, path10, childIndex)
623
+ (childNode, childIndex) => translateRecursively(childNode, path14, childIndex)
528
624
  ),
529
625
  tagName: domNode.nodeName,
530
626
  attributes,
531
- path: path10
627
+ path: path14
532
628
  };
533
629
  }
534
630
  function isSameElement(node1, node2) {
@@ -665,11 +761,11 @@ function checkXPathUniqueness(doc, xpathExpr, targetNode) {
665
761
  return { isUnique: false };
666
762
  }
667
763
  }
668
- function findDOMNodeByPath(doc, path10) {
669
- if (!path10) {
764
+ function findDOMNodeByPath(doc, path14) {
765
+ if (!path14) {
670
766
  return doc.documentElement;
671
767
  }
672
- const indices = path10.split(".").map(Number);
768
+ const indices = path14.split(".").map(Number);
673
769
  let current = doc.documentElement;
674
770
  for (const index of indices) {
675
771
  if (!current) {
@@ -1310,7 +1406,7 @@ function inferPurpose(nodes, index) {
1310
1406
  }
1311
1407
  function serializeWebSnapshot(nodes, context, options = {}) {
1312
1408
  const { inViewportOnly = true } = options;
1313
- let header = "[Page";
1409
+ let header = SNAPSHOT_PAGE_HEADER;
1314
1410
  if (context?.title) {
1315
1411
  header += `: ${context.title}`;
1316
1412
  }
@@ -1324,7 +1420,7 @@ function serializeWebSnapshot(nodes, context, options = {}) {
1324
1420
  if (inViewportOnly && node.isInViewport === false) {
1325
1421
  continue;
1326
1422
  }
1327
- const indent = " ".repeat(node.depth + 1);
1423
+ const indent = SNAPSHOT_INDENT_UNIT.repeat(node.depth + 1);
1328
1424
  const isInteractive = INTERACTIVE_ROLES.has(node.role);
1329
1425
  if (isStatictextEchoedByParent(nodes, i)) {
1330
1426
  continue;
@@ -1337,12 +1433,16 @@ function serializeWebSnapshot(nodes, context, options = {}) {
1337
1433
  const purpose = inferPurpose(nodes, i);
1338
1434
  if (node.name) {
1339
1435
  lines.push(
1340
- purpose ? `${indent}${roleLabel} "${node.name}" \u2208 "${purpose}" \u2192 ${node.selector}` : `${indent}${roleLabel} "${node.name}" \u2192 ${node.selector}`
1436
+ purpose ? `${indent}${roleLabel} "${node.name}" ${SNAPSHOT_PURPOSE_TOKEN} "${purpose}" ${SNAPSHOT_LOCATOR_DELIM} ${node.selector}` : `${indent}${roleLabel} "${node.name}" ${SNAPSHOT_LOCATOR_DELIM} ${node.selector}`
1341
1437
  );
1342
1438
  } else if (purpose) {
1343
- lines.push(`${indent}${roleLabel} \u2208 "${purpose}" \u2192 ${node.selector}`);
1439
+ lines.push(
1440
+ `${indent}${roleLabel} ${SNAPSHOT_PURPOSE_TOKEN} "${purpose}" ${SNAPSHOT_LOCATOR_DELIM} ${node.selector}`
1441
+ );
1344
1442
  } else {
1345
- lines.push(`${indent}${roleLabel} \u2192 ${node.selector}`);
1443
+ lines.push(
1444
+ `${indent}${roleLabel} ${SNAPSHOT_LOCATOR_DELIM} ${node.selector}`
1445
+ );
1346
1446
  }
1347
1447
  } else {
1348
1448
  lines.push(
@@ -1660,7 +1760,7 @@ function renderMobileNodes(nodes) {
1660
1760
  const selectorIndex = /* @__PURE__ */ new Map();
1661
1761
  for (let i = 0; i < nodes.length; i++) {
1662
1762
  const node = nodes[i];
1663
- const indent = " ".repeat(node.depth + 1);
1763
+ const indent = SNAPSHOT_INDENT_UNIT.repeat(node.depth + 1);
1664
1764
  if (NOISY_ROLES.has(node.role) && !node.name && node.depth > 1 && !node.isInteractive) {
1665
1765
  continue;
1666
1766
  }
@@ -1693,12 +1793,16 @@ function renderMobileNodes(nodes) {
1693
1793
  const purpose = mobileInferPurpose(nodes, i);
1694
1794
  if (node.name) {
1695
1795
  lines.push(
1696
- purpose ? `${indent}${node.role} "${node.name}" \u2208 "${purpose}" \u2192 ${selector}` : `${indent}${node.role} "${node.name}" \u2192 ${selector}`
1796
+ purpose ? `${indent}${node.role} "${node.name}" ${SNAPSHOT_PURPOSE_TOKEN} "${purpose}" ${SNAPSHOT_LOCATOR_DELIM} ${selector}` : `${indent}${node.role} "${node.name}" ${SNAPSHOT_LOCATOR_DELIM} ${selector}`
1697
1797
  );
1698
1798
  } else if (purpose) {
1699
- lines.push(`${indent}${node.role} \u2208 "${purpose}" \u2192 ${selector}`);
1799
+ lines.push(
1800
+ `${indent}${node.role} ${SNAPSHOT_PURPOSE_TOKEN} "${purpose}" ${SNAPSHOT_LOCATOR_DELIM} ${selector}`
1801
+ );
1700
1802
  } else {
1701
- lines.push(`${indent}${node.role} \u2192 ${selector}`);
1803
+ lines.push(
1804
+ `${indent}${node.role} ${SNAPSHOT_LOCATOR_DELIM} ${selector}`
1805
+ );
1702
1806
  }
1703
1807
  } else {
1704
1808
  lines.push(
@@ -2003,7 +2107,8 @@ async function captureActionSnapshot(input) {
2003
2107
  ),
2004
2108
  runWith(
2005
2109
  input.runScript,
2006
- elementsScript(false, true),
2110
+ // includeBounds: the per-action element rects drive A8 input points.
2111
+ elementsScript(true, true),
2007
2112
  []
2008
2113
  )
2009
2114
  ]);
@@ -2050,6 +2155,443 @@ async function captureActionSnapshot(input) {
2050
2155
  }
2051
2156
  }
2052
2157
 
2158
+ // ../core/src/artifact-naming.ts
2159
+ function trimChar(value, char) {
2160
+ let start2 = 0;
2161
+ let end = value.length;
2162
+ while (start2 < end && value[start2] === char) {
2163
+ start2++;
2164
+ }
2165
+ while (end > start2 && value[end - 1] === char) {
2166
+ end--;
2167
+ }
2168
+ return value.slice(start2, end);
2169
+ }
2170
+ function fileSlug(value) {
2171
+ return trimChar(value.replace(/[^a-zA-Z0-9_-]+/g, "-"), "-");
2172
+ }
2173
+
2174
+ // ../core/src/artifacts-manifest.ts
2175
+ import fs from "fs/promises";
2176
+ import path from "path";
2177
+ function artifactsManifestFilename(sessionId) {
2178
+ return `devtools-artifacts-${sessionId}.json`;
2179
+ }
2180
+ function buildArtifactsManifest(input) {
2181
+ const tests = [];
2182
+ for (const [uid, meta] of input.testMetadata) {
2183
+ tests.push({
2184
+ uid,
2185
+ title: meta.title,
2186
+ specFile: meta.specFile,
2187
+ state: meta.state,
2188
+ attempt: meta.attempt
2189
+ });
2190
+ }
2191
+ return {
2192
+ sessionId: input.sessionId,
2193
+ format: input.format,
2194
+ artifacts: [...input.artifacts],
2195
+ tests
2196
+ };
2197
+ }
2198
+ async function writeArtifactsManifest(outputDir, manifest) {
2199
+ await fs.mkdir(outputDir, { recursive: true });
2200
+ const filePath = path.join(
2201
+ outputDir,
2202
+ artifactsManifestFilename(manifest.sessionId)
2203
+ );
2204
+ await fs.writeFile(filePath, JSON.stringify(manifest, null, 2), "utf8");
2205
+ return filePath;
2206
+ }
2207
+
2208
+ // ../core/src/allure-artifacts.ts
2209
+ import fs5 from "fs/promises";
2210
+ import { basename } from "path";
2211
+
2212
+ // ../core/src/screenshot-artifact.ts
2213
+ import fs2 from "fs/promises";
2214
+ import path2 from "path";
2215
+ function shouldCaptureScreenshot(policy, failed) {
2216
+ if (policy === "on") {
2217
+ return true;
2218
+ }
2219
+ if (policy === "only-on-failure") {
2220
+ return failed;
2221
+ }
2222
+ return false;
2223
+ }
2224
+ async function writeScreenshotArtifact(input) {
2225
+ await fs2.mkdir(input.outputDir, { recursive: true });
2226
+ const filename = `screenshot-${fileSlug(input.testUid)}-${input.sessionId.slice(0, 8)}.png`;
2227
+ const filePath = path2.join(input.outputDir, filename);
2228
+ await fs2.writeFile(filePath, Buffer.from(input.base64, "base64"));
2229
+ return {
2230
+ kind: "screenshot",
2231
+ path: filePath,
2232
+ scope: "test",
2233
+ key: input.testUid,
2234
+ testUids: [input.testUid],
2235
+ retained: true
2236
+ };
2237
+ }
2238
+
2239
+ // ../core/src/video-slice.ts
2240
+ import fs4 from "fs/promises";
2241
+ import path4 from "path";
2242
+
2243
+ // ../core/src/video-encoder.ts
2244
+ import fs3 from "fs/promises";
2245
+ import path3 from "path";
2246
+ import os from "os";
2247
+ import { createRequire } from "module";
2248
+ var require2 = createRequire(import.meta.url);
2249
+ function loadFfmpeg() {
2250
+ try {
2251
+ return require2("fluent-ffmpeg");
2252
+ } catch {
2253
+ throw new Error(
2254
+ "VideoEncoder: fluent-ffmpeg is required for screencast encoding. Install it with: npm install fluent-ffmpeg"
2255
+ );
2256
+ }
2257
+ }
2258
+ async function writeFramesAndManifest(frames, tmpDir, ext) {
2259
+ const manifestLines = ["ffconcat version 1.0"];
2260
+ for (let i = 0; i < frames.length; i++) {
2261
+ const frameName = `frame-${String(i).padStart(6, "0")}.${ext}`;
2262
+ const framePath = path3.join(tmpDir, frameName);
2263
+ await fs3.writeFile(framePath, Buffer.from(frames[i].data, "base64"));
2264
+ const nextTs = frames[i + 1]?.timestamp ?? frames[i].timestamp + 100;
2265
+ const durationSecs = Math.max((nextTs - frames[i].timestamp) / 1e3, 0.01);
2266
+ manifestLines.push(`file '${framePath}'`);
2267
+ manifestLines.push(`duration ${durationSecs.toFixed(6)}`);
2268
+ }
2269
+ const lastFramePath = path3.join(
2270
+ tmpDir,
2271
+ `frame-${String(frames.length - 1).padStart(6, "0")}.${ext}`
2272
+ );
2273
+ manifestLines.push(`file '${lastFramePath}'`);
2274
+ const manifestPath = path3.join(tmpDir, "manifest.txt");
2275
+ await fs3.writeFile(manifestPath, manifestLines.join("\n"));
2276
+ return manifestPath;
2277
+ }
2278
+ function classifyFfmpegError(err) {
2279
+ const msg = err.message || "";
2280
+ if (msg.includes("Cannot find ffmpeg") || msg.includes("ENOENT") || msg.includes("spawn") || msg.includes("not found")) {
2281
+ return new Error(
2282
+ "VideoEncoder: ffmpeg binary not found on PATH. Install ffmpeg: https://ffmpeg.org/download.html"
2283
+ );
2284
+ }
2285
+ return new Error(`VideoEncoder: ffmpeg error \u2014 ${msg}`);
2286
+ }
2287
+ function runFfmpeg(ffmpeg, manifestPath, outputPath) {
2288
+ return new Promise((resolve2, reject) => {
2289
+ ffmpeg().input(manifestPath).inputOptions(["-f", "concat", "-safe", "0"]).videoCodec("libvpx").outputOptions([
2290
+ "-b:v",
2291
+ "1M",
2292
+ "-pix_fmt",
2293
+ "yuv420p",
2294
+ "-vsync",
2295
+ "cfr",
2296
+ "-r",
2297
+ "10",
2298
+ // Force a keyframe every frame. VP8 can only seek to keyframes, and
2299
+ // libvpx's default GOP places a single keyframe on near-static
2300
+ // screencast content — so scrubber/marker seeks snap back to the
2301
+ // start. At 10fps over short recordings, all-intra keeps seeking
2302
+ // frame-accurate at an acceptable size.
2303
+ "-g",
2304
+ "1",
2305
+ "-keyint_min",
2306
+ "1",
2307
+ "-auto-alt-ref",
2308
+ "0",
2309
+ "-disposition:v",
2310
+ "default"
2311
+ ]).output(outputPath).on("end", () => resolve2()).on("error", (err) => reject(classifyFfmpegError(err))).run();
2312
+ });
2313
+ }
2314
+ async function encodeToVideo(frames, outputPath, options = {}) {
2315
+ if (frames.length === 0) {
2316
+ throw new Error("VideoEncoder: no frames to encode");
2317
+ }
2318
+ const ffmpeg = loadFfmpeg();
2319
+ const ext = options.captureFormat === "png" ? "png" : "jpg";
2320
+ const tmpDir = await fs3.mkdtemp(
2321
+ path3.join(os.tmpdir(), "devtools-screencast-")
2322
+ );
2323
+ try {
2324
+ const manifestPath = await writeFramesAndManifest(frames, tmpDir, ext);
2325
+ await runFfmpeg(ffmpeg, manifestPath, outputPath);
2326
+ } finally {
2327
+ await fs3.rm(tmpDir, { recursive: true, force: true }).catch(() => {
2328
+ });
2329
+ }
2330
+ }
2331
+
2332
+ // ../core/src/video-slice.ts
2333
+ function sliceFramesFrom(frames, startWallTime) {
2334
+ return frames.filter((f) => f.timestamp >= startWallTime);
2335
+ }
2336
+ async function encodePerTestVideo(input) {
2337
+ const minFrames = input.minFrames ?? 2;
2338
+ if (input.frames.length < minFrames) {
2339
+ return void 0;
2340
+ }
2341
+ await fs4.mkdir(input.outputDir, { recursive: true });
2342
+ const retrySuffix = input.attempt ? `-retry${input.attempt}` : "";
2343
+ const filename = `video-${fileSlug(input.testUid)}-${input.sessionId.slice(0, 8)}${retrySuffix}.webm`;
2344
+ const filePath = path4.join(input.outputDir, filename);
2345
+ try {
2346
+ await encodeToVideo([...input.frames], filePath, {
2347
+ captureFormat: input.captureFormat
2348
+ });
2349
+ } catch (err) {
2350
+ input.onLog?.("warn", `Per-test video encode failed: ${errorMessage(err)}`);
2351
+ return void 0;
2352
+ }
2353
+ return {
2354
+ kind: "video",
2355
+ path: filePath,
2356
+ scope: "test",
2357
+ key: input.testUid,
2358
+ testUids: [input.testUid],
2359
+ retained: true
2360
+ };
2361
+ }
2362
+
2363
+ // ../core/src/trace-retention.ts
2364
+ var KNOWN_POLICIES = /* @__PURE__ */ new Set([
2365
+ "on",
2366
+ "retain-on-failure",
2367
+ "retain-on-first-failure",
2368
+ "on-first-retry",
2369
+ "on-all-retries",
2370
+ "retain-on-failure-and-retries"
2371
+ ]);
2372
+ function shouldRetainTrace(policy, input) {
2373
+ if (policy === void 0 || policy === "on" || !KNOWN_POLICIES.has(policy)) {
2374
+ return { retain: true };
2375
+ }
2376
+ const outcomes = Array.from(input.outcomes);
2377
+ if (outcomes.length === 0) {
2378
+ return { retain: true, failOpen: true };
2379
+ }
2380
+ const anyFailed = outcomes.some((o) => o.state === "failed");
2381
+ if (!input.attemptInfoAvailable && policy !== "retain-on-failure") {
2382
+ return { retain: anyFailed, degradedToFailure: true };
2383
+ }
2384
+ const groups = groupByTest(outcomes);
2385
+ switch (policy) {
2386
+ case "retain-on-failure":
2387
+ return { retain: groups.some((g) => finalAttempt(g).state === "failed") };
2388
+ case "retain-on-first-failure":
2389
+ return {
2390
+ retain: groups.some((g) => firstAttempt(g)?.state === "failed")
2391
+ };
2392
+ case "on-first-retry":
2393
+ return { retain: groups.some((g) => g.some((o) => o.attempt === 1)) };
2394
+ case "on-all-retries":
2395
+ return { retain: groups.some((g) => g.some((o) => attemptOf(o) >= 1)) };
2396
+ case "retain-on-failure-and-retries":
2397
+ return {
2398
+ retain: groups.some(
2399
+ (g) => finalAttempt(g).state === "failed" || g.some((o) => attemptOf(o) >= 1)
2400
+ )
2401
+ };
2402
+ }
2403
+ }
2404
+ var attemptOf = (o) => o.attempt ?? 0;
2405
+ function groupByTest(outcomes) {
2406
+ const byUid = /* @__PURE__ */ new Map();
2407
+ const groups = [];
2408
+ for (const outcome of outcomes) {
2409
+ if (outcome.uid === void 0) {
2410
+ groups.push([outcome]);
2411
+ continue;
2412
+ }
2413
+ const existing = byUid.get(outcome.uid);
2414
+ if (existing) {
2415
+ existing.push(outcome);
2416
+ } else {
2417
+ const group = [outcome];
2418
+ byUid.set(outcome.uid, group);
2419
+ groups.push(group);
2420
+ }
2421
+ }
2422
+ return groups;
2423
+ }
2424
+ function finalAttempt(group) {
2425
+ return group.reduce((best, o) => attemptOf(o) >= attemptOf(best) ? o : best);
2426
+ }
2427
+ function firstAttempt(group) {
2428
+ return group.find((o) => attemptOf(o) === 0);
2429
+ }
2430
+ function tracePolicyModeWarning(policy, mode) {
2431
+ if (policy === void 0 || mode === "trace") {
2432
+ return void 0;
2433
+ }
2434
+ return 'tracePolicy only applies in trace mode; ignoring it because mode is not "trace".';
2435
+ }
2436
+
2437
+ // ../core/src/allure-artifacts.ts
2438
+ var CONTENT_TYPE_BY_KIND = {
2439
+ trace: "application/zip",
2440
+ video: "video/webm",
2441
+ screenshot: "image/png"
2442
+ };
2443
+ async function attachTraceArtifact(artifact, sink, onLog) {
2444
+ if (!sink || !artifact.retained || !artifact.path) {
2445
+ return;
2446
+ }
2447
+ try {
2448
+ const stat = await fs5.stat(artifact.path);
2449
+ if (!stat.isFile()) {
2450
+ return;
2451
+ }
2452
+ const content = await fs5.readFile(artifact.path);
2453
+ await sink(
2454
+ basename(artifact.path),
2455
+ content,
2456
+ CONTENT_TYPE_BY_KIND[artifact.kind]
2457
+ );
2458
+ } catch (err) {
2459
+ onLog?.(
2460
+ "warn",
2461
+ `Allure attach skipped for ${artifact.path}: ${String(err)}`
2462
+ );
2463
+ }
2464
+ }
2465
+ function lastRenderedScreenshot(snapshots, startWallTime) {
2466
+ for (let i = snapshots.length - 1; i >= 0; i--) {
2467
+ const snap = snapshots[i];
2468
+ if (snap.timestamp < startWallTime) {
2469
+ return void 0;
2470
+ }
2471
+ if (snap.command !== "__final__" && snap.screenshot) {
2472
+ return snap.screenshot;
2473
+ }
2474
+ }
2475
+ return void 0;
2476
+ }
2477
+ async function captureAndAttachScreenshot(input) {
2478
+ const { mode, granularity, policy, failed, screenshotBase64, sessionId } = input;
2479
+ if (mode !== "trace" || granularity !== "test" || !screenshotBase64 || !sessionId || !input.testUid || !shouldCaptureScreenshot(policy, failed)) {
2480
+ return;
2481
+ }
2482
+ const artifact = await writeScreenshotArtifact({
2483
+ outputDir: input.outputDir,
2484
+ testUid: input.testUid,
2485
+ sessionId,
2486
+ base64: screenshotBase64
2487
+ });
2488
+ input.onArtifact(artifact);
2489
+ await attachTraceArtifact(artifact, input.attach, input.onLog);
2490
+ }
2491
+ async function captureAndAttachVideo(input) {
2492
+ const { mode, granularity, policy, frames, testUid, sessionId } = input;
2493
+ if (mode !== "trace" || granularity !== "test" || !policy || policy === "off" || !frames || !testUid || !sessionId || input.outcomes.length === 0) {
2494
+ return;
2495
+ }
2496
+ if (!shouldRetainTrace(policy, {
2497
+ outcomes: input.outcomes,
2498
+ attemptInfoAvailable: true
2499
+ }).retain) {
2500
+ return;
2501
+ }
2502
+ const artifact = await encodePerTestVideo({
2503
+ frames: sliceFramesFrom(frames, input.startWallTime),
2504
+ outputDir: input.outputDir,
2505
+ testUid,
2506
+ sessionId,
2507
+ attempt: input.attempt,
2508
+ captureFormat: input.captureFormat,
2509
+ onLog: input.onLog
2510
+ });
2511
+ if (!artifact) {
2512
+ return;
2513
+ }
2514
+ input.onArtifact(artifact);
2515
+ await attachTraceArtifact(artifact, input.attach, input.onLog);
2516
+ }
2517
+
2518
+ // ../core/src/attempt-tracker.ts
2519
+ var TestAttemptTracker = class {
2520
+ #ledger = /* @__PURE__ */ new Map();
2521
+ #sawRetry = false;
2522
+ /** Record a starting test; returns its attempt number (0 first, +1 per rerun).
2523
+ * `specFile` (optional) enables spec-scoped retention lookups. */
2524
+ recordStart(uid, specFile) {
2525
+ const entry = this.#ledger.get(uid);
2526
+ if (entry && entry.attempts.length > 0) {
2527
+ this.#sawRetry = true;
2528
+ entry.attempts[entry.attempts.length - 1].state = "failed";
2529
+ }
2530
+ const attempt = entry ? entry.attempts.length : 0;
2531
+ const slot = { uid, attempt };
2532
+ if (entry) {
2533
+ entry.attempts.push(slot);
2534
+ if (specFile !== void 0) {
2535
+ entry.specFile = specFile;
2536
+ }
2537
+ } else {
2538
+ this.#ledger.set(uid, { specFile, attempts: [slot] });
2539
+ }
2540
+ return attempt;
2541
+ }
2542
+ /** Stamp the resolved state onto uid's most recent attempt slot once the
2543
+ * outcome is known. `attempt` overrides the slot's number when the adapter
2544
+ * resolved a more authoritative value (e.g. WDIO's result.retries). */
2545
+ recordOutcome(uid, state, attempt) {
2546
+ const attempts = this.#ledger.get(uid)?.attempts;
2547
+ const slot = attempts?.[attempts.length - 1];
2548
+ if (!slot) {
2549
+ return;
2550
+ }
2551
+ slot.state = state;
2552
+ if (attempt !== void 0) {
2553
+ slot.attempt = attempt;
2554
+ }
2555
+ }
2556
+ /** Latest attempt recorded for `uid`, or undefined if it never started. */
2557
+ attemptFor(uid) {
2558
+ const entry = this.#ledger.get(uid);
2559
+ return entry ? entry.attempts.length - 1 : void 0;
2560
+ }
2561
+ /** True once any test has started more than once (a retry occurred). */
2562
+ get sawRetry() {
2563
+ return this.#sawRetry;
2564
+ }
2565
+ all() {
2566
+ const out = [];
2567
+ for (const entry of this.#ledger.values()) {
2568
+ out.push(...entry.attempts);
2569
+ }
2570
+ return out;
2571
+ }
2572
+ forSpec(specFile) {
2573
+ const out = [];
2574
+ for (const entry of this.#ledger.values()) {
2575
+ if (entry.specFile === specFile) {
2576
+ out.push(...entry.attempts);
2577
+ }
2578
+ }
2579
+ return out;
2580
+ }
2581
+ forTest(uid, attempt) {
2582
+ const attempts = this.#ledger.get(uid)?.attempts ?? [];
2583
+ return attempt === void 0 ? attempts : attempts.filter((a) => a.attempt === attempt);
2584
+ }
2585
+ /** Clear all state — used at session boundaries and reuse-mode reconnects. */
2586
+ reset() {
2587
+ this.#ledger.clear();
2588
+ this.#sawRetry = false;
2589
+ }
2590
+ };
2591
+
2592
+ // ../core/src/assert-patcher.ts
2593
+ import { createRequire as createRequire2 } from "module";
2594
+
2053
2595
  // ../core/src/stack.ts
2054
2596
  import { parse as parseStackTrace } from "stacktrace-parser";
2055
2597
  function isUserCodeFrame(frame) {
@@ -2064,8 +2606,14 @@ function normalizeFilePath(filePath) {
2064
2606
  return stripped;
2065
2607
  }
2066
2608
  }
2067
- function getCallSourceFromStack() {
2068
- const stack = new Error().stack;
2609
+ function isAssertFromUserCode(stack) {
2610
+ if (!stack) {
2611
+ return true;
2612
+ }
2613
+ const caller = parseStackTrace(stack)[1];
2614
+ return !!caller && isUserCodeFrame(caller);
2615
+ }
2616
+ function getCallSourceFromStack(stack = new Error().stack) {
2069
2617
  if (!stack) {
2070
2618
  return { filePath: void 0, callSource: "unknown:0" };
2071
2619
  }
@@ -2077,31 +2625,839 @@ function getCallSourceFromStack() {
2077
2625
  return { filePath, callSource: `${filePath}:${frame.lineNumber ?? 0}` };
2078
2626
  }
2079
2627
 
2080
- // ../core/src/trace-exporter.ts
2081
- import fs from "fs/promises";
2082
- import path from "path";
2083
-
2084
- // ../core/src/trace-har.ts
2085
- function toHeaderArray(h) {
2086
- if (!h) {
2087
- return [];
2628
+ // ../core/src/assert-patcher.ts
2629
+ var require3 = createRequire2(import.meta.url);
2630
+ var ASSERT_PATCHED_SYMBOL = /* @__PURE__ */ Symbol.for(
2631
+ "@wdio/devtools-core/assert-patched"
2632
+ );
2633
+ function safeSerializeAssertArg(value) {
2634
+ if (value === null || value === void 0) {
2635
+ return value;
2088
2636
  }
2089
- return Object.entries(h).map(([name, value]) => ({ name, value }));
2637
+ if (value instanceof RegExp) {
2638
+ return value.toString();
2639
+ }
2640
+ if (typeof value === "function") {
2641
+ return "[Function]";
2642
+ }
2643
+ if (typeof value === "object") {
2644
+ try {
2645
+ return JSON.parse(JSON.stringify(value));
2646
+ } catch {
2647
+ return String(value);
2648
+ }
2649
+ }
2650
+ return value;
2090
2651
  }
2091
- function toQueryString(url) {
2652
+ function displayAssertValue(value) {
2653
+ if (typeof value === "string") {
2654
+ return `'${value}'`;
2655
+ }
2092
2656
  try {
2093
- const u = new URL(url);
2094
- const out = [];
2095
- u.searchParams.forEach((value, name) => out.push({ name, value }));
2096
- return out;
2657
+ return JSON.stringify(value) ?? String(value);
2097
2658
  } catch {
2098
- return [];
2659
+ return String(value);
2099
2660
  }
2100
2661
  }
2101
- function networkRequestToHar(entry) {
2102
- const startedDateTime = new Date(entry.timestamp).toISOString();
2103
- const duration = entry.time ?? (entry.endTime ?? entry.startTime) - entry.startTime;
2104
- const status = entry.response?.status ?? entry.status ?? 0;
2662
+ function describeAssertFailure(err) {
2663
+ const error = toError(err);
2664
+ const e = typeof err === "object" && err ? err : {};
2665
+ if (!("actual" in e) && !("expected" in e)) {
2666
+ return { result: void 0, error };
2667
+ }
2668
+ const rawMessage = stripAnsi(error.message);
2669
+ const cleanMessage = e.generatedMessage ? `${rawMessage.split("\n")[0] ?? "Assertion failed"}
2670
+
2671
+ Expected: ${displayAssertValue(e.expected)}
2672
+ Received: ${displayAssertValue(e.actual)}` : rawMessage;
2673
+ error.message = cleanMessage;
2674
+ if (error.stack) {
2675
+ error.stack = stripAnsi(error.stack).replace(rawMessage, cleanMessage);
2676
+ }
2677
+ return {
2678
+ result: {
2679
+ passed: false,
2680
+ actual: safeSerializeAssertArg(e.actual),
2681
+ expected: safeSerializeAssertArg(e.expected)
2682
+ },
2683
+ error
2684
+ };
2685
+ }
2686
+ function makeAssertEmitters(methodName, args, onCommand, callerStack) {
2687
+ const callInfo = getCallSourceFromStack(callerStack);
2688
+ if (callInfo.filePath === void 0 || !isAssertFromUserCode(callerStack)) {
2689
+ return { passed: () => {
2690
+ }, failed: () => {
2691
+ } };
2692
+ }
2693
+ const startedAt = Date.now();
2694
+ const sanitizedArgs = args.map(safeSerializeAssertArg);
2695
+ const emit = (result, error) => onCommand({
2696
+ command: `assert.${methodName}`,
2697
+ args: sanitizedArgs,
2698
+ result,
2699
+ error,
2700
+ callSource: callInfo.callSource,
2701
+ timestamp: startedAt
2702
+ });
2703
+ return {
2704
+ passed: () => emit("passed", void 0),
2705
+ failed: (err) => {
2706
+ const { result, error } = describeAssertFailure(err);
2707
+ emit(result, error);
2708
+ }
2709
+ };
2710
+ }
2711
+ function makePatchedAssertMethod(methodName, assertObj, original, onCommand) {
2712
+ return function patchedAssert(...args) {
2713
+ const callerStack = new Error().stack;
2714
+ const { passed, failed } = makeAssertEmitters(
2715
+ methodName,
2716
+ args,
2717
+ onCommand,
2718
+ callerStack
2719
+ );
2720
+ let result;
2721
+ assertObj[methodName] = original;
2722
+ try {
2723
+ result = original.apply(this, args);
2724
+ } catch (err) {
2725
+ failed(err);
2726
+ throw err;
2727
+ } finally {
2728
+ assertObj[methodName] = patchedAssert;
2729
+ }
2730
+ const maybe = result;
2731
+ if (maybe && typeof maybe.then === "function") {
2732
+ return result.then(
2733
+ (v) => {
2734
+ passed();
2735
+ return v;
2736
+ },
2737
+ (err) => {
2738
+ failed(err);
2739
+ throw err;
2740
+ }
2741
+ );
2742
+ }
2743
+ passed();
2744
+ return result;
2745
+ };
2746
+ }
2747
+ function capturedAssertToCommandLog(cmd, testUid) {
2748
+ const entry = {
2749
+ command: cmd.command,
2750
+ args: cmd.args,
2751
+ result: cmd.result,
2752
+ timestamp: cmd.timestamp,
2753
+ startTime: cmd.timestamp
2754
+ };
2755
+ if (cmd.error) {
2756
+ entry.error = {
2757
+ name: cmd.error.name,
2758
+ message: cmd.error.message,
2759
+ stack: cmd.error.stack
2760
+ };
2761
+ }
2762
+ if (cmd.callSource) {
2763
+ entry.callSource = cmd.callSource;
2764
+ }
2765
+ if (testUid) {
2766
+ entry.testUid = testUid;
2767
+ }
2768
+ return entry;
2769
+ }
2770
+ function matcherAssertionToCommandLog(input, testUid) {
2771
+ const command = `${input.prefix ?? "expect"}.${input.method}`;
2772
+ const message = typeof input.message === "function" ? input.message() : input.message;
2773
+ const entry = capturedAssertToCommandLog(
2774
+ {
2775
+ command,
2776
+ args: (input.args ?? []).map(safeSerializeAssertArg),
2777
+ result: input.passed ? "passed" : void 0,
2778
+ error: input.passed ? void 0 : new Error(stripAnsi(message ?? `${command} failed`)),
2779
+ callSource: input.callSource,
2780
+ timestamp: Date.now()
2781
+ },
2782
+ testUid
2783
+ );
2784
+ if (input.title) {
2785
+ entry.title = input.title;
2786
+ }
2787
+ return entry;
2788
+ }
2789
+ function patchAssertNamespace(nsObj, onCommand) {
2790
+ let count = 0;
2791
+ for (const methodName of TRACKED_ASSERT_METHODS) {
2792
+ const original = nsObj[methodName];
2793
+ if (typeof original !== "function") {
2794
+ continue;
2795
+ }
2796
+ nsObj[methodName] = makePatchedAssertMethod(
2797
+ methodName,
2798
+ nsObj,
2799
+ original,
2800
+ onCommand
2801
+ );
2802
+ count++;
2803
+ }
2804
+ return count;
2805
+ }
2806
+ function patchNodeAssert(onCommand, onLog) {
2807
+ const log14 = (level, message) => onLog?.(level, message);
2808
+ let assertModule;
2809
+ try {
2810
+ assertModule = require3("node:assert");
2811
+ } catch {
2812
+ log14("warn", "node:assert not available \u2014 skipping assertion capture");
2813
+ return false;
2814
+ }
2815
+ const assertObj = assertModule;
2816
+ if (assertObj[ASSERT_PATCHED_SYMBOL]) {
2817
+ return true;
2818
+ }
2819
+ assertObj[ASSERT_PATCHED_SYMBOL] = true;
2820
+ let patched = patchAssertNamespace(assertObj, onCommand);
2821
+ const strict = assertObj.strict;
2822
+ if (strict && (typeof strict === "object" || typeof strict === "function")) {
2823
+ patched += patchAssertNamespace(
2824
+ strict,
2825
+ onCommand
2826
+ );
2827
+ }
2828
+ log14("info", `Patched ${patched} node:assert method(s)`);
2829
+ return true;
2830
+ }
2831
+
2832
+ // ../core/src/sha1.ts
2833
+ import { createHash } from "crypto";
2834
+ function sha1Hex(data) {
2835
+ return createHash("sha1").update(data).digest("hex");
2836
+ }
2837
+
2838
+ // ../core/src/trace-sources.ts
2839
+ var MAX_SOURCE_BYTES = 2 * 1024 * 1024;
2840
+ function sourceResourceName(filePath) {
2841
+ return `src@${sha1Hex(filePath)}.txt`;
2842
+ }
2843
+ function splitNumericSuffix(value) {
2844
+ const sep = value.lastIndexOf(":");
2845
+ if (sep <= 0) {
2846
+ return { rest: value };
2847
+ }
2848
+ const digits = value.slice(sep + 1);
2849
+ if (!/^\d+$/.test(digits)) {
2850
+ return { rest: value };
2851
+ }
2852
+ return { rest: value.slice(0, sep), num: Number(digits) };
2853
+ }
2854
+ function callSourceToStack(callSource) {
2855
+ if (!callSource || callSource === "unknown:0") {
2856
+ return void 0;
2857
+ }
2858
+ const last = splitNumericSuffix(callSource);
2859
+ if (last.num === void 0) {
2860
+ return [{ file: callSource, line: 0, column: 0 }];
2861
+ }
2862
+ const prev = splitNumericSuffix(last.rest);
2863
+ if (prev.num === void 0) {
2864
+ return [{ file: last.rest, line: last.num, column: 0 }];
2865
+ }
2866
+ return [{ file: prev.rest, line: prev.num, column: last.num }];
2867
+ }
2868
+ function buildSourceResources(sources) {
2869
+ const out = [];
2870
+ for (const [filePath, text] of Object.entries(sources)) {
2871
+ const data = Buffer.from(text, "utf8");
2872
+ if (data.byteLength > MAX_SOURCE_BYTES) {
2873
+ continue;
2874
+ }
2875
+ out.push({ resourceName: sourceResourceName(filePath), data });
2876
+ }
2877
+ return out;
2878
+ }
2879
+
2880
+ // ../core/src/trace-hierarchy.ts
2881
+ function buildGroupPath(cmd, testMetadata) {
2882
+ const path14 = [];
2883
+ if (cmd.testUid) {
2884
+ const entry = testMetadata?.get(cmd.testUid);
2885
+ for (const ancestor of entry?.ancestry ?? []) {
2886
+ path14.push({ uid: ancestor.uid, title: ancestor.title });
2887
+ }
2888
+ path14.push({ uid: cmd.testUid, title: entry?.title ?? cmd.testUid });
2889
+ }
2890
+ if (cmd.stepUid) {
2891
+ const stepEntry = testMetadata?.get(cmd.stepUid);
2892
+ path14.push({ uid: cmd.stepUid, title: stepEntry?.title ?? cmd.stepUid });
2893
+ }
2894
+ return path14;
2895
+ }
2896
+
2897
+ // ../core/src/trace-action-events.ts
2898
+ var MAX_RESULT_BYTES = 64 * 1024;
2899
+ function serializableResult(result) {
2900
+ if (result === void 0) {
2901
+ return void 0;
2902
+ }
2903
+ try {
2904
+ const json = JSON.stringify(result);
2905
+ if (json === void 0 || json.length > MAX_RESULT_BYTES) {
2906
+ return void 0;
2907
+ }
2908
+ return JSON.parse(json);
2909
+ } catch {
2910
+ return void 0;
2911
+ }
2912
+ }
2913
+ function collapsedAssertResult(result) {
2914
+ if (typeof result === "object" && result !== null && "passed" in result) {
2915
+ return result;
2916
+ }
2917
+ return void 0;
2918
+ }
2919
+ function buildAssertParams(cmd) {
2920
+ const params = Object.fromEntries(
2921
+ cmd.args.map((arg, index) => [String(index), arg])
2922
+ );
2923
+ const [actual, expected, message] = cmd.args;
2924
+ const collapsed = collapsedAssertResult(cmd.result);
2925
+ const semantic = {
2926
+ actual: collapsed?.actual ?? actual,
2927
+ expected: collapsed?.expected ?? expected,
2928
+ message: collapsed?.message ?? message
2929
+ };
2930
+ for (const [key, value] of Object.entries(semantic)) {
2931
+ if (value !== void 0) {
2932
+ params[key] = value;
2933
+ }
2934
+ }
2935
+ return params;
2936
+ }
2937
+ function buildActionParams(action, rawArgs) {
2938
+ const isValueMethod = FILL_METHODS.has(action.method);
2939
+ if (action.class === "Element" && isValueMethod && rawArgs.length >= 2) {
2940
+ return { selector: rawArgs[0], value: rawArgs[1] };
2941
+ }
2942
+ if (action.class === "Element" && isValueMethod && rawArgs.length === 1) {
2943
+ return { value: rawArgs[0] };
2944
+ }
2945
+ if (action.class === "Element" && rawArgs.length === 1 && typeof rawArgs[0] === "string") {
2946
+ return { selector: rawArgs[0] };
2947
+ }
2948
+ if (rawArgs.length === 1 && typeof rawArgs[0] === "string") {
2949
+ return { url: rawArgs[0] };
2950
+ }
2951
+ return Object.fromEntries(rawArgs.map((a, i) => [String(i), a]));
2952
+ }
2953
+ function closeGroupsFrom(stream, from) {
2954
+ for (let i = stream.openGroups.length - 1; i >= from; i--) {
2955
+ stream.events.push({
2956
+ type: "after",
2957
+ callId: stream.openGroups[i].callId,
2958
+ endTime: stream.prevEndMs
2959
+ });
2960
+ }
2961
+ stream.openGroups.length = Math.max(0, from);
2962
+ }
2963
+ function syncGroups(stream, cmd, pageId, wallTime, testMetadata) {
2964
+ const desired = buildGroupPath(cmd, testMetadata);
2965
+ let common = 0;
2966
+ while (common < stream.openGroups.length && common < desired.length && stream.openGroups[common].uid === desired[common].uid) {
2967
+ common++;
2968
+ }
2969
+ closeGroupsFrom(stream, common);
2970
+ for (let i = common; i < desired.length; i++) {
2971
+ stream.callCounter++;
2972
+ const callId = `call@${stream.callCounter}`;
2973
+ const parentId = stream.openGroups[i - 1]?.callId;
2974
+ const groupBefore = {
2975
+ type: "before",
2976
+ callId,
2977
+ startTime: Math.max(
2978
+ stream.prevEndMs,
2979
+ (cmd.startTime ?? cmd.timestamp) - wallTime
2980
+ ),
2981
+ class: "Tracing",
2982
+ method: "tracingGroup",
2983
+ pageId,
2984
+ params: { name: desired[i].title },
2985
+ title: desired[i].title,
2986
+ apiName: "tracing.tracingGroup"
2987
+ };
2988
+ if (parentId) {
2989
+ groupBefore.parentId = parentId;
2990
+ }
2991
+ stream.events.push(groupBefore);
2992
+ stream.openGroups.push({ uid: desired[i].uid, callId });
2993
+ }
2994
+ }
2995
+ function buildParamsAndTitle(action, cmd) {
2996
+ const isAssert = action.class === ASSERT_ACTION_CLASS;
2997
+ const params = isAssert ? buildAssertParams(cmd) : buildActionParams(action, cmd.args);
2998
+ if (cmd.selector && params.locator === void 0) {
2999
+ params.locator = cmd.selector;
3000
+ }
3001
+ return {
3002
+ params,
3003
+ title: formatActionTitle(
3004
+ action,
3005
+ cmd.args,
3006
+ params,
3007
+ isAssert ? cmd.command : void 0
3008
+ )
3009
+ };
3010
+ }
3011
+ function actionError(cmd, isAssert) {
3012
+ if (cmd.error) {
3013
+ const err = cmd.error;
3014
+ return { message: err.message ?? String(cmd.error) };
3015
+ }
3016
+ if (isAssert) {
3017
+ const collapsed = collapsedAssertResult(cmd.result);
3018
+ if (collapsed && collapsed.passed === false) {
3019
+ return { message: String(collapsed.message ?? "Assertion failed") };
3020
+ }
3021
+ }
3022
+ return void 0;
3023
+ }
3024
+ function resolveActionPoint(action, cmd, snapshotIndex) {
3025
+ if (action.class !== "Element" || !POINTABLE_METHODS.has(action.method)) {
3026
+ return void 0;
3027
+ }
3028
+ const selector = cmd.args?.[0];
3029
+ if (typeof selector !== "string") {
3030
+ return void 0;
3031
+ }
3032
+ const elements = snapshotIndex?.elementsAt(cmd.timestamp);
3033
+ for (const el of elements ?? []) {
3034
+ const e = el;
3035
+ if (e.selector === selector && e.boundingBox) {
3036
+ const bb = e.boundingBox;
3037
+ return { x: bb.x + bb.width / 2, y: bb.y + bb.height / 2 };
3038
+ }
3039
+ }
3040
+ return void 0;
3041
+ }
3042
+ function buildAfterEvent(cmd, action, callId, endMs, snapshotIndex) {
3043
+ const afterEvent = { type: "after", callId, endTime: endMs };
3044
+ const error = actionError(cmd, action.class === ASSERT_ACTION_CLASS);
3045
+ if (error) {
3046
+ afterEvent.error = error;
3047
+ }
3048
+ const result = serializableResult(cmd.result);
3049
+ if (result !== void 0) {
3050
+ afterEvent.result = result;
3051
+ }
3052
+ const afterName = snapshotIndex?.claimAfter(cmd.timestamp, callId);
3053
+ if (afterName) {
3054
+ afterEvent.afterSnapshot = afterName;
3055
+ }
3056
+ const point = resolveActionPoint(action, cmd, snapshotIndex);
3057
+ if (point) {
3058
+ afterEvent.point = point;
3059
+ }
3060
+ return afterEvent;
3061
+ }
3062
+ function pushActionPair(stream, cmd, action, pageId, wallTime, snapshotIndex) {
3063
+ stream.callCounter++;
3064
+ const callId = `call@${stream.callCounter}`;
3065
+ const rawStartMs = (cmd.startTime ?? cmd.timestamp) - wallTime;
3066
+ const rawEndMs = cmd.timestamp - wallTime;
3067
+ const startMs = Math.max(stream.prevEndMs, rawStartMs);
3068
+ const endMs = Math.max(startMs + 1, rawEndMs);
3069
+ const { params, title } = buildParamsAndTitle(action, cmd);
3070
+ const beforeEvent = {
3071
+ type: "before",
3072
+ callId,
3073
+ startTime: startMs,
3074
+ class: action.class,
3075
+ method: action.method,
3076
+ pageId,
3077
+ params,
3078
+ title,
3079
+ apiName: `${action.class.toLowerCase()}.${action.method}`,
3080
+ parentId: stream.openGroups[stream.openGroups.length - 1]?.callId
3081
+ };
3082
+ const stack = callSourceToStack(cmd.callSource);
3083
+ if (stack) {
3084
+ beforeEvent.stack = stack;
3085
+ }
3086
+ const beforeName = snapshotIndex?.beforeName();
3087
+ if (beforeName) {
3088
+ beforeEvent.beforeSnapshot = beforeName;
3089
+ }
3090
+ stream.events.push(beforeEvent);
3091
+ stream.events.push(buildAfterEvent(cmd, action, callId, endMs, snapshotIndex));
3092
+ stream.prevEndMs = endMs;
3093
+ }
3094
+ function buildActionEvents(commands, pageId, wallTime, testMetadata, snapshotIndex) {
3095
+ const stream = {
3096
+ events: [],
3097
+ prevEndMs: 0,
3098
+ callCounter: 0,
3099
+ openGroups: []
3100
+ };
3101
+ const ordered = [...commands].sort(
3102
+ (a, b) => (a.startTime ?? a.timestamp) - (b.startTime ?? b.timestamp)
3103
+ );
3104
+ for (const cmd of ordered) {
3105
+ const action = mapCommandToAction(cmd.command);
3106
+ if (!action) {
3107
+ continue;
3108
+ }
3109
+ syncGroups(stream, cmd, pageId, wallTime, testMetadata);
3110
+ pushActionPair(stream, cmd, action, pageId, wallTime, snapshotIndex);
3111
+ }
3112
+ closeGroupsFrom(stream, 0);
3113
+ return stream.events;
3114
+ }
3115
+
3116
+ // ../core/src/trace-console.ts
3117
+ var MAX_CONSOLE_EVENTS = 1e4;
3118
+ function toTraceLevel(level) {
3119
+ if (level === "warn") {
3120
+ return "warning";
3121
+ }
3122
+ if (level === "trace") {
3123
+ return "debug";
3124
+ }
3125
+ return level;
3126
+ }
3127
+ function previewArg(arg) {
3128
+ if (typeof arg === "string") {
3129
+ return arg;
3130
+ }
3131
+ try {
3132
+ return JSON.stringify(arg) ?? String(arg);
3133
+ } catch {
3134
+ return String(arg);
3135
+ }
3136
+ }
3137
+ function buildConsoleEvents(logs, pageId, wallTime) {
3138
+ const capped = logs.slice(0, MAX_CONSOLE_EVENTS);
3139
+ const events = capped.map((log14) => {
3140
+ const time = Math.max(0, log14.timestamp - wallTime);
3141
+ const text = log14.args.map(previewArg).join(" ");
3142
+ if (log14.source === "browser" || log14.source === void 0) {
3143
+ return {
3144
+ type: "console",
3145
+ time,
3146
+ pageId,
3147
+ messageType: toTraceLevel(log14.type),
3148
+ text,
3149
+ args: log14.args.map((arg) => ({ preview: previewArg(arg), value: arg })),
3150
+ // Location isn't captured at the patch site; the required field ships zeroed.
3151
+ location: { url: "", lineNumber: 0, columnNumber: 0 }
3152
+ };
3153
+ }
3154
+ return {
3155
+ type: log14.type === "error" || log14.type === "warn" ? "stderr" : "stdout",
3156
+ timestamp: time,
3157
+ text,
3158
+ source: log14.source
3159
+ };
3160
+ });
3161
+ if (logs.length > MAX_CONSOLE_EVENTS) {
3162
+ const last = capped[capped.length - 1];
3163
+ events.push({
3164
+ type: "stderr",
3165
+ timestamp: last ? Math.max(0, last.timestamp - wallTime) : 0,
3166
+ text: `[devtools] console truncated: dropped ${logs.length - MAX_CONSOLE_EVENTS} entries`
3167
+ });
3168
+ }
3169
+ return events;
3170
+ }
3171
+
3172
+ // ../core/src/trace-exporter.ts
3173
+ import fs6 from "fs/promises";
3174
+ import path5 from "path";
3175
+
3176
+ // ../core/src/trace-snapshots.ts
3177
+ function collapseByTimestamp(snapshots) {
3178
+ const byTs = /* @__PURE__ */ new Map();
3179
+ const order = [];
3180
+ for (const snap of snapshots) {
3181
+ const existing = byTs.get(snap.timestamp);
3182
+ if (!existing) {
3183
+ byTs.set(snap.timestamp, { ...snap });
3184
+ order.push(snap.timestamp);
3185
+ continue;
3186
+ }
3187
+ if ((snap.screenshot?.length ?? 0) > (existing.screenshot?.length ?? 0)) {
3188
+ existing.screenshot = snap.screenshot;
3189
+ }
3190
+ if (!existing.elements?.length && snap.elements?.length) {
3191
+ existing.elements = snap.elements;
3192
+ }
3193
+ if (!existing.snapshotText && snap.snapshotText) {
3194
+ existing.snapshotText = snap.snapshotText;
3195
+ }
3196
+ }
3197
+ return order.map((ts) => byTs.get(ts));
3198
+ }
3199
+ function buildSnapshotResources(rawSnapshots, pageId) {
3200
+ const snapshots = collapseByTimestamp(rawSnapshots);
3201
+ const out = [];
3202
+ for (const snap of snapshots) {
3203
+ const base = `${pageId}-${snap.timestamp}`;
3204
+ if (snap.screenshot) {
3205
+ out.push({
3206
+ resourceName: `${base}.jpeg`,
3207
+ data: Buffer.from(snap.screenshot, "base64")
3208
+ });
3209
+ }
3210
+ if (snap.elements && snap.elements.length) {
3211
+ out.push({
3212
+ resourceName: `${base}-elements.json`,
3213
+ data: Buffer.from(JSON.stringify(snap.elements), "utf8")
3214
+ });
3215
+ }
3216
+ if (snap.snapshotText) {
3217
+ out.push({
3218
+ resourceName: `${base}-snapshot.txt`,
3219
+ data: Buffer.from(snap.snapshotText, "utf8")
3220
+ });
3221
+ }
3222
+ }
3223
+ return out;
3224
+ }
3225
+ function frameForSnapshot(snap, pageId, timestamp, viewport) {
3226
+ const base = `${pageId}-${snap.timestamp}`;
3227
+ const frame = {
3228
+ type: "screencast-frame",
3229
+ pageId,
3230
+ sha1: `${base}.jpeg`,
3231
+ width: viewport.width,
3232
+ height: viewport.height,
3233
+ timestamp
3234
+ };
3235
+ if (snap.elements && snap.elements.length) {
3236
+ frame.elements = `${base}-elements.json`;
3237
+ }
3238
+ if (snap.snapshotText) {
3239
+ frame.snapshot = `${base}-snapshot.txt`;
3240
+ }
3241
+ return frame;
3242
+ }
3243
+ function buildFilmstripEvents(rawSnapshots, pageId, wallTime, viewport) {
3244
+ const snapshots = collapseByTimestamp(rawSnapshots);
3245
+ const firstSnap = snapshots.find((s) => s.screenshot);
3246
+ const events = [];
3247
+ if (firstSnap) {
3248
+ events.push(frameForSnapshot(firstSnap, pageId, 0, viewport));
3249
+ }
3250
+ for (const snap of snapshots) {
3251
+ if (snap === firstSnap || !snap.screenshot) {
3252
+ continue;
3253
+ }
3254
+ events.push(
3255
+ frameForSnapshot(
3256
+ snap,
3257
+ pageId,
3258
+ Math.max(0, snap.timestamp - wallTime),
3259
+ viewport
3260
+ )
3261
+ );
3262
+ }
3263
+ return events;
3264
+ }
3265
+
3266
+ // ../core/src/screencast-trace.ts
3267
+ import { createHash as createHash2 } from "crypto";
3268
+ var DEFAULT_MAX_FRAMES = 600;
3269
+ var DEFAULT_MIN_INTERVAL_MS = 100;
3270
+ function thinScreencastFrames(frames, options = {}) {
3271
+ const minInterval = options.minFrameIntervalMs ?? DEFAULT_MIN_INTERVAL_MS;
3272
+ const maxFrames = options.maxFrames ?? DEFAULT_MAX_FRAMES;
3273
+ const kept = [];
3274
+ let lastKeptTs = -Infinity;
3275
+ let lastData;
3276
+ for (const frame of frames) {
3277
+ if (frame.timestamp - lastKeptTs < minInterval) {
3278
+ continue;
3279
+ }
3280
+ if (frame.data === lastData) {
3281
+ continue;
3282
+ }
3283
+ kept.push(frame);
3284
+ lastKeptTs = frame.timestamp;
3285
+ lastData = frame.data;
3286
+ }
3287
+ if (kept.length <= maxFrames) {
3288
+ return kept;
3289
+ }
3290
+ if (maxFrames <= 1) {
3291
+ return kept.length ? [kept[0]] : [];
3292
+ }
3293
+ const out = [];
3294
+ const step = (kept.length - 1) / (maxFrames - 1);
3295
+ for (let i = 0; i < maxFrames; i++) {
3296
+ out.push(kept[Math.round(i * step)]);
3297
+ }
3298
+ return out;
3299
+ }
3300
+ function buildDenseScreencast(frames, pageId, wallTime, viewport, options = {}) {
3301
+ const events = [];
3302
+ const resources = [];
3303
+ const seen = /* @__PURE__ */ new Set();
3304
+ for (const frame of thinScreencastFrames(frames, options)) {
3305
+ const data = Buffer.from(frame.data, "base64");
3306
+ const sha1 = createHash2("sha1").update(data).digest("hex");
3307
+ const resourceName = `${sha1}.jpeg`;
3308
+ if (!seen.has(sha1)) {
3309
+ seen.add(sha1);
3310
+ resources.push({ resourceName, data });
3311
+ }
3312
+ events.push({
3313
+ type: "screencast-frame",
3314
+ pageId,
3315
+ sha1: resourceName,
3316
+ width: viewport.width,
3317
+ height: viewport.height,
3318
+ timestamp: Math.max(0, frame.timestamp - wallTime)
3319
+ });
3320
+ }
3321
+ return { events, resources };
3322
+ }
3323
+
3324
+ // ../core/src/trace-frame-snapshots.ts
3325
+ var SNAPSHOT_DOCTYPE = "html";
3326
+ var FALLBACK_FRAME_URL = "about:blank";
3327
+ var BODY_STYLE = "margin:0";
3328
+ var IMAGE_STYLE = "display:block;width:100vw;height:100vh;object-fit:contain";
3329
+ var FrameSnapshotIndex = class {
3330
+ #byTimestamp = /* @__PURE__ */ new Map();
3331
+ // Element rects by timestamp, indexed independently of the screenshot gate so
3332
+ // A8 input points resolve even when the frame carries no screenshot.
3333
+ #elementsByTimestamp = /* @__PURE__ */ new Map();
3334
+ #refs = [];
3335
+ #lastName;
3336
+ constructor(snapshots) {
3337
+ for (const snap of snapshots) {
3338
+ if (snap.elements) {
3339
+ const currentEls = this.#elementsByTimestamp.get(snap.timestamp);
3340
+ if (!currentEls || snap.elements.length > currentEls.length) {
3341
+ this.#elementsByTimestamp.set(snap.timestamp, snap.elements);
3342
+ }
3343
+ }
3344
+ if (!snap.screenshot) {
3345
+ continue;
3346
+ }
3347
+ const current = this.#byTimestamp.get(snap.timestamp);
3348
+ if (!current || snap.screenshot.length > (current.screenshot?.length ?? 0)) {
3349
+ this.#byTimestamp.set(snap.timestamp, snap);
3350
+ }
3351
+ }
3352
+ }
3353
+ /** Captured element rects at a command's completion timestamp, if any. */
3354
+ elementsAt(timestamp) {
3355
+ return this.#elementsByTimestamp.get(timestamp);
3356
+ }
3357
+ /** Snapshot name representing the page state before the next action. */
3358
+ beforeName() {
3359
+ return this.#lastName;
3360
+ }
3361
+ /** Claims the screenshot captured at the command's completion, if any. */
3362
+ claimAfter(timestamp, callId) {
3363
+ const snap = this.#byTimestamp.get(timestamp);
3364
+ if (!snap) {
3365
+ return void 0;
3366
+ }
3367
+ this.#byTimestamp.delete(timestamp);
3368
+ const snapshotName = `after@${callId}`;
3369
+ this.#refs.push({ callId, snapshotName, snapshot: snap });
3370
+ this.#lastName = snapshotName;
3371
+ return snapshotName;
3372
+ }
3373
+ refs() {
3374
+ return this.#refs;
3375
+ }
3376
+ };
3377
+ function frameIdForPage(pageId) {
3378
+ const suffix = pageId.startsWith("page@") ? pageId.slice("page@".length) : pageId;
3379
+ return `frame@${suffix}`;
3380
+ }
3381
+ function imageMimeType(base64) {
3382
+ return base64.startsWith("iVBOR") ? "image/png" : "image/jpeg";
3383
+ }
3384
+ function imageDocument(snap) {
3385
+ const screenshot = snap.screenshot ?? "";
3386
+ return [
3387
+ "HTML",
3388
+ {},
3389
+ ["HEAD", {}, ["BASE", { href: snap.url ?? FALLBACK_FRAME_URL }]],
3390
+ [
3391
+ "BODY",
3392
+ { style: BODY_STYLE },
3393
+ [
3394
+ "IMG",
3395
+ {
3396
+ src: `data:${imageMimeType(screenshot)};base64,${screenshot}`,
3397
+ style: IMAGE_STYLE
3398
+ }
3399
+ ]
3400
+ ]
3401
+ ];
3402
+ }
3403
+ function buildImageFrameSnapshots(refs, pageId, wallTime, viewport) {
3404
+ const frameId = frameIdForPage(pageId);
3405
+ return refs.map((ref) => ({
3406
+ type: "frame-snapshot",
3407
+ snapshot: {
3408
+ callId: ref.callId,
3409
+ snapshotName: ref.snapshotName,
3410
+ pageId,
3411
+ frameId,
3412
+ frameUrl: ref.snapshot.url ?? FALLBACK_FRAME_URL,
3413
+ doctype: SNAPSHOT_DOCTYPE,
3414
+ html: imageDocument(ref.snapshot),
3415
+ viewport: { width: viewport.width, height: viewport.height },
3416
+ timestamp: Math.max(0, ref.snapshot.timestamp - wallTime),
3417
+ wallTime: ref.snapshot.timestamp,
3418
+ collectionTime: 0,
3419
+ resourceOverrides: [],
3420
+ isMainFrame: true
3421
+ }
3422
+ }));
3423
+ }
3424
+
3425
+ // ../core/src/trace-har.ts
3426
+ var INLINE_BODY_TEXT_MAX_BYTES = 8 * 1024;
3427
+ function toHeaderArray(h) {
3428
+ if (!h) {
3429
+ return [];
3430
+ }
3431
+ return Object.entries(h).map(([name, value]) => ({ name, value }));
3432
+ }
3433
+ function toQueryString(url) {
3434
+ try {
3435
+ const u = new URL(url);
3436
+ const out = [];
3437
+ u.searchParams.forEach((value, name) => out.push({ name, value }));
3438
+ return out;
3439
+ } catch {
3440
+ return [];
3441
+ }
3442
+ }
3443
+ function toHarContent(entry, mimeType, bodySha1) {
3444
+ const content = { size: entry.size ?? 0, mimeType };
3445
+ const body = entry.responseBody;
3446
+ if (body === void 0) {
3447
+ return content;
3448
+ }
3449
+ if (Buffer.byteLength(body, "utf8") < INLINE_BODY_TEXT_MAX_BYTES) {
3450
+ content.text = body;
3451
+ }
3452
+ if (bodySha1) {
3453
+ content._sha1 = bodySha1;
3454
+ }
3455
+ return content;
3456
+ }
3457
+ function networkRequestToHar(entry, opts = {}) {
3458
+ const startedDateTime = new Date(entry.timestamp).toISOString();
3459
+ const duration = entry.time ?? (entry.endTime ?? entry.startTime) - entry.startTime;
3460
+ const status = entry.response?.status ?? entry.status ?? 0;
2105
3461
  const mimeType = entry.response?.mimeType ?? "";
2106
3462
  const responseHeaders = entry.response?.headers ?? entry.responseHeaders;
2107
3463
  return {
@@ -2125,7 +3481,7 @@ function networkRequestToHar(entry) {
2125
3481
  httpVersion: "HTTP/1.1",
2126
3482
  cookies: [],
2127
3483
  headers: toHeaderArray(responseHeaders),
2128
- content: { size: entry.size ?? 0, mimeType },
3484
+ content: toHarContent(entry, mimeType, opts.bodySha1),
2129
3485
  redirectURL: "",
2130
3486
  headersSize: -1,
2131
3487
  bodySize: entry.size ?? -1
@@ -2153,6 +3509,9 @@ function buildTraceZip(inputs) {
2153
3509
  "transcript.md"
2154
3510
  );
2155
3511
  }
3512
+ if (inputs.mutationsNdjson?.length) {
3513
+ zipFile.addBuffer(inputs.mutationsNdjson, "trace.mutations");
3514
+ }
2156
3515
  for (const resource of inputs.resources) {
2157
3516
  zipFile.addBuffer(resource.data, `resources/${resource.resourceName}`);
2158
3517
  }
@@ -2164,13 +3523,80 @@ function buildTraceZip(inputs) {
2164
3523
  });
2165
3524
  }
2166
3525
 
3526
+ // ../core/src/trace-mutations.ts
3527
+ var MAX_MUTATIONS_NDJSON_BYTES = 50 * 1024 * 1024;
3528
+ function buildMutationsNdjson(mutations, cap = MAX_MUTATIONS_NDJSON_BYTES) {
3529
+ if (!mutations.length) {
3530
+ return { ndjson: Buffer.alloc(0), truncated: false, written: 0 };
3531
+ }
3532
+ const lines = [];
3533
+ let bytes = 0;
3534
+ for (const mutation of mutations) {
3535
+ const line = JSON.stringify(mutation);
3536
+ const add = Buffer.byteLength(line, "utf8") + (lines.length ? 1 : 0);
3537
+ if (lines.length > 0 && bytes + add > cap) {
3538
+ break;
3539
+ }
3540
+ lines.push(line);
3541
+ bytes += add;
3542
+ }
3543
+ const written = lines.length;
3544
+ const dropped = mutations.length - written;
3545
+ if (dropped > 0) {
3546
+ const marker = { __truncated__: true, dropped };
3547
+ lines.push(JSON.stringify(marker));
3548
+ }
3549
+ return {
3550
+ ndjson: Buffer.from(lines.join("\n"), "utf8"),
3551
+ truncated: dropped > 0,
3552
+ written
3553
+ };
3554
+ }
3555
+
2167
3556
  // ../core/src/trace-exporter.ts
2168
3557
  var TRACE_VERSION = 8;
2169
3558
  var LIBRARY_NAME = "@wdio/devtools-core";
2170
3559
  var LIBRARY_VERSION = "1.0.0";
3560
+ var MAX_BODY_RESOURCE_BYTES = 1024 * 1024;
3561
+ var MAX_TOTAL_BODY_RESOURCE_BYTES = 20 * 1024 * 1024;
3562
+ function buildNetworkBodyResources(requests, caps = {
3563
+ maxBodyBytes: MAX_BODY_RESOURCE_BYTES,
3564
+ maxTotalBytes: MAX_TOTAL_BODY_RESOURCE_BYTES
3565
+ }) {
3566
+ const resources = [];
3567
+ const sha1ByRequestId = /* @__PURE__ */ new Map();
3568
+ const stored = /* @__PURE__ */ new Set();
3569
+ let totalBytes = 0;
3570
+ for (const request of requests) {
3571
+ if (request.responseBody === void 0) {
3572
+ continue;
3573
+ }
3574
+ const data = Buffer.from(request.responseBody, "utf8");
3575
+ if (data.byteLength > caps.maxBodyBytes) {
3576
+ continue;
3577
+ }
3578
+ const sha1 = sha1Hex(data);
3579
+ if (stored.has(sha1)) {
3580
+ sha1ByRequestId.set(request.id, sha1);
3581
+ continue;
3582
+ }
3583
+ if (totalBytes + data.byteLength > caps.maxTotalBytes) {
3584
+ continue;
3585
+ }
3586
+ stored.add(sha1);
3587
+ totalBytes += data.byteLength;
3588
+ resources.push({ resourceName: sha1, data });
3589
+ sha1ByRequestId.set(request.id, sha1);
3590
+ }
3591
+ return { resources, sha1ByRequestId };
3592
+ }
2171
3593
  function shortId(sessionId) {
2172
3594
  return (sessionId ?? Math.random().toString(36).slice(2, 10)).slice(0, 8);
2173
3595
  }
3596
+ function allocateTraceIds(sessionId) {
3597
+ const idPrefix = shortId(sessionId);
3598
+ return { contextId: `context@${idPrefix}`, pageId: `page@${idPrefix}` };
3599
+ }
2174
3600
  function resolveContextNaming(caps) {
2175
3601
  const platformName = typeof caps?.platformName === "string" ? caps.platformName.toLowerCase() : void 0;
2176
3602
  const deviceName = typeof caps?.["appium:deviceName"] === "string" ? caps["appium:deviceName"] : void 0;
@@ -2205,106 +3631,14 @@ function buildContextOptions(trace, contextId, wallTime) {
2205
3631
  }
2206
3632
  };
2207
3633
  }
2208
- function buildActionEvents(commands, pageId, wallTime, testMetadata) {
2209
- const events = [];
2210
- let prevEndMs = 0;
2211
- let callCounter = 0;
2212
- let lastTestUid;
2213
- let groupCallId;
2214
- for (const cmd of commands) {
2215
- const action = mapCommandToAction(cmd.command);
2216
- if (!action) {
2217
- continue;
2218
- }
2219
- if (cmd.testUid && cmd.testUid !== lastTestUid) {
2220
- if (lastTestUid && groupCallId) {
2221
- callCounter++;
2222
- events.push({
2223
- type: "after",
2224
- callId: groupCallId,
2225
- endTime: prevEndMs
2226
- });
2227
- }
2228
- callCounter++;
2229
- groupCallId = `call@${callCounter}`;
2230
- const meta = testMetadata?.get(cmd.testUid);
2231
- const groupName = meta?.title ?? cmd.testUid;
2232
- events.push({
2233
- type: "before",
2234
- callId: groupCallId,
2235
- startTime: Math.max(
2236
- prevEndMs,
2237
- (cmd.startTime ?? cmd.timestamp) - wallTime
2238
- ),
2239
- class: "Tracing",
2240
- method: "tracingGroup",
2241
- pageId,
2242
- params: { name: groupName },
2243
- title: groupName,
2244
- apiName: "tracing.tracingGroup"
2245
- });
2246
- lastTestUid = cmd.testUid;
2247
- }
2248
- callCounter++;
2249
- const callId = `call@${callCounter}`;
2250
- const rawStartMs = (cmd.startTime ?? cmd.timestamp) - wallTime;
2251
- const rawEndMs = cmd.timestamp - wallTime;
2252
- const startMs = Math.max(prevEndMs, rawStartMs);
2253
- const endMs = Math.max(startMs + 1, rawEndMs);
2254
- const rawArgs = cmd.args;
2255
- let params;
2256
- const isValueMethod = FILL_METHODS.has(action.method);
2257
- if (action.class === "Element" && isValueMethod && rawArgs.length >= 2) {
2258
- params = { selector: rawArgs[0], value: rawArgs[1] };
2259
- } else if (action.class === "Element" && isValueMethod && rawArgs.length === 1) {
2260
- params = { value: rawArgs[0] };
2261
- } else if (action.class === "Element" && rawArgs.length === 1 && typeof rawArgs[0] === "string") {
2262
- params = { selector: rawArgs[0] };
2263
- } else if (rawArgs.length === 1 && typeof rawArgs[0] === "string") {
2264
- params = { url: rawArgs[0] };
2265
- } else {
2266
- params = Object.fromEntries(rawArgs.map((a, i) => [String(i), a]));
2267
- }
2268
- events.push({
2269
- type: "before",
2270
- callId,
2271
- startTime: startMs,
2272
- class: action.class,
2273
- method: action.method,
2274
- pageId,
2275
- params,
2276
- title: formatActionTitle(action, cmd.args, params),
2277
- apiName: `${action.class.toLowerCase()}.${action.method}`,
2278
- parentId: groupCallId
2279
- });
2280
- const afterEvent = {
2281
- type: "after",
2282
- callId,
2283
- endTime: endMs
2284
- };
2285
- if (cmd.error) {
2286
- const err = cmd.error;
2287
- afterEvent.error = { message: err.message ?? String(cmd.error) };
2288
- }
2289
- events.push(afterEvent);
2290
- prevEndMs = endMs;
2291
- }
2292
- if (lastTestUid && groupCallId) {
2293
- callCounter++;
2294
- events.push({
2295
- type: "after",
2296
- callId: groupCallId,
2297
- endTime: prevEndMs
2298
- });
2299
- }
2300
- return events;
2301
- }
2302
- function buildNetworkNdjson(requests, wallTime, pageId) {
3634
+ function buildNetworkNdjson(requests, wallTime, pageId, sha1ByRequestId) {
2303
3635
  if (!requests.length) {
2304
3636
  return Buffer.alloc(0);
2305
3637
  }
2306
3638
  const lines = requests.map((r) => {
2307
- const entry = networkRequestToHar(r);
3639
+ const entry = networkRequestToHar(r, {
3640
+ bodySha1: sha1ByRequestId.get(r.id)
3641
+ });
2308
3642
  entry.snapshot = {
2309
3643
  ...entry.snapshot,
2310
3644
  // Monotonic offset so the viewer positions bars on the timeline.
@@ -2316,51 +3650,6 @@ function buildNetworkNdjson(requests, wallTime, pageId) {
2316
3650
  });
2317
3651
  return Buffer.from(lines.join("\n"), "utf8");
2318
3652
  }
2319
- function buildSnapshotResources(snapshots, pageId) {
2320
- const out = [];
2321
- for (const snap of snapshots) {
2322
- const base = `${pageId}-${snap.timestamp}`;
2323
- if (snap.screenshot) {
2324
- out.push({
2325
- resourceName: `${base}.jpeg`,
2326
- data: Buffer.from(snap.screenshot, "base64")
2327
- });
2328
- }
2329
- if (snap.elements && snap.elements.length) {
2330
- out.push({
2331
- resourceName: `${base}-elements.json`,
2332
- data: Buffer.from(JSON.stringify(snap.elements), "utf8")
2333
- });
2334
- }
2335
- if (snap.snapshotText) {
2336
- out.push({
2337
- resourceName: `${base}-snapshot.txt`,
2338
- data: Buffer.from(snap.snapshotText, "utf8")
2339
- });
2340
- }
2341
- }
2342
- return out;
2343
- }
2344
- function buildScreencastFrames(snapshots, pageId, wallTime, viewport) {
2345
- return snapshots.filter((s) => s.screenshot).map((s) => {
2346
- const base = `${pageId}-${s.timestamp}`;
2347
- const frame = {
2348
- type: "screencast-frame",
2349
- pageId,
2350
- sha1: `${base}.jpeg`,
2351
- width: viewport.width,
2352
- height: viewport.height,
2353
- timestamp: Math.max(0, s.timestamp - wallTime)
2354
- };
2355
- if (s.elements && s.elements.length) {
2356
- frame.elements = `${base}-elements.json`;
2357
- }
2358
- if (s.snapshotText) {
2359
- frame.snapshot = `${base}-snapshot.txt`;
2360
- }
2361
- return frame;
2362
- });
2363
- }
2364
3653
  function eventTime(e) {
2365
3654
  switch (e.type) {
2366
3655
  case "context-options":
@@ -2371,6 +3660,13 @@ function eventTime(e) {
2371
3660
  return e.endTime;
2372
3661
  case "screencast-frame":
2373
3662
  return e.timestamp;
3663
+ case "frame-snapshot":
3664
+ return e.snapshot.timestamp;
3665
+ case "console":
3666
+ return e.time;
3667
+ case "stdout":
3668
+ case "stderr":
3669
+ return e.timestamp;
2374
3670
  }
2375
3671
  }
2376
3672
  function eventOrder(e) {
@@ -2380,9 +3676,14 @@ function eventOrder(e) {
2380
3676
  case "after":
2381
3677
  return 1;
2382
3678
  case "screencast-frame":
3679
+ case "frame-snapshot":
2383
3680
  return 2;
2384
- case "before":
3681
+ case "console":
3682
+ case "stdout":
3683
+ case "stderr":
2385
3684
  return 3;
3685
+ case "before":
3686
+ return 4;
2386
3687
  }
2387
3688
  }
2388
3689
  function compareEvents(a, b) {
@@ -2392,8 +3693,11 @@ function compareEvents(a, b) {
2392
3693
  function generateTranscript(commands, startWallTime, title) {
2393
3694
  const wallTimeISO = new Date(startWallTime).toISOString();
2394
3695
  const lines = [`# ${title ?? "Session"} \u2014 ${wallTimeISO}`, ""];
3696
+ const ordered = [...commands].sort(
3697
+ (a, b) => (a.startTime ?? a.timestamp) - (b.startTime ?? b.timestamp)
3698
+ );
2395
3699
  const captured = [];
2396
- for (const c of commands) {
3700
+ for (const c of ordered) {
2397
3701
  const action = mapCommandToAction(String(c.command));
2398
3702
  if (action) {
2399
3703
  captured.push({ entry: c, action });
@@ -2417,51 +3721,72 @@ function generateTranscript(commands, startWallTime, title) {
2417
3721
  });
2418
3722
  return lines.join("\n");
2419
3723
  }
2420
- function buildTraceBundle(trace, opts = {}) {
2421
- const firstCommandTs = trace.commands[0]?.timestamp;
2422
- const wallTime = opts.wallTimeOverride ?? firstCommandTs ?? Date.now();
2423
- const idPrefix = shortId(opts.sessionId);
2424
- const contextId = `context@${idPrefix}`;
2425
- const pageId = `page@${idPrefix}`;
3724
+ function buildEventStream(trace, ctxOptions, pageId, wallTime, testMetadata, denseFrameEvents = []) {
2426
3725
  const viewport = trace.metadata.viewport ?? { width: 1280, height: 720 };
2427
3726
  const snapshots = trace.actionSnapshots ?? [];
2428
- const ctxOptions = buildContextOptions(trace, contextId, wallTime);
2429
- const events = [ctxOptions];
2430
- const firstSnap = snapshots.find((s) => s.screenshot);
2431
- if (firstSnap) {
2432
- const base = `${pageId}-${firstSnap.timestamp}`;
2433
- const initFrame = {
2434
- type: "screencast-frame",
3727
+ const snapshotIndex = new FrameSnapshotIndex(snapshots);
3728
+ const filmstrip = denseFrameEvents.length > 0 ? denseFrameEvents : buildFilmstripEvents(snapshots, pageId, wallTime, viewport);
3729
+ const events = [
3730
+ ctxOptions,
3731
+ ...filmstrip,
3732
+ ...buildActionEvents(
3733
+ trace.commands,
2435
3734
  pageId,
2436
- sha1: `${base}.jpeg`,
2437
- width: viewport.width,
2438
- height: viewport.height,
2439
- timestamp: 0
2440
- };
2441
- if (firstSnap.elements && firstSnap.elements.length) {
2442
- initFrame.elements = `${base}-elements.json`;
2443
- }
2444
- if (firstSnap.snapshotText) {
2445
- initFrame.snapshot = `${base}-snapshot.txt`;
2446
- }
2447
- events.push(initFrame);
2448
- }
2449
- events.push(
2450
- ...buildScreencastFrames(
2451
- firstSnap ? snapshots.filter((s) => s !== firstSnap) : snapshots,
3735
+ wallTime,
3736
+ testMetadata,
3737
+ snapshotIndex
3738
+ ),
3739
+ ...buildImageFrameSnapshots(
3740
+ snapshotIndex.refs(),
2452
3741
  pageId,
2453
3742
  wallTime,
2454
3743
  viewport
2455
3744
  ),
2456
- ...buildActionEvents(trace.commands, pageId, wallTime, opts.testMetadata)
2457
- );
3745
+ ...buildConsoleEvents(trace.consoleLogs, pageId, wallTime)
3746
+ ];
2458
3747
  events.sort(compareEvents);
2459
- const ctxBName = ctxOptions.title;
3748
+ return events;
3749
+ }
3750
+ function buildTraceBundle(trace, opts = {}) {
3751
+ const firstCommandTs = trace.commands[0]?.timestamp;
3752
+ const wallTime = opts.wallTimeOverride ?? firstCommandTs ?? Date.now();
3753
+ const { contextId, pageId } = allocateTraceIds(opts.sessionId);
3754
+ const ctxOptions = buildContextOptions(trace, contextId, wallTime);
3755
+ const dense = buildDenseScreencast(
3756
+ trace.screencastFrames ?? [],
3757
+ pageId,
3758
+ wallTime,
3759
+ trace.metadata.viewport ?? { width: 1280, height: 720 }
3760
+ );
3761
+ const events = buildEventStream(
3762
+ trace,
3763
+ ctxOptions,
3764
+ pageId,
3765
+ wallTime,
3766
+ opts.testMetadata,
3767
+ dense.events
3768
+ );
3769
+ const networkBodies = buildNetworkBodyResources(trace.networkRequests);
2460
3770
  return {
2461
3771
  traceNdjson: events.map((e) => JSON.stringify(e)).join("\n") + "\n",
2462
- networkNdjson: buildNetworkNdjson(trace.networkRequests, wallTime, pageId),
2463
- transcriptMd: generateTranscript(trace.commands, wallTime, ctxBName),
2464
- resources: buildSnapshotResources(snapshots, pageId)
3772
+ networkNdjson: buildNetworkNdjson(
3773
+ trace.networkRequests,
3774
+ wallTime,
3775
+ pageId,
3776
+ networkBodies.sha1ByRequestId
3777
+ ),
3778
+ transcriptMd: generateTranscript(
3779
+ trace.commands,
3780
+ wallTime,
3781
+ ctxOptions.title
3782
+ ),
3783
+ mutationsNdjson: buildMutationsNdjson(trace.mutations ?? []).ndjson,
3784
+ resources: [
3785
+ ...buildSnapshotResources(trace.actionSnapshots ?? [], pageId),
3786
+ ...dense.resources,
3787
+ ...buildSourceResources(trace.sources),
3788
+ ...networkBodies.resources
3789
+ ]
2465
3790
  };
2466
3791
  }
2467
3792
  async function exportTraceZip(trace, opts = {}) {
@@ -2470,25 +3795,30 @@ async function exportTraceZip(trace, opts = {}) {
2470
3795
  traceNdjson: bundle.traceNdjson,
2471
3796
  networkNdjson: bundle.networkNdjson,
2472
3797
  resources: bundle.resources,
2473
- transcriptMd: bundle.transcriptMd
3798
+ transcriptMd: bundle.transcriptMd,
3799
+ mutationsNdjson: bundle.mutationsNdjson
2474
3800
  });
2475
3801
  }
2476
3802
  async function exportTraceDirectory(trace, targetDir, opts = {}) {
2477
3803
  const bundle = buildTraceBundle(trace, opts);
2478
- await fs.mkdir(path.join(targetDir, "resources"), { recursive: true });
3804
+ await fs6.mkdir(path5.join(targetDir, "resources"), { recursive: true });
2479
3805
  await Promise.all([
2480
- fs.writeFile(path.join(targetDir, "trace.trace"), bundle.traceNdjson),
2481
- fs.writeFile(
2482
- path.join(targetDir, "transcript.md"),
3806
+ fs6.writeFile(path5.join(targetDir, "trace.trace"), bundle.traceNdjson),
3807
+ fs6.writeFile(
3808
+ path5.join(targetDir, "transcript.md"),
2483
3809
  bundle.transcriptMd,
2484
3810
  "utf8"
2485
3811
  ),
2486
- bundle.networkNdjson.length ? fs.writeFile(
2487
- path.join(targetDir, "trace.network"),
3812
+ bundle.networkNdjson.length ? fs6.writeFile(
3813
+ path5.join(targetDir, "trace.network"),
2488
3814
  bundle.networkNdjson
2489
3815
  ) : Promise.resolve(),
3816
+ bundle.mutationsNdjson.length ? fs6.writeFile(
3817
+ path5.join(targetDir, "trace.mutations"),
3818
+ bundle.mutationsNdjson
3819
+ ) : Promise.resolve(),
2490
3820
  ...bundle.resources.map(
2491
- (r) => fs.writeFile(path.join(targetDir, "resources", r.resourceName), r.data)
3821
+ (r) => fs6.writeFile(path5.join(targetDir, "resources", r.resourceName), r.data)
2492
3822
  )
2493
3823
  ]);
2494
3824
  }
@@ -2506,145 +3836,508 @@ async function writeTraceZip(capturer, opts) {
2506
3836
  },
2507
3837
  commands: capturer.commandsLog,
2508
3838
  sources: Object.fromEntries(capturer.sources),
2509
- ...actionSnapshots.length ? { actionSnapshots } : {}
3839
+ ...actionSnapshots.length ? { actionSnapshots } : {},
3840
+ ...opts.screencastFrames?.length ? { screencastFrames: [...opts.screencastFrames] } : {}
2510
3841
  };
2511
- await fs.mkdir(opts.outputDir, { recursive: true });
3842
+ await fs6.mkdir(opts.outputDir, { recursive: true });
2512
3843
  const exportOpts = {
2513
3844
  sessionId: opts.sessionId,
2514
3845
  wallTimeOverride: capturer.startWallTime,
2515
3846
  testMetadata: opts.testMetadata
2516
3847
  };
3848
+ const stem = opts.fileStem ?? `trace-${opts.sessionId}`;
2517
3849
  if (opts.format === "ndjson-directory") {
2518
- const dir = path.join(opts.outputDir, `trace-${opts.sessionId}`);
2519
- await fs.mkdir(dir, { recursive: true });
3850
+ const dir = path5.join(opts.outputDir, stem);
3851
+ await fs6.mkdir(dir, { recursive: true });
2520
3852
  await exportTraceDirectory(traceLog, dir, exportOpts);
2521
3853
  return dir;
2522
3854
  }
2523
3855
  const zip = await exportTraceZip(traceLog, exportOpts);
2524
- const zipPath = path.join(opts.outputDir, `trace-${opts.sessionId}.zip`);
2525
- await fs.writeFile(zipPath, zip);
3856
+ const zipPath = path5.join(opts.outputDir, `${stem}.zip`);
3857
+ await fs6.writeFile(zipPath, zip);
2526
3858
  return zipPath;
2527
3859
  }
2528
- function synthesizeSnapshotsFromCommands(commands) {
2529
- return commands.filter((c) => c.screenshot && mapCommandToAction(c.command)).map((c) => ({
2530
- timestamp: c.timestamp,
2531
- command: c.command,
2532
- screenshot: c.screenshot
3860
+ function synthesizeSnapshotsFromCommands(commands) {
3861
+ return commands.filter((c) => c.screenshot && mapCommandToAction(c.command)).map((c) => ({
3862
+ timestamp: c.timestamp,
3863
+ command: c.command,
3864
+ screenshot: c.screenshot
3865
+ }));
3866
+ }
3867
+
3868
+ // ../core/src/spec-trace-helpers.ts
3869
+ import path6 from "path";
3870
+
3871
+ // ../core/src/uid.ts
3872
+ function deterministicUid(...parts) {
3873
+ const hash = parts.join("::").split("").reduce((acc, char) => (acc << 5) - acc + char.charCodeAt(0) | 0, 0);
3874
+ return `stable-${Math.abs(hash).toString(36)}`;
3875
+ }
3876
+ var signatureCounters = /* @__PURE__ */ new Map();
3877
+ function generateStableUid(file, name) {
3878
+ const signature = `${file}::${name}`;
3879
+ const count = signatureCounters.get(signature) ?? 0;
3880
+ signatureCounters.set(signature, count + 1);
3881
+ const input = count > 0 ? `${signature}::${count}` : signature;
3882
+ return deterministicUid(input);
3883
+ }
3884
+ function resetSignatureCounters() {
3885
+ signatureCounters.clear();
3886
+ }
3887
+
3888
+ // ../core/src/spec-trace-helpers.ts
3889
+ function findFlushableRange(ranges, testUid) {
3890
+ if (testUid !== void 0) {
3891
+ for (let i = ranges.length - 1; i >= 0; i--) {
3892
+ if (ranges[i].testUid === testUid) {
3893
+ return ranges[i];
3894
+ }
3895
+ }
3896
+ return void 0;
3897
+ }
3898
+ return ranges[ranges.length - 1];
3899
+ }
3900
+ function sanitizeSpecName(specFile) {
3901
+ const cleaned = trimChar(
3902
+ specFile.replace(/^.*[/\\]/, "").replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]/g, "_"),
3903
+ "_"
3904
+ );
3905
+ return cleaned || "unknown-spec";
3906
+ }
3907
+ function buildSpecSessionId(specFile, sessionId) {
3908
+ const base = sanitizeSpecName(specFile);
3909
+ const hash = deterministicUid(specFile).split("-").pop().slice(0, 8);
3910
+ return `${base}-${hash}-${sessionId.slice(0, 8)}`;
3911
+ }
3912
+ function buildTestSliceSessionId(specFile, key, sessionId) {
3913
+ const base = sanitizeSpecName(specFile);
3914
+ const hash = deterministicUid(key).split("-").pop().slice(0, 8);
3915
+ return `${base}-${hash}-${sessionId.slice(0, 8)}`;
3916
+ }
3917
+ var MAX_SLUG_LENGTH = 60;
3918
+ function slugify(text) {
3919
+ const collapsed = trimChar(
3920
+ text.toLowerCase().replace(/[^a-z0-9]+/g, "-"),
3921
+ "-"
3922
+ );
3923
+ return trimChar(collapsed.slice(0, MAX_SLUG_LENGTH), "-");
3924
+ }
3925
+ function buildTestSliceFolder(specFile, testTitle, browser, key) {
3926
+ const specBase = sanitizeSpecName(path6.basename(specFile));
3927
+ const keyHash = deterministicUid(key).split("-").pop().slice(0, 8);
3928
+ const titleSlug = slugify(testTitle ?? "") || keyHash;
3929
+ const browserSlug = slugify(browser ?? "") || "browser";
3930
+ const retryMatch = key.match(/-retry(\d+)$/);
3931
+ const retrySuffix = retryMatch ? `-retry${retryMatch[1]}` : "";
3932
+ return `${specBase}-${titleSlug}-${browserSlug}-${keyHash}${retrySuffix}`;
3933
+ }
3934
+ function buildSpecCapturer(capturer, range, nextRange) {
3935
+ const end = nextRange ? {
3936
+ commands: nextRange.commandStartIdx,
3937
+ console: nextRange.consoleStartIdx,
3938
+ network: nextRange.networkStartIdx,
3939
+ mutations: nextRange.mutationStartIdx,
3940
+ traceLogs: nextRange.traceLogStartIdx
3941
+ } : void 0;
3942
+ return {
3943
+ mutations: capturer.mutations.slice(range.mutationStartIdx, end?.mutations),
3944
+ traceLogs: capturer.traceLogs.slice(range.traceLogStartIdx, end?.traceLogs),
3945
+ consoleLogs: capturer.consoleLogs.slice(
3946
+ range.consoleStartIdx,
3947
+ end?.console
3948
+ ),
3949
+ networkRequests: capturer.networkRequests.slice(
3950
+ range.networkStartIdx,
3951
+ end?.network
3952
+ ),
3953
+ commandsLog: capturer.commandsLog.slice(
3954
+ range.commandStartIdx,
3955
+ end?.commands
3956
+ ),
3957
+ sources: new Map(capturer.sources),
3958
+ metadata: capturer.metadata,
3959
+ startWallTime: capturer.startWallTime
3960
+ };
3961
+ }
3962
+ function filterTestMetadataBySpec(allMetadata, specFile) {
3963
+ const filtered = /* @__PURE__ */ new Map();
3964
+ for (const [uid, meta] of allMetadata) {
3965
+ if (meta.specFile === specFile) {
3966
+ filtered.set(uid, meta);
3967
+ }
3968
+ }
3969
+ return filtered;
3970
+ }
3971
+ function filterTestMetadataByUid(allMetadata, testUid) {
3972
+ const filtered = /* @__PURE__ */ new Map();
3973
+ const entry = allMetadata.get(testUid);
3974
+ if (entry) {
3975
+ filtered.set(testUid, entry);
3976
+ }
3977
+ return filtered;
3978
+ }
3979
+ function pushSliceRange(ctx, specFile, key, testUid, suppressSameKey) {
3980
+ const lastRange = ctx.specRanges[ctx.specRanges.length - 1];
3981
+ if (suppressSameKey && lastRange && lastRange.key === key) {
3982
+ return null;
3983
+ }
3984
+ const prevRange = lastRange && !ctx.flushedSpecs.has(lastRange.key) ? lastRange : null;
3985
+ ctx.specRanges.push({
3986
+ specFile,
3987
+ key,
3988
+ testUid,
3989
+ commandStartIdx: ctx.capturer.commandsLog.length,
3990
+ consoleStartIdx: ctx.capturer.consoleLogs.length,
3991
+ networkStartIdx: ctx.capturer.networkRequests.length,
3992
+ mutationStartIdx: ctx.capturer.mutations.length,
3993
+ traceLogStartIdx: ctx.capturer.traceLogs.length
3994
+ });
3995
+ return prevRange;
3996
+ }
3997
+ function recordSliceBoundary(ctx, granularity, specFile, testUid) {
3998
+ if (granularity === "spec") {
3999
+ return pushSliceRange(ctx, specFile, specFile, void 0, true);
4000
+ }
4001
+ if (granularity === "test" && testUid !== void 0) {
4002
+ const priorAttempts = ctx.specRanges.filter(
4003
+ (r) => r.testUid === testUid
4004
+ ).length;
4005
+ const key = priorAttempts === 0 ? testUid : `${testUid}-retry${priorAttempts}`;
4006
+ return pushSliceRange(ctx, specFile, key, testUid, false);
4007
+ }
4008
+ return null;
4009
+ }
4010
+ function recordSpecBoundary(ctx, specFile, traceGranularity) {
4011
+ return recordSliceBoundary(ctx, traceGranularity, specFile);
4012
+ }
4013
+ function sliceByWindow(items, start2, end) {
4014
+ if (start2 === void 0) {
4015
+ return [];
4016
+ }
4017
+ return items.filter(
4018
+ (i) => i.timestamp >= start2 && (end === void 0 || i.timestamp < end)
4019
+ );
4020
+ }
4021
+ async function writeSliceTrace(input, sliceSessionId, testMetadata, overrides = {}) {
4022
+ const sliceCapturer = buildSpecCapturer(
4023
+ input.capturer,
4024
+ input.range,
4025
+ input.nextRange
4026
+ );
4027
+ const commandStart = (c) => c ? c.startTime ?? c.timestamp : void 0;
4028
+ const fallbackStart = () => {
4029
+ const times = [
4030
+ ...sliceCapturer.consoleLogs.map((c) => c.timestamp),
4031
+ ...sliceCapturer.networkRequests.map((n) => n.timestamp)
4032
+ ];
4033
+ return times.length ? Math.min(...times) : void 0;
4034
+ };
4035
+ const windowStart = commandStart(sliceCapturer.commandsLog[0]) ?? fallbackStart();
4036
+ const windowEnd = input.nextRange ? commandStart(input.capturer.commandsLog[input.nextRange.commandStartIdx]) : void 0;
4037
+ const sliceSnapshots = sliceByWindow(
4038
+ input.actionSnapshots,
4039
+ windowStart,
4040
+ windowEnd
4041
+ );
4042
+ const sliceFrames = input.screencastFrames ? sliceByWindow(input.screencastFrames, windowStart, windowEnd) : void 0;
4043
+ if (windowStart !== void 0) {
4044
+ sliceCapturer.startWallTime = windowStart;
4045
+ }
4046
+ return writeTraceZip(sliceCapturer, {
4047
+ outputDir: overrides.outputDir ?? input.outputDir,
4048
+ sessionId: sliceSessionId,
4049
+ fileStem: overrides.fileStem,
4050
+ capabilities: input.capabilities,
4051
+ actionSnapshots: sliceSnapshots.length > 0 ? sliceSnapshots : void 0,
4052
+ screencastFrames: sliceFrames?.length ? sliceFrames : void 0,
4053
+ format: input.format,
4054
+ testMetadata
4055
+ });
4056
+ }
4057
+ async function writeSpecTrace(input) {
4058
+ return writeSliceTrace(
4059
+ input,
4060
+ buildSpecSessionId(input.range.specFile, input.sessionId),
4061
+ filterTestMetadataBySpec(input.testMetadata, input.range.specFile)
4062
+ );
4063
+ }
4064
+ async function writeTestSliceTrace(input) {
4065
+ const testUid = input.range.testUid ?? input.range.key;
4066
+ const title = input.testMetadata.get(testUid)?.title;
4067
+ const browserName = input.capabilities?.browserName;
4068
+ const folder = buildTestSliceFolder(
4069
+ input.range.specFile,
4070
+ title,
4071
+ browserName,
4072
+ input.range.key
4073
+ );
4074
+ return writeSliceTrace(
4075
+ input,
4076
+ buildTestSliceSessionId(
4077
+ input.range.specFile,
4078
+ input.range.key,
4079
+ input.sessionId
4080
+ ),
4081
+ filterTestMetadataByUid(input.testMetadata, testUid),
4082
+ { outputDir: path6.join(input.outputDir, folder), fileStem: "trace" }
4083
+ );
4084
+ }
4085
+
4086
+ // ../core/src/trace-finalizer.ts
4087
+ var SPEC_WITHOUT_BOUNDARIES_WARNING = 'traceGranularity is "spec" but no spec boundaries were detected (the runner may not expose per-test hooks). Falling back to session-level trace.';
4088
+ var TEST_WITHOUT_BOUNDARIES_WARNING = 'traceGranularity is "test" but no test boundaries were detected (the runner may not expose per-test hooks). Falling back to session-level trace.';
4089
+ var SLICE_COUNT_WARN_THRESHOLD = 200;
4090
+ var PENDING_SETTLE_TIMEOUT_MS = 5e3;
4091
+ async function settlePending(pending, timeoutMs) {
4092
+ let timer;
4093
+ const timedOut = new Promise((resolve2) => {
4094
+ timer = setTimeout(() => resolve2(true), timeoutMs);
4095
+ timer.unref?.();
4096
+ });
4097
+ const settled = Promise.allSettled(pending).then(() => false);
4098
+ const result = await Promise.race([settled, timedOut]);
4099
+ if (timer) {
4100
+ clearTimeout(timer);
4101
+ }
4102
+ return result;
4103
+ }
4104
+ async function awaitPendingCaptures(ctx) {
4105
+ if (!ctx.awaitPending?.length) {
4106
+ return;
4107
+ }
4108
+ const timedOut = await settlePending(
4109
+ ctx.awaitPending,
4110
+ PENDING_SETTLE_TIMEOUT_MS
4111
+ );
4112
+ if (timedOut) {
4113
+ ctx.log?.(
4114
+ "warn",
4115
+ `One or more of ${ctx.awaitPending.length} snapshot capture(s) did not settle within ${PENDING_SETTLE_TIMEOUT_MS}ms; writing trace with the snapshots captured so far.`
4116
+ );
4117
+ }
4118
+ }
4119
+ function sliceCountWarning(count) {
4120
+ return `traceGranularity produced ${count} trace slices. Consider pairing it with a retention policy (e.g. tracePolicy: "retain-on-failure") to avoid writing hundreds of trace archives.`;
4121
+ }
4122
+ function toOutcomes(metadata) {
4123
+ return Array.from(metadata.values(), (m) => ({
4124
+ state: m.state,
4125
+ attempt: m.attempt
2533
4126
  }));
2534
4127
  }
2535
-
2536
- // ../core/src/uid.ts
2537
- function deterministicUid(...parts) {
2538
- const hash = parts.join("::").split("").reduce((acc, char) => (acc << 5) - acc + char.charCodeAt(0) | 0, 0);
2539
- return `stable-${Math.abs(hash).toString(36)}`;
4128
+ function shouldRetain(ctx, metadata, ledgerOutcomes) {
4129
+ return shouldRetainTrace(ctx.policy, {
4130
+ outcomes: ledgerOutcomes?.length ? ledgerOutcomes : toOutcomes(metadata),
4131
+ attemptInfoAvailable: ctx.attemptInfoAvailable ?? false
4132
+ }).retain;
2540
4133
  }
2541
- var signatureCounters = /* @__PURE__ */ new Map();
2542
- function generateStableUid(file, name) {
2543
- const signature = `${file}::${name}`;
2544
- const count = signatureCounters.get(signature) ?? 0;
2545
- signatureCounters.set(signature, count + 1);
2546
- const input = count > 0 ? `${signature}::${count}` : signature;
2547
- return deterministicUid(input);
4134
+ function attemptFromKey(key) {
4135
+ const match = /-retry(\d+)$/.exec(key);
4136
+ return match ? Number(match[1]) : 0;
2548
4137
  }
2549
- function resetSignatureCounters() {
2550
- signatureCounters.clear();
4138
+ function sliceOutcomeView(ctx, range, isTestSlice) {
4139
+ if (!ctx.outcomes) {
4140
+ return void 0;
4141
+ }
4142
+ return isTestSlice ? ctx.outcomes.forTest(range.testUid, attemptFromKey(range.key)) : ctx.outcomes.forSpec(range.specFile);
2551
4143
  }
2552
-
2553
- // ../core/src/finalize-screencast.ts
2554
- import fs3 from "fs";
2555
- import os2 from "os";
2556
- import path3 from "path";
2557
-
2558
- // ../core/src/video-encoder.ts
2559
- import fs2 from "fs/promises";
2560
- import path2 from "path";
2561
- import os from "os";
2562
- import { createRequire } from "module";
2563
- var require2 = createRequire(import.meta.url);
2564
- function loadFfmpeg() {
4144
+ async function flushRangeTrace(ctx, range, nextRange) {
4145
+ if (ctx.mode !== "trace") {
4146
+ return void 0;
4147
+ }
4148
+ if (ctx.flushed.has(range.key)) {
4149
+ return void 0;
4150
+ }
4151
+ ctx.flushed.add(range.key);
4152
+ const isTestSlice = range.testUid !== void 0;
4153
+ const sliceMetadata = isTestSlice ? filterTestMetadataByUid(ctx.testMetadata, range.testUid) : filterTestMetadataBySpec(ctx.testMetadata, range.specFile);
4154
+ const outcomes = sliceOutcomeView(ctx, range, isTestSlice);
4155
+ const artifact = {
4156
+ kind: "trace",
4157
+ path: "",
4158
+ scope: isTestSlice ? "test" : "spec",
4159
+ key: range.key,
4160
+ testUids: Array.from(sliceMetadata.keys()),
4161
+ retained: shouldRetain(ctx, sliceMetadata, outcomes)
4162
+ };
4163
+ if (!artifact.retained) {
4164
+ ctx.onArtifact?.(artifact);
4165
+ return artifact;
4166
+ }
4167
+ const writeSlice = isTestSlice ? writeTestSliceTrace : writeSpecTrace;
4168
+ artifact.path = await writeSlice({
4169
+ range,
4170
+ nextRange,
4171
+ capturer: ctx.capturer,
4172
+ actionSnapshots: ctx.actionSnapshots ?? [],
4173
+ screencastFrames: ctx.screencastFrames,
4174
+ sessionId: ctx.sessionId,
4175
+ outputDir: ctx.resolveOutputDir(range),
4176
+ format: ctx.format,
4177
+ testMetadata: ctx.testMetadata,
4178
+ capabilities: ctx.capabilities
4179
+ });
4180
+ ctx.log?.(
4181
+ "info",
4182
+ `Trace for ${isTestSlice ? "test" : "spec"} "${range.key}" saved to ${artifact.path}`
4183
+ );
4184
+ ctx.onArtifact?.(artifact);
4185
+ return artifact;
4186
+ }
4187
+ async function flushRangeLogged(ctx, range) {
2565
4188
  try {
2566
- return require2("fluent-ffmpeg");
2567
- } catch {
2568
- throw new Error(
2569
- "VideoEncoder: fluent-ffmpeg is required for screencast encoding. Install it with: npm install fluent-ffmpeg"
4189
+ return await flushRangeTrace(ctx, range);
4190
+ } catch (err) {
4191
+ const label = range.testUid !== void 0 ? `test "${range.key}"` : `spec "${range.specFile}"`;
4192
+ ctx.log?.(
4193
+ "warn",
4194
+ `Failed to flush trace for ${label}: ${errorMessage(err)}`
2570
4195
  );
4196
+ return void 0;
2571
4197
  }
2572
4198
  }
2573
- async function writeFramesAndManifest(frames, tmpDir, ext) {
2574
- const manifestLines = ["ffconcat version 1.0"];
2575
- for (let i = 0; i < frames.length; i++) {
2576
- const frameName = `frame-${String(i).padStart(6, "0")}.${ext}`;
2577
- const framePath = path2.join(tmpDir, frameName);
2578
- await fs2.writeFile(framePath, Buffer.from(frames[i].data, "base64"));
2579
- const nextTs = frames[i + 1]?.timestamp ?? frames[i].timestamp + 100;
2580
- const durationSecs = Math.max((nextTs - frames[i].timestamp) / 1e3, 0.01);
2581
- manifestLines.push(`file '${framePath}'`);
2582
- manifestLines.push(`duration ${durationSecs.toFixed(6)}`);
4199
+ async function writeSessionTrace(ctx) {
4200
+ const prepare = ctx.prepareSnapshots ?? ((s) => s);
4201
+ const snapshots = prepare(ctx.actionSnapshots ?? []);
4202
+ const artifact = {
4203
+ kind: "trace",
4204
+ path: "",
4205
+ scope: "session",
4206
+ testUids: Array.from(ctx.testMetadata.keys()),
4207
+ retained: shouldRetain(ctx, ctx.testMetadata, ctx.outcomes?.all())
4208
+ };
4209
+ if (!artifact.retained) {
4210
+ ctx.onArtifact?.(artifact);
4211
+ return artifact;
4212
+ }
4213
+ artifact.path = await writeTraceZip(ctx.capturer, {
4214
+ outputDir: ctx.resolveOutputDir(),
4215
+ sessionId: ctx.sessionId,
4216
+ capabilities: ctx.capabilities,
4217
+ actionSnapshots: snapshots.length ? snapshots : void 0,
4218
+ screencastFrames: ctx.screencastFrames,
4219
+ format: ctx.format,
4220
+ testMetadata: ctx.testMetadata
4221
+ });
4222
+ ctx.log?.("info", `Trace saved to ${artifact.path}`);
4223
+ ctx.onArtifact?.(artifact);
4224
+ return artifact;
4225
+ }
4226
+ async function safely(ctx, write) {
4227
+ try {
4228
+ return await write();
4229
+ } catch (err) {
4230
+ ctx.log?.("warn", `trace write failed: ${errorMessage(err)}`);
4231
+ return void 0;
2583
4232
  }
2584
- const lastFramePath = path2.join(
2585
- tmpDir,
2586
- `frame-${String(frames.length - 1).padStart(6, "0")}.${ext}`
2587
- );
2588
- manifestLines.push(`file '${lastFramePath}'`);
2589
- const manifestPath = path2.join(tmpDir, "manifest.txt");
2590
- await fs2.writeFile(manifestPath, manifestLines.join("\n"));
2591
- return manifestPath;
2592
4233
  }
2593
- function classifyFfmpegError(err) {
2594
- const msg = err.message || "";
2595
- if (msg.includes("Cannot find ffmpeg") || msg.includes("ENOENT") || msg.includes("spawn") || msg.includes("not found")) {
2596
- return new Error(
2597
- "VideoEncoder: ffmpeg binary not found on PATH. Install ffmpeg: https://ffmpeg.org/download.html"
4234
+ async function flushAllRanges(ctx) {
4235
+ const artifacts = [];
4236
+ for (let i = 0; i < ctx.ranges.length; i++) {
4237
+ const range = ctx.ranges[i];
4238
+ const nextRange = ctx.ranges[i + 1];
4239
+ const artifact = await safely(
4240
+ ctx,
4241
+ () => flushRangeTrace(ctx, range, nextRange)
2598
4242
  );
4243
+ if (artifact) {
4244
+ artifacts.push(artifact);
4245
+ }
2599
4246
  }
2600
- return new Error(`VideoEncoder: ffmpeg error \u2014 ${msg}`);
4247
+ return artifacts;
2601
4248
  }
2602
- function runFfmpeg(ffmpeg, manifestPath, outputPath) {
2603
- return new Promise((resolve2, reject) => {
2604
- ffmpeg().input(manifestPath).inputOptions(["-f", "concat", "-safe", "0"]).videoCodec("libvpx").outputOptions([
2605
- "-b:v",
2606
- "1M",
2607
- "-pix_fmt",
2608
- "yuv420p",
2609
- "-vsync",
2610
- "cfr",
2611
- "-r",
2612
- "10",
2613
- // Force a keyframe every frame. VP8 can only seek to keyframes, and
2614
- // libvpx's default GOP places a single keyframe on near-static
2615
- // screencast content — so scrubber/marker seeks snap back to the
2616
- // start. At 10fps over short recordings, all-intra keeps seeking
2617
- // frame-accurate at an acceptable size.
2618
- "-g",
2619
- "1",
2620
- "-keyint_min",
2621
- "1",
2622
- "-auto-alt-ref",
2623
- "0",
2624
- "-disposition:v",
2625
- "default"
2626
- ]).output(outputPath).on("end", () => resolve2()).on("error", (err) => reject(classifyFfmpegError(err))).run();
2627
- });
4249
+ async function finalizeTraceExport(ctx) {
4250
+ if (ctx.mode !== "trace") {
4251
+ return [];
4252
+ }
4253
+ await awaitPendingCaptures(ctx);
4254
+ const artifacts = await fanOutTraceWrites(ctx);
4255
+ await maybeWriteManifest(ctx, artifacts);
4256
+ return artifacts;
2628
4257
  }
2629
- async function encodeToVideo(frames, outputPath, options = {}) {
2630
- if (frames.length === 0) {
2631
- throw new Error("VideoEncoder: no frames to encode");
4258
+ async function fanOutTraceWrites(ctx) {
4259
+ const sliced = ctx.granularity === "spec" || ctx.granularity === "test";
4260
+ if (sliced && ctx.ranges.length > 0) {
4261
+ if (ctx.ranges.length > SLICE_COUNT_WARN_THRESHOLD) {
4262
+ ctx.log?.("warn", sliceCountWarning(ctx.ranges.length));
4263
+ }
4264
+ return flushAllRanges(ctx);
2632
4265
  }
2633
- const ffmpeg = loadFfmpeg();
2634
- const ext = options.captureFormat === "png" ? "png" : "jpg";
2635
- const tmpDir = await fs2.mkdtemp(
2636
- path2.join(os.tmpdir(), "devtools-screencast-")
2637
- );
4266
+ if (ctx.granularity === "spec") {
4267
+ ctx.log?.("warn", SPEC_WITHOUT_BOUNDARIES_WARNING);
4268
+ } else if (ctx.granularity === "test") {
4269
+ ctx.log?.("warn", TEST_WITHOUT_BOUNDARIES_WARNING);
4270
+ }
4271
+ const artifact = await safely(ctx, () => writeSessionTrace(ctx));
4272
+ return artifact ? [artifact] : [];
4273
+ }
4274
+ async function maybeWriteManifest(ctx, fanOutArtifacts) {
4275
+ if (!ctx.emitManifest) {
4276
+ return;
4277
+ }
4278
+ const artifacts = ctx.collectedArtifacts ?? fanOutArtifacts;
2638
4279
  try {
2639
- const manifestPath = await writeFramesAndManifest(frames, tmpDir, ext);
2640
- await runFfmpeg(ffmpeg, manifestPath, outputPath);
2641
- } finally {
2642
- await fs2.rm(tmpDir, { recursive: true, force: true }).catch(() => {
2643
- });
4280
+ const path14 = await writeArtifactsManifest(
4281
+ ctx.resolveOutputDir(),
4282
+ buildArtifactsManifest({
4283
+ sessionId: ctx.sessionId,
4284
+ format: ctx.format ?? "zip",
4285
+ artifacts,
4286
+ testMetadata: ctx.testMetadata
4287
+ })
4288
+ );
4289
+ ctx.log?.("info", `Artifacts manifest written to ${path14}`);
4290
+ } catch (err) {
4291
+ ctx.log?.(
4292
+ "warn",
4293
+ `Failed to write artifacts manifest: ${errorMessage(err)}`
4294
+ );
2644
4295
  }
2645
4296
  }
2646
4297
 
4298
+ // ../core/src/terminal-throttle.ts
4299
+ var TERMINAL_REPEAT_WINDOW_MS = 1e3;
4300
+ var LEADING_ISO_TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d\d\d)?Z {1,4}/;
4301
+ function terminalRepeatKey(line) {
4302
+ return line.replace(LEADING_ISO_TIMESTAMP_RE, "");
4303
+ }
4304
+ var TerminalLineThrottle = class {
4305
+ #lastEmitted = /* @__PURE__ */ new Map();
4306
+ #windowMs;
4307
+ constructor(windowMs = TERMINAL_REPEAT_WINDOW_MS) {
4308
+ this.#windowMs = windowMs;
4309
+ }
4310
+ /**
4311
+ * True if the line should be emitted, false if it's a within-window repeat of
4312
+ * a line already emitted. The window is anchored to the last *emit*, not the
4313
+ * last occurrence, so a sustained stream of one line still emits once per
4314
+ * window instead of going silent after the first. Expired keys are pruned on
4315
+ * each emit, so state stays bounded by the number of distinct lines seen
4316
+ * within the window.
4317
+ */
4318
+ shouldEmit(line, now = Date.now()) {
4319
+ const key = terminalRepeatKey(line);
4320
+ const last = this.#lastEmitted.get(key);
4321
+ if (last !== void 0 && now - last < this.#windowMs) {
4322
+ return false;
4323
+ }
4324
+ this.#lastEmitted.set(key, now);
4325
+ this.#prune(now);
4326
+ return true;
4327
+ }
4328
+ #prune(now) {
4329
+ for (const [key, ts] of this.#lastEmitted) {
4330
+ if (now - ts >= this.#windowMs) {
4331
+ this.#lastEmitted.delete(key);
4332
+ }
4333
+ }
4334
+ }
4335
+ };
4336
+
2647
4337
  // ../core/src/finalize-screencast.ts
4338
+ import fs7 from "fs";
4339
+ import os2 from "os";
4340
+ import path7 from "path";
2648
4341
  async function finalizeScreencast({
2649
4342
  recorder,
2650
4343
  sessionId,
@@ -2655,11 +4348,11 @@ async function finalizeScreencast({
2655
4348
  sendUpstream,
2656
4349
  onLog
2657
4350
  }) {
2658
- const log12 = (level, message) => onLog?.(level, message);
4351
+ const log14 = (level, message) => onLog?.(level, message);
2659
4352
  try {
2660
4353
  await recorder.stop();
2661
4354
  } catch (err) {
2662
- log12("warn", `Screencast stop failed: ${errorMessage(err)}`);
4355
+ log14("warn", `Screencast stop failed: ${errorMessage(err)}`);
2663
4356
  return;
2664
4357
  }
2665
4358
  const frames = recorder.frames;
@@ -2668,15 +4361,16 @@ async function finalizeScreencast({
2668
4361
  }
2669
4362
  const fileName = `${filenamePrefix}-${sessionId}.webm`;
2670
4363
  const candidate = outputDir || process.cwd();
2671
- let videoPath = path3.join(candidate, fileName);
4364
+ let videoPath = path7.join(candidate, fileName);
2672
4365
  try {
2673
- fs3.accessSync(candidate, fs3.constants.W_OK);
4366
+ fs7.mkdirSync(candidate, { recursive: true });
4367
+ fs7.accessSync(candidate, fs7.constants.W_OK);
2674
4368
  } catch {
2675
- videoPath = path3.join(os2.tmpdir(), fileName);
4369
+ videoPath = path7.join(os2.tmpdir(), fileName);
2676
4370
  }
2677
4371
  try {
2678
4372
  await encodeToVideo(frames, videoPath, { captureFormat });
2679
- log12("info", `\u{1F4F9} Screencast video: ${videoPath}`);
4373
+ log14("info", `\u{1F4F9} Screencast video: ${videoPath}`);
2680
4374
  sendUpstream("screencast", {
2681
4375
  sessionId,
2682
4376
  videoPath,
@@ -2686,33 +4380,37 @@ async function finalizeScreencast({
2686
4380
  startTime: frames[0]?.timestamp
2687
4381
  });
2688
4382
  } catch (err) {
2689
- log12("warn", `Screencast encode failed: ${errorMessage(err)}`);
4383
+ log14("warn", `Screencast encode failed: ${errorMessage(err)}`);
2690
4384
  }
2691
4385
  }
2692
4386
 
2693
4387
  // ../core/src/output-dir.ts
2694
- import fs4 from "fs";
2695
- import path4 from "path";
2696
- var NODE_MODULES_SEGMENT = `${path4.sep}node_modules${path4.sep}`;
4388
+ import fs8 from "fs";
4389
+ import path8 from "path";
4390
+ var NODE_MODULES_SEGMENT = `${path8.sep}node_modules${path8.sep}`;
4391
+ var OUTPUT_SUBDIR = "test-results";
2697
4392
  function isWritable(dir) {
2698
4393
  try {
2699
- fs4.accessSync(dir, fs4.constants.W_OK);
4394
+ fs8.accessSync(dir, fs8.constants.W_OK);
2700
4395
  return true;
2701
4396
  } catch {
2702
4397
  return false;
2703
4398
  }
2704
4399
  }
2705
4400
  function resolveAdapterOutputDir(input = {}) {
4401
+ return path8.join(resolveBaseDir(input), OUTPUT_SUBDIR);
4402
+ }
4403
+ function resolveBaseDir(input) {
2706
4404
  const fallback = input.fallbackDir ?? process.cwd();
2707
4405
  if (input.userConfiguredDir) {
2708
4406
  return input.userConfiguredDir;
2709
4407
  }
2710
4408
  const candidates = [];
2711
4409
  if (input.testFilePath) {
2712
- candidates.push(path4.dirname(input.testFilePath));
4410
+ candidates.push(path8.dirname(input.testFilePath));
2713
4411
  }
2714
4412
  if (input.configPath) {
2715
- candidates.push(path4.dirname(input.configPath));
4413
+ candidates.push(path8.dirname(input.configPath));
2716
4414
  }
2717
4415
  candidates.push(fallback);
2718
4416
  for (const dir of candidates) {
@@ -2932,7 +4630,38 @@ var ScreencastRecorderBase = class {
2932
4630
  */
2933
4631
  pushCdpFrame(data, timestampSeconds) {
2934
4632
  const timestamp = typeof timestampSeconds === "number" ? Math.round(timestampSeconds * 1e3) : Date.now();
2935
- this.buffer.push({ data, timestamp });
4633
+ this.#appendFrame({ data, timestamp });
4634
+ }
4635
+ /**
4636
+ * Append a frame, decimating the buffer in place once it exceeds
4637
+ * `maxBufferFrames` so a long session can't grow it without bound.
4638
+ */
4639
+ #appendFrame(frame) {
4640
+ this.buffer.push(frame);
4641
+ if (this.buffer.length > Math.max(2, this.options.maxBufferFrames)) {
4642
+ this.#decimateBuffer();
4643
+ }
4644
+ }
4645
+ /**
4646
+ * Halve the buffer by keeping every other frame plus the first and last, so
4647
+ * memory is bounded while the temporal spread survives (never tail-truncated).
4648
+ * `#startIndex` is remapped to the count of surviving pre-marker frames, which
4649
+ * keeps `frames`/`duration` excluding pre-marker frames across decimation.
4650
+ */
4651
+ #decimateBuffer() {
4652
+ const lastIndex = this.buffer.length - 1;
4653
+ const kept = [];
4654
+ let preMarkerKept = 0;
4655
+ for (let i = 0; i < this.buffer.length; i++) {
4656
+ if (i % 2 === 0 || i === lastIndex) {
4657
+ if (i < this.#startIndex) {
4658
+ preMarkerKept++;
4659
+ }
4660
+ kept.push(this.buffer[i]);
4661
+ }
4662
+ }
4663
+ this.buffer = kept;
4664
+ this.#startIndex = preMarkerKept;
2936
4665
  }
2937
4666
  /** Whether `setStartMarker` (or `markStartAtLatest`) has fired yet. */
2938
4667
  get hasStartMarker() {
@@ -2971,13 +4700,13 @@ var ScreencastRecorderBase = class {
2971
4700
  this.onUnavailable(new Error("first screenshot returned null"));
2972
4701
  return;
2973
4702
  }
2974
- this.buffer.push({ data: first, timestamp: Date.now() });
4703
+ this.#appendFrame({ data: first, timestamp: Date.now() });
2975
4704
  const intervalMs = this.options.pollIntervalMs;
2976
4705
  this.#pollTimer = setInterval(async () => {
2977
4706
  try {
2978
4707
  const data = await this.takeScreenshot();
2979
4708
  if (data !== null) {
2980
- this.buffer.push({ data, timestamp: Date.now() });
4709
+ this.#appendFrame({ data, timestamp: Date.now() });
2981
4710
  }
2982
4711
  } catch {
2983
4712
  this.#stopPolling();
@@ -2999,15 +4728,15 @@ var ScreencastRecorderBase = class {
2999
4728
  };
3000
4729
 
3001
4730
  // ../core/src/script-loader.ts
3002
- import fs5 from "fs/promises";
3003
- import path5 from "path";
3004
- import { createRequire as createRequire2 } from "module";
3005
- var require3 = createRequire2(import.meta.url);
4731
+ import fs9 from "fs/promises";
4732
+ import path9 from "path";
4733
+ import { createRequire as createRequire3 } from "module";
4734
+ var require4 = createRequire3(import.meta.url);
3006
4735
  async function loadInjectableScript() {
3007
- const scriptPath = require3.resolve("@wdio/devtools-script");
3008
- const scriptDir = path5.dirname(scriptPath);
3009
- const preloadScriptPath = path5.join(scriptDir, "script.js");
3010
- const scriptContent = await fs5.readFile(preloadScriptPath, "utf-8");
4736
+ const scriptPath = require4.resolve("@wdio/devtools-script");
4737
+ const scriptDir = path9.dirname(scriptPath);
4738
+ const preloadScriptPath = path9.join(scriptDir, "script.js");
4739
+ const scriptContent = await fs9.readFile(preloadScriptPath, "utf-8");
3011
4740
  return `(async function() { ${scriptContent} })()`;
3012
4741
  }
3013
4742
  async function pollUntilReady(check, opts = {}) {
@@ -3023,7 +4752,7 @@ async function pollUntilReady(check, opts = {}) {
3023
4752
  }
3024
4753
 
3025
4754
  // ../core/src/session-capturer.ts
3026
- import fs6 from "fs/promises";
4755
+ import fs10 from "fs/promises";
3027
4756
  import { WebSocket } from "ws";
3028
4757
  var SessionCapturerBase = class {
3029
4758
  // ── State (mostly private; subclasses access shared ws via `this.ws`) ────
@@ -3041,6 +4770,11 @@ var SessionCapturerBase = class {
3041
4770
  // stdout, OR when stream forwarding wants to log via console.
3042
4771
  #isCapturingConsole = false;
3043
4772
  #isCapturingStream = false;
4773
+ // Collapses high-frequency identical terminal lines (e.g. WDIO's per-command
4774
+ // COMMAND/RESULT logger frames reprinted every ~100ms during an expect poll)
4775
+ // so they don't flood the console lane. Terminal-source only — user
4776
+ // console.* is source='test' and never throttled.
4777
+ #terminalThrottle = new TerminalLineThrottle();
3044
4778
  // Command bookkeeping — used by adapters that emit commands themselves
3045
4779
  // (nightwatch, selenium). The WDIO service adapter doesn't call sendCommand
3046
4780
  // (WDIO owns the command lifecycle), so this state is harmless overhead.
@@ -3165,6 +4899,32 @@ var SessionCapturerBase = class {
3165
4899
  command: toSend
3166
4900
  });
3167
4901
  }
4902
+ /**
4903
+ * Mark the most recent user action of a test as failed — for framework
4904
+ * failures that aren't captured as their own command. Broadcasts the swap so
4905
+ * live mode highlights it too. Returns false when no eligible action is found,
4906
+ * OR when that most-recent action already carries an error: the failure is
4907
+ * then already represented (e.g. an expect matcher captured as its own row via
4908
+ * afterAssertion), so it must not bleed onto an earlier *passing* action.
4909
+ */
4910
+ failLastAction(testUid, error) {
4911
+ for (let i = this.commandsLog.length - 1; i >= 0; i--) {
4912
+ const command = this.commandsLog[i];
4913
+ if (!mapCommandToAction(command.command)) {
4914
+ continue;
4915
+ }
4916
+ if (testUid && command.testUid !== testUid) {
4917
+ continue;
4918
+ }
4919
+ if (command.error) {
4920
+ return false;
4921
+ }
4922
+ command.error = error;
4923
+ this.sendReplaceCommand(command.timestamp, command);
4924
+ return true;
4925
+ }
4926
+ return false;
4927
+ }
3168
4928
  /**
3169
4929
  * Read a file from disk, store in `sources`, and broadcast to the UI via
3170
4930
  * `sendUpstream('sources', { [path]: text })`. Idempotent — a cached path is
@@ -3176,7 +4936,7 @@ var SessionCapturerBase = class {
3176
4936
  return;
3177
4937
  }
3178
4938
  try {
3179
- const source = (await fs6.readFile(filePath, "utf-8")).toString();
4939
+ const source = (await fs10.readFile(filePath, "utf-8")).toString();
3180
4940
  this.sources.set(filePath, source);
3181
4941
  this.sendUpstream("sources", { [filePath]: source });
3182
4942
  } catch (err) {
@@ -3324,7 +5084,7 @@ var SessionCapturerBase = class {
3324
5084
  const segments = rawLine.split("\r").filter((s) => s.trim());
3325
5085
  const lastSegment = segments[segments.length - 1] ?? rawLine;
3326
5086
  const clean = stripAnsi(lastSegment).trim();
3327
- if (!clean || this.isInternalStreamLine(clean) || SPINNER_RE.test(clean)) {
5087
+ if (!clean || this.isInternalStreamLine(clean) || SPINNER_RE.test(clean) || !this.#terminalThrottle.shouldEmit(clean)) {
3328
5088
  continue;
3329
5089
  }
3330
5090
  this.onLine(detectLogLevel(clean), [clean], LOG_SOURCES.TERMINAL);
@@ -3402,100 +5162,6 @@ function safeStringify(value) {
3402
5162
  }
3403
5163
  }
3404
5164
 
3405
- // ../core/src/spec-trace-helpers.ts
3406
- function sanitizeSpecName(specFile) {
3407
- return specFile.replace(/^.*[/\\]/, "").replace(/\.[^.]+$/, "").replace(/[^a-zA-Z0-9_-]/g, "_").replace(/^_+|_+$/g, "") || "unknown-spec";
3408
- }
3409
- function buildSpecSessionId(specFile, sessionId) {
3410
- const base = sanitizeSpecName(specFile);
3411
- const hash = deterministicUid(specFile).split("-").pop().slice(0, 8);
3412
- return `${base}-${hash}-${sessionId.slice(0, 8)}`;
3413
- }
3414
- function buildSpecCapturer(capturer, range, nextRange) {
3415
- const end = nextRange ? {
3416
- commands: nextRange.commandStartIdx,
3417
- console: nextRange.consoleStartIdx,
3418
- network: nextRange.networkStartIdx,
3419
- mutations: nextRange.mutationStartIdx,
3420
- traceLogs: nextRange.traceLogStartIdx
3421
- } : void 0;
3422
- return {
3423
- mutations: capturer.mutations.slice(range.mutationStartIdx, end?.mutations),
3424
- traceLogs: capturer.traceLogs.slice(range.traceLogStartIdx, end?.traceLogs),
3425
- consoleLogs: capturer.consoleLogs.slice(
3426
- range.consoleStartIdx,
3427
- end?.console
3428
- ),
3429
- networkRequests: capturer.networkRequests.slice(
3430
- range.networkStartIdx,
3431
- end?.network
3432
- ),
3433
- commandsLog: capturer.commandsLog.slice(
3434
- range.commandStartIdx,
3435
- end?.commands
3436
- ),
3437
- sources: new Map(capturer.sources),
3438
- metadata: capturer.metadata,
3439
- startWallTime: capturer.startWallTime
3440
- };
3441
- }
3442
- function filterTestMetadataBySpec(allMetadata, specFile) {
3443
- const filtered = /* @__PURE__ */ new Map();
3444
- for (const [uid, meta] of allMetadata) {
3445
- if (meta.specFile === specFile) {
3446
- filtered.set(uid, meta);
3447
- }
3448
- }
3449
- return filtered;
3450
- }
3451
- function recordSpecBoundary(ctx, specFile, traceGranularity) {
3452
- if (traceGranularity !== "spec") {
3453
- return null;
3454
- }
3455
- const lastRange = ctx.specRanges[ctx.specRanges.length - 1];
3456
- if (lastRange && lastRange.specFile === specFile) {
3457
- return null;
3458
- }
3459
- const prevRange = lastRange && !ctx.flushedSpecs.has(lastRange.specFile) ? lastRange : null;
3460
- ctx.specRanges.push({
3461
- specFile,
3462
- commandStartIdx: ctx.capturer.commandsLog.length,
3463
- consoleStartIdx: ctx.capturer.consoleLogs.length,
3464
- networkStartIdx: ctx.capturer.networkRequests.length,
3465
- mutationStartIdx: ctx.capturer.mutations.length,
3466
- traceLogStartIdx: ctx.capturer.traceLogs.length,
3467
- snapshotCount: ctx.actionSnapshots.length
3468
- });
3469
- return prevRange;
3470
- }
3471
- async function writeSpecTrace(input) {
3472
- const specCapturer = buildSpecCapturer(
3473
- input.capturer,
3474
- input.range,
3475
- input.nextRange
3476
- );
3477
- const specSnapshots = input.actionSnapshots.slice(
3478
- input.range.snapshotCount,
3479
- input.nextRange?.snapshotCount ?? input.actionSnapshots.length
3480
- );
3481
- const specSessionId = buildSpecSessionId(
3482
- input.range.specFile,
3483
- input.sessionId
3484
- );
3485
- const testMetadata = filterTestMetadataBySpec(
3486
- input.testMetadata,
3487
- input.range.specFile
3488
- );
3489
- return writeTraceZip(specCapturer, {
3490
- outputDir: input.outputDir,
3491
- sessionId: specSessionId,
3492
- capabilities: input.capabilities,
3493
- actionSnapshots: specSnapshots.length > 0 ? specSnapshots : void 0,
3494
- format: input.format,
3495
- testMetadata
3496
- });
3497
- }
3498
-
3499
5165
  // ../core/src/suite-helpers.ts
3500
5166
  function createSuiteStats(init) {
3501
5167
  return {
@@ -3575,16 +5241,61 @@ function stampTestEnd(test, end = /* @__PURE__ */ new Date()) {
3575
5241
  test.end = end;
3576
5242
  test._duration = end.getTime() - (test.start?.getTime() ?? end.getTime());
3577
5243
  }
5244
+ function deriveAncestorKind(suite, parentKind) {
5245
+ if (parentKind === "feature") {
5246
+ return "scenario";
5247
+ }
5248
+ if (suite.file?.endsWith(".feature") && parentKind !== "scenario") {
5249
+ return "feature";
5250
+ }
5251
+ return "suite";
5252
+ }
5253
+ function collectSuiteTestMetadata(suites) {
5254
+ const metadata = /* @__PURE__ */ new Map();
5255
+ const walk = (suite, ancestors) => {
5256
+ const parentKind = ancestors[ancestors.length - 1]?.kind;
5257
+ const chain = [
5258
+ ...ancestors,
5259
+ {
5260
+ uid: suite.uid,
5261
+ title: suite.title,
5262
+ kind: deriveAncestorKind(suite, parentKind)
5263
+ }
5264
+ ];
5265
+ for (const entry of suite.tests ?? []) {
5266
+ if (typeof entry !== "object" || entry === null) {
5267
+ continue;
5268
+ }
5269
+ metadata.set(entry.uid, {
5270
+ title: entry.fullTitle || entry.title,
5271
+ specFile: entry.file ?? suite.file,
5272
+ state: entry.state,
5273
+ attempt: entry.retries ?? 0,
5274
+ ancestry: chain
5275
+ });
5276
+ }
5277
+ for (const child of suite.suites ?? []) {
5278
+ walk(child, chain);
5279
+ }
5280
+ };
5281
+ for (const suite of suites) {
5282
+ if (typeof suite !== "object" || suite === null) {
5283
+ continue;
5284
+ }
5285
+ walk(suite, []);
5286
+ }
5287
+ return metadata;
5288
+ }
3578
5289
 
3579
5290
  // ../core/src/test-discovery.ts
3580
- import fs7 from "fs";
5291
+ import fs11 from "fs";
3581
5292
  function findTestDefinitions(filePath, opts = {}) {
3582
- if (!fs7.existsSync(filePath)) {
5293
+ if (!fs11.existsSync(filePath)) {
3583
5294
  return [];
3584
5295
  }
3585
5296
  let source;
3586
5297
  try {
3587
- source = fs7.readFileSync(filePath, "utf-8");
5298
+ source = fs11.readFileSync(filePath, "utf-8");
3588
5299
  } catch {
3589
5300
  return [];
3590
5301
  }
@@ -3678,15 +5389,113 @@ var TestReporterBase = class {
3678
5389
  }
3679
5390
  };
3680
5391
 
5392
+ // src/trace-context.ts
5393
+ function buildTraceContext(input, sessionId) {
5394
+ return {
5395
+ mode: input.mode,
5396
+ policy: input.policy,
5397
+ granularity: input.granularity,
5398
+ format: input.format,
5399
+ capturer: input.capturer,
5400
+ actionSnapshots: input.capturer.actionSnapshots,
5401
+ sessionId,
5402
+ testMetadata: collectSuiteTestMetadata(input.suites),
5403
+ outcomes: input.outcomes,
5404
+ ranges: input.ranges,
5405
+ flushed: input.flushed,
5406
+ screencastFrames: input.screencastFrames,
5407
+ resolveOutputDir: () => resolveAdapterOutputDir({
5408
+ testFilePath: input.testFilePath,
5409
+ configPath: input.configPath
5410
+ }),
5411
+ awaitPending: [...input.capturer.snapshotCaptures, ...input.traceFlushes],
5412
+ // Nightwatch feeds real per-test attempt numbers via TestAttemptTracker
5413
+ // (B4), so retry-aware policies use per-test attempts, not the fallback.
5414
+ attemptInfoAvailable: true,
5415
+ // Off by default; opt-in only — Nightwatch has no live Allure signal to
5416
+ // auto-detect against (produce-only Allure), unlike WDIO/Selenium.
5417
+ emitManifest: input.emitArtifactsManifest ?? false,
5418
+ collectedArtifacts: input.artifacts,
5419
+ onArtifact: (a) => input.artifacts.push(a),
5420
+ log: input.log
5421
+ };
5422
+ }
5423
+
5424
+ // src/test-artifacts.ts
5425
+ async function emitTestArtifacts(input) {
5426
+ await captureAndAttachScreenshot({
5427
+ mode: input.mode,
5428
+ granularity: input.granularity,
5429
+ policy: input.screenshotPolicy,
5430
+ failed: input.failed,
5431
+ screenshotBase64: lastRenderedScreenshot(
5432
+ input.actionSnapshots,
5433
+ input.startWallTime
5434
+ ),
5435
+ sessionId: input.sessionId,
5436
+ outputDir: input.outputDir,
5437
+ testUid: input.uid,
5438
+ attach: void 0,
5439
+ onArtifact: input.onArtifact,
5440
+ onLog: input.onLog
5441
+ });
5442
+ await captureAndAttachVideo({
5443
+ mode: input.mode,
5444
+ granularity: input.granularity,
5445
+ policy: input.videoPolicy,
5446
+ frames: input.frames,
5447
+ startWallTime: input.startWallTime,
5448
+ outcomes: input.outcomes,
5449
+ attempt: input.attempt,
5450
+ outputDir: input.outputDir,
5451
+ testUid: input.uid,
5452
+ sessionId: input.sessionId,
5453
+ captureFormat: input.captureFormat,
5454
+ attach: void 0,
5455
+ onArtifact: input.onArtifact,
5456
+ onLog: input.onLog
5457
+ });
5458
+ }
5459
+
5460
+ // src/helpers/assertCapture.ts
5461
+ import logger from "@wdio/logger";
5462
+ var log = logger("@wdio/nightwatch-devtools:assertCapture");
5463
+ function wireAssertCapture(getCapturer, getTestUid) {
5464
+ patchNodeAssert(
5465
+ (cmd) => captureAssert(getCapturer(), getTestUid(), cmd),
5466
+ (level, message) => log[level](message)
5467
+ );
5468
+ }
5469
+ function captureAssert(capturer, testUid, cmd) {
5470
+ if (!capturer) {
5471
+ return;
5472
+ }
5473
+ capturer.captureCommand(
5474
+ cmd.command,
5475
+ cmd.args,
5476
+ cmd.result,
5477
+ cmd.error,
5478
+ testUid,
5479
+ cmd.callSource,
5480
+ cmd.timestamp
5481
+ ).catch(
5482
+ (err) => log.warn(`Failed to capture ${cmd.command}: ${errorMessage(err)}`)
5483
+ );
5484
+ const last = capturer.commandsLog[capturer.commandsLog.length - 1];
5485
+ if (last) {
5486
+ capturer.sendCommand(last);
5487
+ }
5488
+ }
5489
+
3681
5490
  // src/index.ts
3682
5491
  import { stop as stopBackend } from "@wdio/devtools-backend";
3683
- import logger11 from "@wdio/logger";
5492
+ import logger13 from "@wdio/logger";
3684
5493
 
3685
5494
  // src/run-lifecycle.ts
3686
- import * as fs9 from "fs";
3687
- import * as path7 from "path";
5495
+ import * as fs13 from "fs";
5496
+ import * as path11 from "path";
3688
5497
  import * as os3 from "os";
3689
- import logger from "@wdio/logger";
5498
+ import logger2 from "@wdio/logger";
3690
5499
  import { remote } from "webdriverio";
3691
5500
  import { start, stop } from "@wdio/devtools-backend";
3692
5501
 
@@ -3744,8 +5553,8 @@ var CONFIG_FILENAMES = [
3744
5553
  var PLUGIN_GLOBAL_KEY = "__nightwatchDevtoolsPlugin";
3745
5554
 
3746
5555
  // src/helpers/utils.ts
3747
- import * as fs8 from "fs";
3748
- import * as path6 from "path";
5556
+ import * as fs12 from "fs";
5557
+ import * as path10 from "path";
3749
5558
  import { parse as parseStackTrace2 } from "stacktrace-parser";
3750
5559
  function determineTestState(testcase) {
3751
5560
  if (testcase.passed === 0 && testcase.failed === 0) {
@@ -3802,7 +5611,7 @@ function findTestFileFromStack() {
3802
5611
  return void 0;
3803
5612
  }
3804
5613
  const filePath = normalizeFilePath(frame.file);
3805
- return fs8.existsSync(filePath) ? filePath : void 0;
5614
+ return fs12.existsSync(filePath) ? filePath : void 0;
3806
5615
  }
3807
5616
  function extractTestMetadata(filePath) {
3808
5617
  const defs = findTestDefinitions(filePath, {
@@ -3930,21 +5739,21 @@ function resolveNightwatchConfig() {
3930
5739
  );
3931
5740
  if (configFlagIdx !== -1 && process.argv[configFlagIdx + 1]) {
3932
5741
  const argvConfig = process.argv[configFlagIdx + 1];
3933
- const resolved = path6.isAbsolute(argvConfig) ? argvConfig : path6.resolve(process.cwd(), argvConfig);
3934
- if (fs8.existsSync(resolved)) {
5742
+ const resolved = path10.isAbsolute(argvConfig) ? argvConfig : path10.resolve(process.cwd(), argvConfig);
5743
+ if (fs12.existsSync(resolved)) {
3935
5744
  return resolved;
3936
5745
  }
3937
5746
  }
3938
5747
  let dir = process.cwd();
3939
- const root = path6.parse(dir).root;
5748
+ const root = path10.parse(dir).root;
3940
5749
  while (dir && dir !== root) {
3941
5750
  for (const file of CONFIG_FILENAMES) {
3942
- const candidate = path6.join(dir, file);
3943
- if (fs8.existsSync(candidate)) {
5751
+ const candidate = path10.join(dir, file);
5752
+ if (fs12.existsSync(candidate)) {
3944
5753
  return candidate;
3945
5754
  }
3946
5755
  }
3947
- const parent = path6.dirname(dir);
5756
+ const parent = path10.dirname(dir);
3948
5757
  if (parent === dir) {
3949
5758
  break;
3950
5759
  }
@@ -3954,11 +5763,11 @@ function resolveNightwatchConfig() {
3954
5763
  }
3955
5764
 
3956
5765
  // src/run-lifecycle.ts
3957
- var log = logger("@wdio/nightwatch-devtools:run-lifecycle");
5766
+ var log2 = logger2("@wdio/nightwatch-devtools:run-lifecycle");
3958
5767
  function handleReuseMode(ctx) {
3959
5768
  ctx.options.hostname = process.env[REUSE_ENV.HOST];
3960
5769
  ctx.options.port = Number(process.env[REUSE_ENV.PORT]);
3961
- log.info(
5770
+ log2.info(
3962
5771
  `\u267B Reusing DevTools backend at ${ctx.options.hostname}:${ctx.options.port}`
3963
5772
  );
3964
5773
  if (ctx.testReporter) {
@@ -3966,7 +5775,7 @@ function handleReuseMode(ctx) {
3966
5775
  ctx.passCount = 0;
3967
5776
  ctx.failCount = 0;
3968
5777
  ctx.skipCount = 0;
3969
- log.info("Cleared execution data for rerun");
5778
+ log2.info("Cleared execution data for rerun");
3970
5779
  }
3971
5780
  }
3972
5781
  async function runPluginBefore(ctx) {
@@ -3977,9 +5786,9 @@ async function runPluginBefore(ctx) {
3977
5786
  const configPath = resolveNightwatchConfig();
3978
5787
  ctx.setConfigPath(configPath);
3979
5788
  if (configPath) {
3980
- log.info(`\u2713 Config: ${configPath}`);
5789
+ log2.info(`\u2713 Config: ${configPath}`);
3981
5790
  } else {
3982
- log.warn(
5791
+ log2.warn(
3983
5792
  "Could not find nightwatch config \u2014 test rerun will be unavailable"
3984
5793
  );
3985
5794
  }
@@ -3990,18 +5799,18 @@ async function runPluginBefore(ctx) {
3990
5799
  }
3991
5800
  try {
3992
5801
  if (ctx.options.mode === "trace") {
3993
- log.info("Trace mode \u2014 skipping backend port-bind and UI window");
5802
+ log2.info("Trace mode \u2014 skipping backend port-bind and UI window");
3994
5803
  } else {
3995
5804
  ctx.options.port = await findFreePort(
3996
5805
  ctx.options.port,
3997
5806
  ctx.options.hostname
3998
5807
  );
3999
- log.info("\u{1F680} Starting DevTools backend...");
5808
+ log2.info("\u{1F680} Starting DevTools backend...");
4000
5809
  const { port } = await start(ctx.options);
4001
5810
  ctx.options.port = port;
4002
5811
  const url = `http://${ctx.options.hostname}:${ctx.options.port}`;
4003
- log.info(`\u2713 Backend started on port ${ctx.options.port}`);
4004
- log.info(` DevTools UI: ${url}`);
5812
+ log2.info(`\u2713 Backend started on port ${ctx.options.port}`);
5813
+ log2.info(` DevTools UI: ${url}`);
4005
5814
  await ctx.openDevtoolsBrowserAt(url);
4006
5815
  await new Promise(
4007
5816
  (resolve2) => setTimeout(resolve2, TIMING.UI_CONNECTION_WAIT)
@@ -4010,18 +5819,18 @@ async function runPluginBefore(ctx) {
4010
5819
  ;
4011
5820
  globalThis[PLUGIN_GLOBAL_KEY] = ctx.plugin;
4012
5821
  } catch (err) {
4013
- log.error(`Failed to start backend: ${errorMessage(err)}`);
5822
+ log2.error(`Failed to start backend: ${errorMessage(err)}`);
4014
5823
  throw err;
4015
5824
  }
4016
5825
  }
4017
5826
  async function openDevtoolsBrowser(ctx, url) {
4018
5827
  try {
4019
- ctx.userDataDir = path7.join(
5828
+ ctx.userDataDir = path11.join(
4020
5829
  os3.tmpdir(),
4021
5830
  `nightwatch-devtools-${ctx.options.port}-${Date.now()}`
4022
5831
  );
4023
- if (!fs9.existsSync(ctx.userDataDir)) {
4024
- fs9.mkdirSync(ctx.userDataDir, { recursive: true });
5832
+ if (!fs13.existsSync(ctx.userDataDir)) {
5833
+ fs13.mkdirSync(ctx.userDataDir, { recursive: true });
4025
5834
  }
4026
5835
  ctx.devtoolsBrowser = await remote({
4027
5836
  logLevel: "info",
@@ -4047,8 +5856,8 @@ async function openDevtoolsBrowser(ctx, url) {
4047
5856
  });
4048
5857
  await ctx.devtoolsBrowser.url(url);
4049
5858
  } catch (err) {
4050
- log.error(`Failed to open DevTools UI: ${errorMessage(err)}`);
4051
- log.info(`Please manually open: ${url}`);
5859
+ log2.error(`Failed to open DevTools UI: ${errorMessage(err)}`);
5860
+ log2.info(`Please manually open: ${url}`);
4052
5861
  }
4053
5862
  }
4054
5863
  async function finalizeAllSuites(ctx, browser) {
@@ -4071,22 +5880,22 @@ function logRunSummary(ctx) {
4071
5880
  ctx.failCount > 0 ? `${ctx.failCount} failed` : null,
4072
5881
  ctx.skipCount > 0 ? `${ctx.skipCount} skipped` : null
4073
5882
  ].filter(Boolean).join(" ");
4074
- log.info(`${ctx.failCount > 0 ? "\u274C" : "\u2705"} Tests complete! ${summary}`);
4075
- log.info(` DevTools UI: http://${ctx.options.hostname}:${ctx.options.port}`);
5883
+ log2.info(`${ctx.failCount > 0 ? "\u274C" : "\u2705"} Tests complete! ${summary}`);
5884
+ log2.info(` DevTools UI: http://${ctx.options.hostname}:${ctx.options.port}`);
4076
5885
  }
4077
5886
  async function waitForDevtoolsBrowserClose(ctx) {
4078
5887
  if (!ctx.devtoolsBrowser) {
4079
5888
  return;
4080
5889
  }
4081
5890
  ;
4082
- logger.setLevel(
5891
+ logger2.setLevel(
4083
5892
  "devtools",
4084
5893
  "warn"
4085
5894
  );
4086
5895
  let exitBySignal = false;
4087
5896
  const signalHandler = () => {
4088
5897
  exitBySignal = true;
4089
- log.info("\n\u2713 Exiting... Browser window will remain open");
5898
+ log2.info("\n\u2713 Exiting... Browser window will remain open");
4090
5899
  process.exit(0);
4091
5900
  };
4092
5901
  process.once("SIGINT", signalHandler);
@@ -4097,7 +5906,7 @@ async function waitForDevtoolsBrowserClose(ctx) {
4097
5906
  await new Promise((res) => setTimeout(res, TIMING.BROWSER_POLL_INTERVAL));
4098
5907
  } catch {
4099
5908
  if (!exitBySignal) {
4100
- log.info("Browser window closed, stopping DevTools app");
5909
+ log2.info("Browser window closed, stopping DevTools app");
4101
5910
  break;
4102
5911
  }
4103
5912
  }
@@ -4107,7 +5916,7 @@ async function waitForDevtoolsBrowserClose(ctx) {
4107
5916
  }
4108
5917
  process.removeListener("SIGINT", signalHandler);
4109
5918
  process.removeListener("SIGTERM", signalHandler);
4110
- logger.setLevel(
5919
+ logger2.setLevel(
4111
5920
  "devtools",
4112
5921
  "info"
4113
5922
  );
@@ -4120,8 +5929,8 @@ async function waitForDevtoolsBrowserClose(ctx) {
4120
5929
  }
4121
5930
 
4122
5931
  // src/event-hub.ts
4123
- import logger2 from "@wdio/logger";
4124
- var log2 = logger2("@wdio/nightwatch-devtools:event-hub");
5932
+ import logger3 from "@wdio/logger";
5933
+ var log3 = logger3("@wdio/nightwatch-devtools:event-hub");
4125
5934
  function makeSessionMetadataHandler(bindings) {
4126
5935
  return (data) => {
4127
5936
  try {
@@ -4142,23 +5951,23 @@ function makeSessionMetadataHandler(bindings) {
4142
5951
  options: bindings.buildMetadataOptions()
4143
5952
  });
4144
5953
  } catch (err) {
4145
- log2.error(`Error in event handler: ${errorMessage(err)}`);
5954
+ log3.error(`Error in event handler: ${errorMessage(err)}`);
4146
5955
  }
4147
5956
  };
4148
5957
  }
4149
5958
  function registerEventHandlers(eventHub, bindings) {
4150
5959
  bindings.setCucumberRunner(eventHub.runner === "cucumber");
4151
5960
  if (eventHub.runner === "cucumber") {
4152
- log2.info("\u2713 Cucumber runner detected via NightwatchEventHub");
5961
+ log3.info("\u2713 Cucumber runner detected via NightwatchEventHub");
4153
5962
  }
4154
- log2.info("\u2713 NightwatchEventHub registered \u2014 enriched metadata enabled");
5963
+ log3.info("\u2713 NightwatchEventHub registered \u2014 enriched metadata enabled");
4155
5964
  const handler = makeSessionMetadataHandler(bindings);
4156
5965
  eventHub.on("TestSuiteStarted", handler);
4157
5966
  eventHub.on("TestRunStarted", handler);
4158
5967
  }
4159
5968
 
4160
5969
  // src/cucumber-lifecycle.ts
4161
- import logger3 from "@wdio/logger";
5970
+ import logger4 from "@wdio/logger";
4162
5971
 
4163
5972
  // src/helpers/cucumberResult.ts
4164
5973
  function cucumberResultToTestState(result) {
@@ -4186,7 +5995,7 @@ function closeOpenSteps(suite, scenarioState, now = /* @__PURE__ */ new Date())
4186
5995
  }
4187
5996
 
4188
5997
  // src/helpers/cucumberScenarioBuilder.ts
4189
- function buildScenarioStepTest(input, scenarioUid, i) {
5998
+ function buildScenarioStepTest(input, scenarioUid, attempt, i) {
4190
5999
  const {
4191
6000
  featureUri,
4192
6001
  scenarioName,
@@ -4202,7 +6011,9 @@ function buildScenarioStepTest(input, scenarioUid, i) {
4202
6011
  const stepDefLoc = findStepDefinitionLine(stepDefFiles, step.text);
4203
6012
  const callSource = stepDefLoc ? `${stepDefLoc.filePath}:${stepDefLoc.line}` : featureAbsPath && stepLines[i] > 0 ? `${featureAbsPath}:${stepLines[i]}` : void 0;
4204
6013
  return {
4205
- uid: deterministicUid(featureUri, `step:${scenarioName}:${step.text}`),
6014
+ // Scope by the scenario uid (which carries the scenario line) so identical
6015
+ // step text in sibling scenarios and outline example rows stays distinct.
6016
+ uid: deterministicUid(featureUri, `step:${scenarioUid}:${step.text}`),
4206
6017
  cid: DEFAULTS.CID,
4207
6018
  title: stepLabel,
4208
6019
  fullTitle: `${scenarioName} ${stepLabel}`,
@@ -4212,7 +6023,9 @@ function buildScenarioStepTest(input, scenarioUid, i) {
4212
6023
  end: null,
4213
6024
  type: "test",
4214
6025
  file: featureUri,
4215
- retries: 0,
6026
+ // Scenario-level attempt from the tracker (0 first run, +1 per retry);
6027
+ // flows to TestOutcome.attempt via collectSuiteTestMetadata.
6028
+ retries: attempt,
4216
6029
  _duration: 0,
4217
6030
  hooks: [],
4218
6031
  callSource
@@ -4228,7 +6041,11 @@ function buildCucumberScenarioSuite(input) {
4228
6041
  scenarioLine,
4229
6042
  parentFeatureSuiteUid
4230
6043
  } = input;
4231
- const scenarioUid = deterministicUid(featureUri, `scenario:${scenarioName}`);
6044
+ const scenarioUid = deterministicUid(
6045
+ featureUri,
6046
+ `scenario:${scenarioName}:${scenarioLine}`
6047
+ );
6048
+ const attempt = input.recordAttempt?.(scenarioUid, featureUri) ?? 0;
4232
6049
  const scenarioSuite = {
4233
6050
  uid: scenarioUid,
4234
6051
  cid: DEFAULTS.CID,
@@ -4247,49 +6064,51 @@ function buildCucumberScenarioSuite(input) {
4247
6064
  callSource: featureAbsPath && scenarioLine > 0 ? `${featureAbsPath}:${scenarioLine}` : void 0
4248
6065
  };
4249
6066
  for (let i = 0; i < steps.length; i++) {
4250
- scenarioSuite.tests.push(buildScenarioStepTest(input, scenarioUid, i));
6067
+ scenarioSuite.tests.push(
6068
+ buildScenarioStepTest(input, scenarioUid, attempt, i)
6069
+ );
4251
6070
  }
4252
6071
  return scenarioSuite;
4253
6072
  }
4254
6073
 
4255
6074
  // src/helpers/featureFileScan.ts
4256
- import fs10 from "fs";
4257
- import path8 from "path";
6075
+ import fs14 from "fs";
6076
+ import path12 from "path";
4258
6077
  function scanFeatureFile(featureUri) {
4259
- const featureAbsPath = path8.resolve(process.cwd(), featureUri);
6078
+ const featureAbsPath = path12.resolve(process.cwd(), featureUri);
4260
6079
  const result = {
4261
- featureName: path8.basename(featureUri, ".feature"),
6080
+ featureName: path12.basename(featureUri, ".feature"),
4262
6081
  featureContent: "",
4263
6082
  featureAbsPath,
4264
6083
  stepDefFiles: [],
4265
6084
  capturedPaths: []
4266
6085
  };
4267
- if (featureUri === "unknown.feature" || !fs10.existsSync(featureAbsPath)) {
6086
+ if (featureUri === "unknown.feature" || !fs14.existsSync(featureAbsPath)) {
4268
6087
  return result;
4269
6088
  }
4270
- result.featureContent = fs10.readFileSync(featureAbsPath, "utf-8");
6089
+ result.featureContent = fs14.readFileSync(featureAbsPath, "utf-8");
4271
6090
  const match = result.featureContent.match(/^\s*Feature:\s*(.+)/m);
4272
6091
  if (match) {
4273
6092
  result.featureName = match[1].trim();
4274
6093
  }
4275
6094
  result.capturedPaths.push(featureAbsPath);
4276
- const featureDir = path8.dirname(featureAbsPath);
6095
+ const featureDir = path12.dirname(featureAbsPath);
4277
6096
  const stepDirCandidates = ["step_definitions", "steps", "support"];
4278
6097
  for (const candidate of stepDirCandidates) {
4279
- const stepDir = path8.join(featureDir, candidate);
4280
- if (!fs10.existsSync(stepDir) || !fs10.statSync(stepDir).isDirectory()) {
6098
+ const stepDir = path12.join(featureDir, candidate);
6099
+ if (!fs14.existsSync(stepDir) || !fs14.statSync(stepDir).isDirectory()) {
4281
6100
  continue;
4282
6101
  }
4283
- for (const entry of fs10.readdirSync(stepDir)) {
6102
+ for (const entry of fs14.readdirSync(stepDir)) {
4284
6103
  if (!/\.(js|ts|mjs|cjs)$/.test(entry)) {
4285
6104
  continue;
4286
6105
  }
4287
- const stepFilePath = path8.join(stepDir, entry);
6106
+ const stepFilePath = path12.join(stepDir, entry);
4288
6107
  result.capturedPaths.push(stepFilePath);
4289
6108
  try {
4290
6109
  result.stepDefFiles.push({
4291
6110
  filePath: stepFilePath,
4292
- content: fs10.readFileSync(stepFilePath, "utf-8")
6111
+ content: fs14.readFileSync(stepFilePath, "utf-8")
4293
6112
  });
4294
6113
  } catch {
4295
6114
  }
@@ -4298,8 +6117,50 @@ function scanFeatureFile(featureUri) {
4298
6117
  return result;
4299
6118
  }
4300
6119
 
6120
+ // src/trace-slices.ts
6121
+ function sliceActive(ctx) {
6122
+ return ctx.traceMode && ctx.traceGranularity === "test";
6123
+ }
6124
+ function boundaryContext(ctx) {
6125
+ return {
6126
+ specRanges: ctx.specRanges,
6127
+ flushedSpecs: ctx.flushedSpecs,
6128
+ capturer: ctx.sessionCapturer
6129
+ };
6130
+ }
6131
+ function flushPrevious(ctx, prevRange) {
6132
+ if (!prevRange) {
6133
+ return;
6134
+ }
6135
+ void ctx.flushTraceRange(prevRange);
6136
+ }
6137
+ function recordSpecSliceBoundary(ctx, specFile) {
6138
+ const prevRange = recordSpecBoundary(
6139
+ boundaryContext(ctx),
6140
+ specFile,
6141
+ ctx.traceGranularity
6142
+ );
6143
+ flushPrevious(ctx, prevRange);
6144
+ }
6145
+ function recordTestSliceBoundary(ctx, specFile, testUid) {
6146
+ if (!sliceActive(ctx)) {
6147
+ return;
6148
+ }
6149
+ recordSliceBoundary(boundaryContext(ctx), "test", specFile, testUid);
6150
+ }
6151
+ function flushTestSlice(ctx) {
6152
+ if (!sliceActive(ctx)) {
6153
+ return;
6154
+ }
6155
+ const range = findFlushableRange(ctx.specRanges);
6156
+ if (!range) {
6157
+ return;
6158
+ }
6159
+ void ctx.flushTraceRange(range);
6160
+ }
6161
+
4301
6162
  // src/cucumber-lifecycle.ts
4302
- var log3 = logger3("@wdio/nightwatch-devtools:cucumber");
6163
+ var log4 = logger4("@wdio/nightwatch-devtools:cucumber");
4303
6164
  function attachScenarioToFeature(ctx, featureSuite, scenarioSuite) {
4304
6165
  const existingIdx = featureSuite.suites.findIndex(
4305
6166
  (s) => s.uid === scenarioSuite.uid
@@ -4335,6 +6196,12 @@ function createFeatureSuite(ctx, featureUri, featureName, featureContent, featur
4335
6196
  function normalizeSteps(pickleSteps) {
4336
6197
  return (pickleSteps ?? []).map((s) => ({ text: s.text ?? "" }));
4337
6198
  }
6199
+ function captureFeatureSources(ctx, paths) {
6200
+ for (const p of paths) {
6201
+ ctx.sessionCapturer.captureSource(p).catch(() => {
6202
+ });
6203
+ }
6204
+ }
4338
6205
  async function initCucumberScenario(ctx, browser, pickle) {
4339
6206
  await ctx.ensureSessionInitialized(browser);
4340
6207
  const featureUri = pickle.uri ?? "unknown.feature";
@@ -4347,10 +6214,7 @@ async function initCucumberScenario(ctx, browser, pickle) {
4347
6214
  stepDefFiles,
4348
6215
  capturedPaths
4349
6216
  } = scanFeatureFile(featureUri);
4350
- for (const p of capturedPaths) {
4351
- ctx.sessionCapturer.captureSource(p).catch(() => {
4352
- });
4353
- }
6217
+ captureFeatureSources(ctx, capturedPaths);
4354
6218
  const { featureSuite, scenarioLine, stepLines, stepKeywords } = createFeatureSuite(
4355
6219
  ctx,
4356
6220
  featureUri,
@@ -4370,15 +6234,17 @@ async function initCucumberScenario(ctx, browser, pickle) {
4370
6234
  stepLines,
4371
6235
  stepKeywords,
4372
6236
  scenarioLine,
4373
- parentFeatureSuiteUid: featureSuite.uid
6237
+ parentFeatureSuiteUid: featureSuite.uid,
6238
+ recordAttempt: (uid, specFile) => ctx.recordAttempt(uid, specFile)
4374
6239
  });
4375
6240
  attachScenarioToFeature(ctx, featureSuite, scenarioSuite);
6241
+ recordTestSliceBoundary(ctx, featureUri, scenarioSuite.uid);
4376
6242
  ctx.setCurrentScenarioSuite(scenarioSuite);
4377
6243
  ctx.setCurrentStep(null);
4378
6244
  ctx.setCurrentTest(null);
4379
6245
  ctx.testReporter.updateSuites();
4380
6246
  ctx.wrapBrowserOnce(browser);
4381
- log3.info(`\u{1F952} Scenario: ${scenarioName}`);
6247
+ log4.info(`\u{1F952} Scenario: ${scenarioName}`);
4382
6248
  }
4383
6249
  async function finalizeCucumberScenario(ctx, browser, result, pickle) {
4384
6250
  try {
@@ -4390,6 +6256,7 @@ async function finalizeCucumberScenario(ctx, browser, result, pickle) {
4390
6256
  scenario.state = scenarioState;
4391
6257
  scenario.end = now;
4392
6258
  scenario._duration = duration;
6259
+ ctx.recordOutcome(scenario.uid, scenarioState);
4393
6260
  closeOpenSteps(scenario, scenarioState, now);
4394
6261
  const featureUri = pickle?.uri ?? "unknown.feature";
4395
6262
  ctx.testManager.markTestAsProcessed(featureUri, pickle?.name ?? "");
@@ -4399,7 +6266,7 @@ async function finalizeCucumberScenario(ctx, browser, result, pickle) {
4399
6266
  }
4400
6267
  ctx.incrementCount(scenarioState);
4401
6268
  const icon = ctx.testIcon(scenarioState);
4402
- log3.info(
6269
+ log4.info(
4403
6270
  ` ${icon} ${pickle?.name ?? "Unknown"} (${(duration / 1e3).toFixed(2)}s)`
4404
6271
  );
4405
6272
  ctx.testReporter.updateSuites();
@@ -4408,8 +6275,13 @@ async function finalizeCucumberScenario(ctx, browser, result, pickle) {
4408
6275
  ctx.setCurrentTest(null);
4409
6276
  }
4410
6277
  await ctx.sessionCapturer.captureTrace(browser);
6278
+ flushTestSlice(ctx);
6279
+ await ctx.emitTestArtifacts(
6280
+ scenario?.uid,
6281
+ scenarioState === TEST_STATE.FAILED
6282
+ );
4411
6283
  } catch (err) {
4412
- log3.error(`Failed to finalize Cucumber scenario: ${errorMessage(err)}`);
6284
+ log4.error(`Failed to finalize Cucumber scenario: ${errorMessage(err)}`);
4413
6285
  }
4414
6286
  }
4415
6287
  async function cucumberBeforeStep(ctx, _browser, pickleStep, _pickle) {
@@ -4453,11 +6325,11 @@ async function cucumberAfter(ctx, browser, result, pickle) {
4453
6325
  }
4454
6326
 
4455
6327
  // src/test-lifecycle.ts
4456
- import logger5 from "@wdio/logger";
6328
+ import logger6 from "@wdio/logger";
4457
6329
 
4458
6330
  // src/helpers/specFileResolver.ts
4459
- import fs11 from "fs";
4460
- import path9 from "path";
6331
+ import fs15 from "fs";
6332
+ import path13 from "path";
4461
6333
  function resolveSpecFilePath(testFile, modulePath, srcFolders, cachedPath) {
4462
6334
  let fullPath = findTestFileFromStack() || null;
4463
6335
  if (!fullPath && cachedPath && cachedPath.includes(testFile)) {
@@ -4473,27 +6345,27 @@ function resolveSpecFilePath(testFile, modulePath, srcFolders, cachedPath) {
4473
6345
  const normalized = (modulePath || "").replace(/\\/g, "/");
4474
6346
  const srcFolderPaths = srcFolders.flatMap(
4475
6347
  (sf) => normalized ? [
4476
- path9.resolve(workspaceRoot, sf, normalized + ".js"),
4477
- path9.resolve(workspaceRoot, sf, normalized + ".ts"),
4478
- path9.resolve(workspaceRoot, sf, normalized + ".cjs"),
4479
- path9.resolve(workspaceRoot, sf, normalized)
6348
+ path13.resolve(workspaceRoot, sf, normalized + ".js"),
6349
+ path13.resolve(workspaceRoot, sf, normalized + ".ts"),
6350
+ path13.resolve(workspaceRoot, sf, normalized + ".cjs"),
6351
+ path13.resolve(workspaceRoot, sf, normalized)
4480
6352
  ] : []
4481
6353
  );
4482
6354
  const possiblePaths = [
4483
6355
  ...srcFolderPaths,
4484
6356
  // Treat module path as relative to cwd (works when src_folders isn't nested)
4485
6357
  ...normalized ? [
4486
- path9.resolve(workspaceRoot, normalized + ".js"),
4487
- path9.resolve(workspaceRoot, normalized + ".ts"),
4488
- path9.resolve(workspaceRoot, normalized + ".cjs"),
4489
- path9.resolve(workspaceRoot, normalized)
6358
+ path13.resolve(workspaceRoot, normalized + ".js"),
6359
+ path13.resolve(workspaceRoot, normalized + ".ts"),
6360
+ path13.resolve(workspaceRoot, normalized + ".cjs"),
6361
+ path13.resolve(workspaceRoot, normalized)
4490
6362
  ] : [],
4491
- path9.resolve(workspaceRoot, "tests", testFile + ".js"),
4492
- path9.resolve(workspaceRoot, "test", testFile + ".js"),
4493
- path9.resolve(workspaceRoot, testFile + ".js")
6363
+ path13.resolve(workspaceRoot, "tests", testFile + ".js"),
6364
+ path13.resolve(workspaceRoot, "test", testFile + ".js"),
6365
+ path13.resolve(workspaceRoot, testFile + ".js")
4494
6366
  ];
4495
6367
  for (const candidate of possiblePaths) {
4496
- if (fs11.existsSync(candidate)) {
6368
+ if (fs15.existsSync(candidate)) {
4497
6369
  return candidate;
4498
6370
  }
4499
6371
  }
@@ -4501,8 +6373,8 @@ function resolveSpecFilePath(testFile, modulePath, srcFolders, cachedPath) {
4501
6373
  }
4502
6374
 
4503
6375
  // src/helpers/closePreviousTest.ts
4504
- import logger4 from "@wdio/logger";
4505
- var log4 = logger4("@wdio/nightwatch-devtools:closePreviousTest");
6376
+ import logger5 from "@wdio/logger";
6377
+ var log5 = logger5("@wdio/nightwatch-devtools:closePreviousTest");
4506
6378
  async function closePreviousTest(input) {
4507
6379
  const {
4508
6380
  runningTest,
@@ -4521,7 +6393,7 @@ async function closePreviousTest(input) {
4521
6393
  testManager.updateTestState(runningTest, testState);
4522
6394
  testManager.markTestAsProcessed(testFile, runningTest.title);
4523
6395
  incrementCount(testState);
4524
- log4.info(
6396
+ log5.info(
4525
6397
  ` ${testIcon(testState)} ${runningTest.title} (${(runningTest._duration / 1e3).toFixed(2)}s)`
4526
6398
  );
4527
6399
  } else {
@@ -4535,13 +6407,13 @@ async function closePreviousTest(input) {
4535
6407
  );
4536
6408
  testManager.markTestAsProcessed(testFile, runningTest.title);
4537
6409
  incrementCount(TEST_STATE.PASSED);
4538
- log4.info(` \u2705 ${runningTest.title} (${(duration / 1e3).toFixed(2)}s)`);
6410
+ log5.info(` \u2705 ${runningTest.title} (${(duration / 1e3).toFixed(2)}s)`);
4539
6411
  }
4540
6412
  await new Promise((resolve2) => setTimeout(resolve2, TIMING.UI_RENDER_DELAY));
4541
6413
  }
4542
6414
 
4543
6415
  // src/test-lifecycle.ts
4544
- var log5 = logger5("@wdio/nightwatch-devtools:test-lifecycle");
6416
+ var log6 = logger6("@wdio/nightwatch-devtools:test-lifecycle");
4545
6417
  function resolveSuiteMetadata(ctx, currentTest) {
4546
6418
  const moduleName = currentTest.module ?? "";
4547
6419
  const testFile = moduleName.split("/").pop() || moduleName || DEFAULTS.FILE_NAME;
@@ -4552,7 +6424,7 @@ function resolveSuiteMetadata(ctx, currentTest) {
4552
6424
  ctx.browserProxy.getCurrentTestFullPath() || void 0
4553
6425
  );
4554
6426
  if (!fullPath) {
4555
- log5.warn(
6427
+ log6.warn(
4556
6428
  `[beforeEach] Could not resolve file path for "${testFile}" \u2014 source view will be unavailable`
4557
6429
  );
4558
6430
  }
@@ -4586,21 +6458,25 @@ function pickCurrentTestName(currentTest, testNames, processedTests) {
4586
6458
  ) : void 0;
4587
6459
  return matchedRuntimeTestName || testNames.find((name) => !processedTests.has(name));
4588
6460
  }
4589
- async function startNextTest(ctx, currentSuite, currentTestName, processedTests) {
6461
+ async function startNextTest(ctx, currentSuite, currentTestName, processedTests, specFile) {
4590
6462
  if (processedTests.size === 0) {
4591
6463
  ctx.suiteManager.markSuiteAsRunning(currentSuite);
4592
6464
  }
4593
6465
  const test = ctx.testManager.findTestInSuite(currentSuite, currentTestName);
4594
6466
  if (test) {
6467
+ test.retries = ctx.recordAttempt(test.uid, specFile ?? void 0);
6468
+ if (specFile) {
6469
+ recordTestSliceBoundary(ctx, specFile, test.uid);
6470
+ }
4595
6471
  test.state = TEST_STATE.RUNNING;
4596
6472
  test.start = /* @__PURE__ */ new Date();
4597
6473
  test.end = null;
4598
6474
  ctx.testReporter.onTestStart(test);
4599
6475
  ctx.setCurrentTest(test);
4600
- log5.info(` \u25B6 ${currentTestName}`);
6476
+ log6.info(` \u25B6 ${currentTestName}`);
4601
6477
  await new Promise((resolve2) => setTimeout(resolve2, TIMING.TEST_START_DELAY));
4602
6478
  } else {
4603
- log5.warn(
6479
+ log6.warn(
4604
6480
  `Test "${currentTestName}" not found in suite "${currentSuite.title}"`
4605
6481
  );
4606
6482
  ctx.setCurrentTest(null);
@@ -4645,7 +6521,7 @@ function closeUnreportedRunningTest(ctx, currentSuite, testFile, results, proces
4645
6521
  ctx.testManager.markTestAsProcessed(testFile, runningTest.title);
4646
6522
  ctx.incrementCount(testState);
4647
6523
  const icon = ctx.testIcon(testState);
4648
- log5.info(` ${icon} ${runningTest.title} (${(duration / 1e3).toFixed(2)}s)`);
6524
+ log6.info(` ${icon} ${runningTest.title} (${(duration / 1e3).toFixed(2)}s)`);
4649
6525
  }
4650
6526
  async function closeReportedTestcases(ctx, currentSuite, testFile, testcases, processedTests) {
4651
6527
  const testcaseNames = Object.keys(testcases);
@@ -4661,7 +6537,7 @@ async function closeReportedTestcases(ctx, currentSuite, testFile, testcases, pr
4661
6537
  ctx.testManager.updateTestState(test, testState, /* @__PURE__ */ new Date(), dur);
4662
6538
  ctx.incrementCount(testState);
4663
6539
  const icon = ctx.testIcon(testState);
4664
- log5.info(` ${icon} ${currentTestName} (${(dur / 1e3).toFixed(2)}s)`);
6540
+ log6.info(` ${icon} ${currentTestName} (${(dur / 1e3).toFixed(2)}s)`);
4665
6541
  }
4666
6542
  ctx.testManager.markTestAsProcessed(testFile, currentTestName);
4667
6543
  }
@@ -4703,11 +6579,11 @@ async function closeOutTestcases(ctx, browser) {
4703
6579
  }
4704
6580
 
4705
6581
  // src/session-init.ts
4706
- import logger10 from "@wdio/logger";
6582
+ import logger12 from "@wdio/logger";
4707
6583
 
4708
6584
  // src/session.ts
4709
6585
  import http from "http";
4710
- import logger6 from "@wdio/logger";
6586
+ import logger7 from "@wdio/logger";
4711
6587
 
4712
6588
  // src/action-snapshot.ts
4713
6589
  function captureActionSnapshot2(browser, command, takeScreenshot) {
@@ -4845,7 +6721,7 @@ function dedupeNetworkRequests(incoming, existing) {
4845
6721
  }
4846
6722
 
4847
6723
  // src/session.ts
4848
- var log6 = logger6("@wdio/nightwatch-devtools:SessionCapturer");
6724
+ var log7 = logger7("@wdio/nightwatch-devtools:SessionCapturer");
4849
6725
  function unwrapDriverValue(result) {
4850
6726
  if (result && typeof result === "object" && "value" in result) {
4851
6727
  return result.value;
@@ -4853,7 +6729,7 @@ function unwrapDriverValue(result) {
4853
6729
  return result;
4854
6730
  }
4855
6731
  var getProp = (obj, key) => obj && typeof obj === "object" ? obj[key] : void 0;
4856
- var getPath = (obj, ...path10) => path10.reduce((acc, k) => getProp(acc, k), obj);
6732
+ var getPath = (obj, ...path14) => path14.reduce((acc, k) => getProp(acc, k), obj);
4857
6733
  var firstDefined = (obj, ...keys) => {
4858
6734
  if (!obj || typeof obj !== "object") {
4859
6735
  return void 0;
@@ -4901,13 +6777,13 @@ var SessionCapturer = class extends SessionCapturerBase {
4901
6777
  this.patchStreams();
4902
6778
  }
4903
6779
  onWsOpen() {
4904
- log6.info("\u2713 Worker WebSocket connected to backend");
6780
+ log7.info("\u2713 Worker WebSocket connected to backend");
4905
6781
  }
4906
6782
  onWsError(err) {
4907
- log6.error(`Couldn't connect to devtools backend: ${errorMessage(err)}`);
6783
+ log7.error(`Couldn't connect to devtools backend: ${errorMessage(err)}`);
4908
6784
  }
4909
6785
  onWsClose() {
4910
- log6.info("Worker WebSocket disconnected");
6786
+ log7.info("Worker WebSocket disconnected");
4911
6787
  }
4912
6788
  async captureCommand(command, args, result, error, testUid, callSource, timestamp) {
4913
6789
  const serializedError = serializeError(error);
@@ -4928,7 +6804,7 @@ var SessionCapturer = class extends SessionCapturerBase {
4928
6804
  );
4929
6805
  if (isNavigationCommand && this.#browser && !error) {
4930
6806
  this.#capturePerformanceData(commandLogEntry, args).catch((err) => {
4931
- log6.warn(`Failed to capture performance data: ${errorMessage(err)}`);
6807
+ log7.warn(`Failed to capture performance data: ${errorMessage(err)}`);
4932
6808
  });
4933
6809
  }
4934
6810
  if (this.traceMode === "trace" && !error && this.#browser && mapCommandToAction(command)) {
@@ -4959,6 +6835,37 @@ var SessionCapturer = class extends SessionCapturerBase {
4959
6835
  typeof args[0] === "string" ? args[0] : void 0
4960
6836
  );
4961
6837
  }
6838
+ /**
6839
+ * Ingest a pre-built assertion entry (native `browser.assert`/`verify`
6840
+ * synthesized from Nightwatch's results, or a node:assert capture) into the
6841
+ * same command stream driver commands use. Unlike {@link captureCommand} this
6842
+ * preserves the entry's `title` (the human assertion message) and never
6843
+ * dedups — each call is a distinct action row. Assigns a fresh `_id` and a
6844
+ * matching stable public `id` (so a later `sendReplaceCommand` can update
6845
+ * this exact row in place — native asserts stream a pending row at call time,
6846
+ * then finalize their pass/fail), pushes, and broadcasts.
6847
+ */
6848
+ captureAssertCommand(entry) {
6849
+ const withId = entry;
6850
+ withId._id = this.commandCounter++;
6851
+ withId.id = withId._id;
6852
+ this.commandsLog.push(withId);
6853
+ if (this.traceMode === "trace" && !entry.error && this.#browser && mapCommandToAction(entry.command)) {
6854
+ const browser = this.#browser;
6855
+ this.snapshotCaptures.push(
6856
+ captureActionSnapshot2(
6857
+ browser,
6858
+ entry.command,
6859
+ () => this.takeScreenshotViaHttp(browser)
6860
+ ).then((snap) => {
6861
+ if (snap) {
6862
+ this.actionSnapshots.push(snap);
6863
+ }
6864
+ })
6865
+ );
6866
+ }
6867
+ this.sendCommand(withId);
6868
+ }
4962
6869
  /**
4963
6870
  * Replace an already-captured command entry (used for retried commands so
4964
6871
  * only the final execution result is shown in the UI).
@@ -5018,38 +6925,38 @@ var SessionCapturer = class extends SessionCapturerBase {
5018
6925
  try {
5019
6926
  const value = JSON.parse(body).value || null;
5020
6927
  if (!value) {
5021
- log6.warn(`[screenshot] Empty response from ${endpoint}`);
6928
+ log7.warn(`[screenshot] Empty response from ${endpoint}`);
5022
6929
  }
5023
6930
  resolve2(value);
5024
6931
  } catch {
5025
- log6.warn(`[screenshot] Failed to parse response from ${endpoint}`);
6932
+ log7.warn(`[screenshot] Failed to parse response from ${endpoint}`);
5026
6933
  resolve2(null);
5027
6934
  }
5028
6935
  });
5029
6936
  });
5030
6937
  req.on("error", (err) => {
5031
- log6.warn(
6938
+ log7.warn(
5032
6939
  `[screenshot] HTTP request failed (${endpoint}): ${errorMessage(err)}`
5033
6940
  );
5034
6941
  resolve2(null);
5035
6942
  });
5036
6943
  req.setTimeout(5e3, () => {
5037
- log6.warn(`[screenshot] Request timed out (${endpoint})`);
6944
+ log7.warn(`[screenshot] Request timed out (${endpoint})`);
5038
6945
  req.destroy();
5039
6946
  resolve2(null);
5040
6947
  });
5041
6948
  });
5042
6949
  }
5043
6950
  onSourceReadError(filePath, err) {
5044
- log6.warn(`Failed to read source file ${filePath}: ${errorMessage(err)}`);
6951
+ log7.warn(`Failed to read source file ${filePath}: ${errorMessage(err)}`);
5045
6952
  }
5046
6953
  onUpstreamDrop(event, reason, err) {
5047
6954
  if (reason === "send-error") {
5048
- log6.warn(`[upstream] Failed to send "${event}": ${errorMessage(err)}`);
6955
+ log7.warn(`[upstream] Failed to send "${event}": ${errorMessage(err)}`);
5049
6956
  return;
5050
6957
  }
5051
6958
  if (this.hasEverConnected()) {
5052
- log6.warn(`[upstream] WebSocket not open \u2014 dropping "${event}" event`);
6959
+ log7.warn(`[upstream] WebSocket not open \u2014 dropping "${event}" event`);
5053
6960
  }
5054
6961
  }
5055
6962
  /**
@@ -5072,12 +6979,12 @@ var SessionCapturer = class extends SessionCapturerBase {
5072
6979
  return unwrapDriverValue(checkResult) === true;
5073
6980
  });
5074
6981
  if (hasCollector) {
5075
- log6.info("\u2713 Script injected and collector ready");
6982
+ log7.info("\u2713 Script injected and collector ready");
5076
6983
  } else {
5077
- log6.warn("Script injection may have failed \u2014 collector not found");
6984
+ log7.warn("Script injection may have failed \u2014 collector not found");
5078
6985
  }
5079
6986
  } catch (err) {
5080
- log6.error(`Failed to inject script: ${errorMessage(err)}`);
6987
+ log7.error(`Failed to inject script: ${errorMessage(err)}`);
5081
6988
  throw err;
5082
6989
  }
5083
6990
  }
@@ -5131,7 +7038,7 @@ var SessionCapturer = class extends SessionCapturerBase {
5131
7038
  } catch (err) {
5132
7039
  const msg = errorMessage(err) ?? "";
5133
7040
  if (!msg.includes("log type") && !msg.includes("performance")) {
5134
- log6.warn(`Performance log capture failed: ${msg}`);
7041
+ log7.warn(`Performance log capture failed: ${msg}`);
5135
7042
  }
5136
7043
  }
5137
7044
  }
@@ -5158,10 +7065,10 @@ var SessionCapturer = class extends SessionCapturerBase {
5158
7065
  traceData.mutations
5159
7066
  ) ? traceData.mutations.length : 0;
5160
7067
  if (mutationCount > 0) {
5161
- log6.info(`[trace] Captured ${mutationCount} DOM mutation(s)`);
7068
+ log7.info(`[trace] Captured ${mutationCount} DOM mutation(s)`);
5162
7069
  }
5163
7070
  } catch (err) {
5164
- log6.error(
7071
+ log7.error(
5165
7072
  `Failed to capture trace from injected script: ${errorMessage(err)}`
5166
7073
  );
5167
7074
  }
@@ -5169,12 +7076,17 @@ var SessionCapturer = class extends SessionCapturerBase {
5169
7076
  };
5170
7077
 
5171
7078
  // src/reporter.ts
5172
- import logger7 from "@wdio/logger";
5173
- var log7 = logger7("@wdio/nightwatch-devtools:Reporter");
7079
+ import logger8 from "@wdio/logger";
7080
+ var log8 = logger8("@wdio/nightwatch-devtools:Reporter");
5174
7081
  var TestReporter = class extends TestReporterBase {
5175
7082
  #currentSpecFile;
5176
7083
  #testNamesCache = /* @__PURE__ */ new Map();
5177
7084
  #currentSuite;
7085
+ #attemptFor;
7086
+ constructor(report, attemptFor) {
7087
+ super(report);
7088
+ this.#attemptFor = attemptFor;
7089
+ }
5178
7090
  onSuiteStart(suiteStats) {
5179
7091
  this.#currentSpecFile = suiteStats.file;
5180
7092
  this.#currentSuite = suiteStats;
@@ -5187,7 +7099,7 @@ var TestReporter = class extends TestReporterBase {
5187
7099
  const testNames = rerunLabel ? metadata.testNames.filter((name) => name === rerunLabel) : metadata.testNames;
5188
7100
  if (testNames.length > 0) {
5189
7101
  this.#testNamesCache.set(this.#currentSpecFile, testNames);
5190
- log7.info(
7102
+ log8.info(
5191
7103
  `\u{1F4DD} Extracted ${testNames.length} test names from ${this.#currentSpecFile}`
5192
7104
  );
5193
7105
  }
@@ -5235,11 +7147,12 @@ var TestReporter = class extends TestReporterBase {
5235
7147
  );
5236
7148
  cachedNames.forEach((testName) => {
5237
7149
  if (!processedTestNames.has(testName)) {
7150
+ const uid = generateStableUid2({
7151
+ file: suiteStats.file,
7152
+ fullTitle: `${suiteStats.title} ${testName}`
7153
+ });
5238
7154
  const skippedTest = {
5239
- uid: generateStableUid2({
5240
- file: suiteStats.file,
5241
- fullTitle: `${suiteStats.title} ${testName}`
5242
- }),
7155
+ uid,
5243
7156
  cid: DEFAULTS.CID,
5244
7157
  title: testName,
5245
7158
  fullTitle: `${suiteStats.title} ${testName}`,
@@ -5249,12 +7162,12 @@ var TestReporter = class extends TestReporterBase {
5249
7162
  end: /* @__PURE__ */ new Date(),
5250
7163
  type: "test",
5251
7164
  file: suiteStats.file,
5252
- retries: DEFAULTS.RETRIES,
7165
+ retries: this.#attemptFor(uid) ?? DEFAULTS.RETRIES,
5253
7166
  _duration: DEFAULTS.DURATION,
5254
7167
  hooks: []
5255
7168
  };
5256
7169
  suiteStats.tests.push(skippedTest);
5257
- log7.info(`Created skipped test "${testName}" (never executed)`);
7170
+ log8.info(`Created skipped test "${testName}" (never executed)`);
5258
7171
  }
5259
7172
  });
5260
7173
  this.sendUpstream();
@@ -5273,10 +7186,12 @@ var TestReporter = class extends TestReporterBase {
5273
7186
 
5274
7187
  // src/helpers/testManager.ts
5275
7188
  var TestManager = class {
5276
- constructor(testReporter) {
7189
+ constructor(testReporter, recordOutcome) {
5277
7190
  this.testReporter = testReporter;
7191
+ this.recordOutcome = recordOutcome;
5278
7192
  }
5279
7193
  testReporter;
7194
+ recordOutcome;
5280
7195
  processedTests = /* @__PURE__ */ new Map();
5281
7196
  lastKnownTestName = null;
5282
7197
  /**
@@ -5299,6 +7214,7 @@ var TestManager = class {
5299
7214
  }
5300
7215
  if (state !== TEST_STATE.RUNNING) {
5301
7216
  this.testReporter.onTestEnd(test);
7217
+ this.recordOutcome?.(test.uid, state);
5302
7218
  }
5303
7219
  }
5304
7220
  /**
@@ -5518,7 +7434,7 @@ var SuiteManager = class {
5518
7434
  };
5519
7435
 
5520
7436
  // src/helpers/browserProxy.ts
5521
- import logger8 from "@wdio/logger";
7437
+ import logger10 from "@wdio/logger";
5522
7438
 
5523
7439
  // src/helpers/serializeCommandResult.ts
5524
7440
  function serializeCommandResult(callbackResult, methodName) {
@@ -5551,17 +7467,201 @@ function serializeCommandResult(callbackResult, methodName) {
5551
7467
  return void 0;
5552
7468
  }
5553
7469
 
7470
+ // src/helpers/nativeAssertions.ts
7471
+ import logger9 from "@wdio/logger";
7472
+ var log9 = logger9("@wdio/nightwatch-devtools:nativeAssertions");
7473
+ var ASSERT_CMD_RE = /^(assert|verify)\.\w+$/;
7474
+ function assertCommandTimings(commands, count) {
7475
+ const executed = commands.filter(
7476
+ (c) => typeof c?.name === "string" && ASSERT_CMD_RE.test(c.name)
7477
+ );
7478
+ if (executed.length !== count) {
7479
+ return new Array(count).fill(null);
7480
+ }
7481
+ return executed.map(
7482
+ (c) => typeof c.startTime === "number" && typeof c.endTime === "number" ? { startTime: c.startTime, endTime: c.endTime } : null
7483
+ );
7484
+ }
7485
+ function messageMatchesArgs(entry, args) {
7486
+ const text = String(entry.fullMsg ?? entry.message ?? "");
7487
+ const literals = args.filter(
7488
+ (a) => typeof a === "string" || typeof a === "number"
7489
+ );
7490
+ return literals.length > 0 && literals.every((a) => text.includes(String(a)));
7491
+ }
7492
+ function gatherResultAssertions(results) {
7493
+ const testcases = results?.testcases;
7494
+ if (testcases && typeof testcases === "object") {
7495
+ const all = [];
7496
+ for (const tc of Object.values(testcases)) {
7497
+ if (Array.isArray(tc?.assertions)) {
7498
+ all.push(...tc.assertions);
7499
+ }
7500
+ }
7501
+ if (all.length > 0) {
7502
+ return all;
7503
+ }
7504
+ }
7505
+ return Array.isArray(results?.assertions) ? results.assertions : [];
7506
+ }
7507
+ function correlate(calls, assertions) {
7508
+ const consumed = new Array(assertions.length).fill(false);
7509
+ const toOutcome = (idx) => {
7510
+ consumed[idx] = true;
7511
+ const entry = assertions[idx];
7512
+ return {
7513
+ passed: !entry.failure,
7514
+ message: stripAnsi(String(entry.fullMsg ?? entry.message ?? "")).trim()
7515
+ };
7516
+ };
7517
+ const matched = calls.map((call) => {
7518
+ const idx = assertions.findIndex(
7519
+ (entry, i) => !consumed[i] && messageMatchesArgs(entry, call.args)
7520
+ );
7521
+ return idx === -1 ? null : toOutcome(idx);
7522
+ });
7523
+ return matched.map((outcome) => {
7524
+ if (outcome) {
7525
+ return outcome;
7526
+ }
7527
+ const idx = consumed.findIndex((used) => !used);
7528
+ return idx === -1 ? null : toOutcome(idx);
7529
+ });
7530
+ }
7531
+ function latestResolvedScreenshot(capturer) {
7532
+ for (let i = capturer.commandsLog.length - 1; i >= 0; i--) {
7533
+ const shot = capturer.commandsLog[i]?.screenshot;
7534
+ if (shot) {
7535
+ return shot;
7536
+ }
7537
+ }
7538
+ return null;
7539
+ }
7540
+ async function resolveAssertionScreenshot(capturer, browser) {
7541
+ return latestResolvedScreenshot(capturer) ?? await capturer.takeScreenshotViaHttp(browser);
7542
+ }
7543
+ function conciseTitle(call) {
7544
+ const preview = call.args.map(
7545
+ (a) => typeof a === "string" ? `'${a}'` : a !== null && typeof a === "object" ? "\u2026" : String(a)
7546
+ ).join(", ");
7547
+ return `${call.prefix}.${call.method}(${preview})`;
7548
+ }
7549
+ function pendingAssertionCommand(call, testUid, screenshot) {
7550
+ const entry = {
7551
+ command: `${call.prefix}.${call.method}`,
7552
+ args: call.args.map(safeSerializeAssertArg),
7553
+ title: conciseTitle(call),
7554
+ timestamp: call.timestamp,
7555
+ startTime: call.timestamp
7556
+ };
7557
+ if (call.callSource) {
7558
+ entry.callSource = call.callSource;
7559
+ }
7560
+ if (testUid) {
7561
+ entry.testUid = testUid;
7562
+ }
7563
+ if (screenshot) {
7564
+ entry.screenshot = screenshot;
7565
+ }
7566
+ return entry;
7567
+ }
7568
+ function parseActualFromMessage(message) {
7569
+ const marker = "but got:";
7570
+ const idx = message.lastIndexOf(marker);
7571
+ if (idx === -1) {
7572
+ return void 0;
7573
+ }
7574
+ let rest = message.slice(idx + marker.length).trim();
7575
+ rest = rest.replace(/ \(\d+ms\)$/, "").trim();
7576
+ rest = rest.replace(/^["']/, "").replace(/["']$/, "").trim();
7577
+ return rest || void 0;
7578
+ }
7579
+ function collapsedAssertResult2(call, outcome) {
7580
+ const result = {
7581
+ passed: outcome.passed,
7582
+ expected: call.args.length <= 1 ? call.args[0] : call.args,
7583
+ message: outcome.message
7584
+ };
7585
+ const actual = outcome.passed ? void 0 : parseActualFromMessage(outcome.message);
7586
+ if (actual !== void 0) {
7587
+ result.actual = actual;
7588
+ }
7589
+ return result;
7590
+ }
7591
+ function finalizeAssertionRow(capturer, call, outcome, timing, screenshot, testUid) {
7592
+ const entry = call.entry;
7593
+ const finalized = matcherAssertionToCommandLog(
7594
+ {
7595
+ prefix: call.prefix,
7596
+ method: call.method,
7597
+ args: call.args,
7598
+ passed: outcome.passed,
7599
+ message: outcome.message || `${call.prefix}.${call.method} failed`,
7600
+ callSource: call.callSource,
7601
+ title: entry.title
7602
+ },
7603
+ testUid
7604
+ );
7605
+ entry.result = collapsedAssertResult2(call, outcome);
7606
+ entry.error = finalized.error;
7607
+ if (!entry.screenshot && screenshot) {
7608
+ entry.screenshot = screenshot;
7609
+ }
7610
+ if (timing) {
7611
+ entry.startTime = timing.startTime;
7612
+ entry.timestamp = timing.endTime > timing.startTime ? timing.endTime : timing.startTime;
7613
+ }
7614
+ capturer.captureAssertCommand(entry);
7615
+ log9.info(`[assert] ${entry.title} \u2192 ${outcome.passed ? "pass" : "fail"}`);
7616
+ }
7617
+ async function captureNativeAssertions(capturer, browser, currentTest, testUid, calls) {
7618
+ if (calls.length === 0) {
7619
+ return;
7620
+ }
7621
+ const results = currentTest?.results;
7622
+ const assertions = gatherResultAssertions(results);
7623
+ const outcomes = correlate(calls, assertions);
7624
+ const timings = assertCommandTimings(
7625
+ Array.isArray(results?.commands) ? results.commands : [],
7626
+ calls.length
7627
+ );
7628
+ const screenshot = await resolveAssertionScreenshot(capturer, browser);
7629
+ calls.forEach((call, index) => {
7630
+ if (!call.entry) {
7631
+ return;
7632
+ }
7633
+ const outcome = outcomes[index];
7634
+ if (outcome) {
7635
+ finalizeAssertionRow(
7636
+ capturer,
7637
+ call,
7638
+ outcome,
7639
+ timings[index],
7640
+ screenshot,
7641
+ testUid
7642
+ );
7643
+ return;
7644
+ }
7645
+ if (!call.entry.screenshot && screenshot) {
7646
+ call.entry.screenshot = screenshot;
7647
+ }
7648
+ capturer.captureAssertCommand(call.entry);
7649
+ });
7650
+ }
7651
+
5554
7652
  // src/helpers/browserProxy.ts
5555
- var log8 = logger8("@wdio/nightwatch-devtools:browserProxy");
7653
+ var log10 = logger10("@wdio/nightwatch-devtools:browserProxy");
5556
7654
  var BrowserProxy = class {
5557
- constructor(sessionCapturer, testManager, getCurrentTest) {
7655
+ constructor(sessionCapturer, testManager, getCurrentTest, captureAssertions = true) {
5558
7656
  this.sessionCapturer = sessionCapturer;
5559
7657
  this.testManager = testManager;
5560
7658
  this.getCurrentTest = getCurrentTest;
7659
+ this.captureAssertions = captureAssertions;
5561
7660
  }
5562
7661
  sessionCapturer;
5563
7662
  testManager;
5564
7663
  getCurrentTest;
7664
+ captureAssertions;
5565
7665
  /** Tracks which browser *instances* have already been proxied to avoid double-wrapping. */
5566
7666
  proxiedBrowsers = /* @__PURE__ */ new WeakSet();
5567
7667
  commandStack = [];
@@ -5573,6 +7673,14 @@ var BrowserProxy = class {
5573
7673
  * rather than appending, showing only the final execution result.
5574
7674
  */
5575
7675
  retryTracker = new RetryTracker();
7676
+ /**
7677
+ * Per-test buffer of explicit `browser.assert.*` / `browser.verify.*` calls,
7678
+ * recorded synchronously at call time by {@link wrapAssertionNamespaces}.
7679
+ * Nightwatch exposes no per-assertion hook and its test-end results carry no
7680
+ * source location for passing assertions, so call-time capture is the only
7681
+ * way to get real args + callSource. Drained (and cleared) each `afterEach`.
7682
+ */
7683
+ nativeAssertCalls = [];
5576
7684
  /**
5577
7685
  * Update the session capturer reference after a WebDriver session change.
5578
7686
  * Does NOT re-wrap browser methods — wrapping is permanent per browser object.
@@ -5584,12 +7692,98 @@ var BrowserProxy = class {
5584
7692
  this.commandStack = [];
5585
7693
  this.lastCommandSig = null;
5586
7694
  this.retryTracker.reset();
7695
+ this.nativeAssertCalls = [];
7696
+ }
7697
+ /** Hand off this test's recorded native-assertion calls and clear the
7698
+ * buffer so the next test starts fresh. */
7699
+ drainNativeAssertCalls() {
7700
+ const calls = this.nativeAssertCalls;
7701
+ this.nativeAssertCalls = [];
7702
+ return calls;
7703
+ }
7704
+ /**
7705
+ * Replace `browser.assert` / `browser.verify` (both are Nightwatch Proxies
7706
+ * whose `get` returns a fresh function per access) with a recording Proxy:
7707
+ * on each method call it captures `{prefix, method, args, callSource}` from a
7708
+ * user-code frame, streams a neutral pending row immediately (live), then
7709
+ * delegates to the ORIGINAL namespace method so Nightwatch's queue, chaining,
7710
+ * and abortOnFailure semantics (assert aborts, verify does not) are
7711
+ * byte-for-byte unchanged. Called once per browser.
7712
+ */
7713
+ wrapAssertionNamespaces(browser) {
7714
+ if (!this.captureAssertions) {
7715
+ return;
7716
+ }
7717
+ const b = browser;
7718
+ ["assert", "verify"].forEach((prefix) => {
7719
+ const original = b[prefix];
7720
+ if (!original || typeof original !== "object") {
7721
+ return;
7722
+ }
7723
+ b[prefix] = this.recordingNamespaceProxy(original, prefix, []);
7724
+ });
7725
+ }
7726
+ /**
7727
+ * Recording Proxy over one assert/verify namespace. A function property
7728
+ * becomes a call-time recorder keyed by its full dotted path
7729
+ * (`titleContains`, `not.titleContains`); a nested namespace object recurses
7730
+ * through the SAME wrapper — Nightwatch exposes `assert.not` as its own Proxy,
7731
+ * so negated asserts are recorded via the identical mechanism as positive
7732
+ * ones instead of a parallel path. The recorder buffers a pending row, then
7733
+ * delegates to the ORIGINAL method so Nightwatch's queue, chaining, and
7734
+ * abort/negate semantics stay byte-for-byte unchanged. Non-method,
7735
+ * non-namespace props pass through untouched.
7736
+ */
7737
+ recordingNamespaceProxy(target, prefix, path14) {
7738
+ return new Proxy(target, {
7739
+ get: (t, name, receiver) => {
7740
+ const orig = Reflect.get(t, name, receiver);
7741
+ if (typeof name !== "string") {
7742
+ return orig;
7743
+ }
7744
+ if (orig !== null && typeof orig === "object") {
7745
+ return this.recordingNamespaceProxy(orig, prefix, [...path14, name]);
7746
+ }
7747
+ if (typeof orig !== "function") {
7748
+ return orig;
7749
+ }
7750
+ return (...args) => {
7751
+ const callInfo = getCallSourceFromStack();
7752
+ if (callInfo.filePath !== void 0) {
7753
+ this.emitPendingAssertion({
7754
+ prefix,
7755
+ method: [...path14, name].join("."),
7756
+ args,
7757
+ callSource: callInfo.callSource,
7758
+ timestamp: Date.now()
7759
+ });
7760
+ }
7761
+ return orig(...args);
7762
+ };
7763
+ }
7764
+ });
7765
+ }
7766
+ /** Buffer an explicit assert/verify call at invocation time (prebuilding its
7767
+ * row with args/callSource/screenshot), but do NOT stream it yet. Nightwatch
7768
+ * exposes no per-assertion result hook, so streaming here would show every
7769
+ * assert as a neutral (green) row while the test is still Running — before
7770
+ * its pass/fail is known. `captureNativeAssertions` emits the rows at test-end
7771
+ * with real outcomes + execution timing instead. */
7772
+ emitPendingAssertion(call) {
7773
+ const testUid = this.getCurrentTest()?.uid;
7774
+ const entry = pendingAssertionCommand(
7775
+ call,
7776
+ testUid,
7777
+ latestResolvedScreenshot(this.sessionCapturer)
7778
+ );
7779
+ call.entry = entry;
7780
+ this.nativeAssertCalls.push(call);
5587
7781
  }
5588
7782
  getCurrentTestFullPath() {
5589
7783
  return this.currentTestFullPath;
5590
7784
  }
5591
- setCurrentTestFullPath(path10) {
5592
- this.currentTestFullPath = path10;
7785
+ setCurrentTestFullPath(path14) {
7786
+ this.currentTestFullPath = path14;
5593
7787
  }
5594
7788
  /**
5595
7789
  * Wrap browser navigation methods (url / navigate / navigateTo) to inject
@@ -5610,9 +7804,9 @@ var BrowserProxy = class {
5610
7804
  b[methodName] = function(...args) {
5611
7805
  const result = original(...args);
5612
7806
  const injectAndCapture = () => {
5613
- log8.info(`[nav] ${methodName}(${args[0] ?? ""}) \u2014 injecting script`);
7807
+ log10.info(`[nav] ${methodName}(${args[0] ?? ""}) \u2014 injecting script`);
5614
7808
  return sessionCapturer.injectScript(browser).then(() => sessionCapturer.captureTrace(browser)).catch(
5615
- (err) => log8.error(`Failed to inject script: ${err.message}`)
7809
+ (err) => log10.error(`Failed to inject script: ${err.message}`)
5616
7810
  );
5617
7811
  };
5618
7812
  const chainable = result;
@@ -5629,7 +7823,7 @@ var BrowserProxy = class {
5629
7823
  wrapNav("url");
5630
7824
  wrapNav("navigate");
5631
7825
  wrapNav("navigateTo");
5632
- log8.info("\u2713 Script injection wrapped");
7826
+ log10.info("\u2713 Script injection wrapped");
5633
7827
  }
5634
7828
  wrapBrowserCommands(browser) {
5635
7829
  if (this.proxiedBrowsers.has(browser)) {
@@ -5662,8 +7856,9 @@ var BrowserProxy = class {
5662
7856
  };
5663
7857
  wrappedMethods.push(methodName);
5664
7858
  });
7859
+ this.wrapAssertionNamespaces(browser);
5665
7860
  this.proxiedBrowsers.add(browser);
5666
- log8.info(`\u2713 Wrapped ${wrappedMethods.length} browser methods`);
7861
+ log10.info(`\u2713 Wrapped ${wrappedMethods.length} browser methods`);
5667
7862
  }
5668
7863
  handleRetryReplacement(browser, methodName, logArgs, serializedResult, effectiveUid, callSource, commandTimestamp) {
5669
7864
  const { entry, oldTimestamp } = this.sessionCapturer.replaceCommand(
@@ -5692,13 +7887,13 @@ var BrowserProxy = class {
5692
7887
  callSource,
5693
7888
  commandTimestamp
5694
7889
  ).catch(
5695
- (err) => log8.error(`Failed to capture ${methodName}: ${err.message}`)
7890
+ (err) => log10.error(`Failed to capture ${methodName}: ${err.message}`)
5696
7891
  );
5697
7892
  const lastCommand = this.sessionCapturer.commandsLog[this.sessionCapturer.commandsLog.length - 1];
5698
7893
  if (lastCommand) {
5699
7894
  this.retryTracker.setLastId(lastCommand._id ?? null);
5700
7895
  this.sessionCapturer.sendCommand(lastCommand);
5701
- log8.info(`[command] ${methodName}`);
7896
+ log10.info(`[command] ${methodName}`);
5702
7897
  this.attachScreenshot(browser, lastCommand, methodName);
5703
7898
  }
5704
7899
  this.maybeRepollMutations(browser, methodName);
@@ -5735,7 +7930,7 @@ var BrowserProxy = class {
5735
7930
  }
5736
7931
  // Result-capturing callback factory — called by Nightwatch's async queue
5737
7932
  // when the command completes. This is where we get the *actual* result.
5738
- makeCaptureCallback(browser, methodName, logArgs, cmdSig, callSource, commandTimestamp, testUid, userCallback) {
7933
+ makeCaptureCallback(browser, methodName, logArgs, cmdSig, callSource, hasUserSource, commandTimestamp, testUid, userCallback) {
5739
7934
  return (callbackResult) => {
5740
7935
  this.popCommandStackIfMatches(methodName, cmdSig);
5741
7936
  const serializedResult = serializeCommandResult(
@@ -5743,7 +7938,7 @@ var BrowserProxy = class {
5743
7938
  methodName
5744
7939
  );
5745
7940
  const effectiveUid = this.getCurrentTest()?.uid ?? testUid;
5746
- if (effectiveUid) {
7941
+ if (effectiveUid && hasUserSource) {
5747
7942
  if (this.retryTracker.isRetry(cmdSig)) {
5748
7943
  this.handleRetryReplacement(
5749
7944
  browser,
@@ -5811,6 +8006,7 @@ var BrowserProxy = class {
5811
8006
  logArgs,
5812
8007
  cmdSig,
5813
8008
  callSource,
8009
+ callInfo.filePath !== void 0,
5814
8010
  commandTimestamp,
5815
8011
  this.getCurrentTest()?.uid,
5816
8012
  userCallback
@@ -5830,7 +8026,7 @@ var BrowserProxy = class {
5830
8026
  return;
5831
8027
  }
5832
8028
  const normalizedError = toError(error);
5833
- log8.error(`[command error] ${methodName}: ${normalizedError.message}`);
8029
+ log10.error(`[command error] ${methodName}: ${normalizedError.message}`);
5834
8030
  this.sessionCapturer.captureCommand(
5835
8031
  methodName,
5836
8032
  args,
@@ -5839,7 +8035,7 @@ var BrowserProxy = class {
5839
8035
  currentTest.uid,
5840
8036
  callSource
5841
8037
  ).catch(
5842
- (err) => log8.error(`Failed to capture ${methodName}: ${err.message}`)
8038
+ (err) => log10.error(`Failed to capture ${methodName}: ${err.message}`)
5843
8039
  );
5844
8040
  const lastCommand = this.sessionCapturer.commandsLog[this.sessionCapturer.commandsLog.length - 1];
5845
8041
  if (lastCommand) {
@@ -5862,7 +8058,7 @@ var BrowserProxy = class {
5862
8058
  if (screenshot) {
5863
8059
  entry.screenshot = screenshot;
5864
8060
  this.sessionCapturer.sendReplaceCommand(ts, entry);
5865
- log8.info(`[screenshot] Attached to ${methodName}${suffix}`);
8061
+ log10.info(`[screenshot] Attached to ${methodName}${suffix}`);
5866
8062
  }
5867
8063
  }).catch(() => {
5868
8064
  });
@@ -5870,8 +8066,8 @@ var BrowserProxy = class {
5870
8066
  };
5871
8067
 
5872
8068
  // src/screencast.ts
5873
- import logger9 from "@wdio/logger";
5874
- var log9 = logger9("@wdio/nightwatch-devtools:ScreencastRecorder");
8069
+ import logger11 from "@wdio/logger";
8070
+ var log11 = logger11("@wdio/nightwatch-devtools:ScreencastRecorder");
5875
8071
  var ScreencastRecorder = class extends ScreencastRecorderBase {
5876
8072
  #sessionCapturer;
5877
8073
  constructor(sessionCapturer, options) {
@@ -5879,15 +8075,15 @@ var ScreencastRecorder = class extends ScreencastRecorderBase {
5879
8075
  this.#sessionCapturer = sessionCapturer;
5880
8076
  }
5881
8077
  onPollingStarted(intervalMs) {
5882
- log9.info(
8078
+ log11.info(
5883
8079
  `\u2713 Screencast recording started (polling mode, ${intervalMs} ms interval)`
5884
8080
  );
5885
8081
  }
5886
8082
  onPollingStopped(frameCount) {
5887
- log9.info(`\u2713 Screencast stopped \u2014 ${frameCount} frame(s) collected`);
8083
+ log11.info(`\u2713 Screencast stopped \u2014 ${frameCount} frame(s) collected`);
5888
8084
  }
5889
8085
  onUnavailable(err) {
5890
- log9.warn(
8086
+ log11.warn(
5891
8087
  `Screencast unavailable (${errorMessage(err)}). Recording skipped.`
5892
8088
  );
5893
8089
  }
@@ -5901,9 +8097,9 @@ var ScreencastRecorder = class extends ScreencastRecorderBase {
5901
8097
  };
5902
8098
 
5903
8099
  // src/session-init.ts
5904
- var log10 = logger10("@wdio/nightwatch-devtools:session-init");
8100
+ var log12 = logger12("@wdio/nightwatch-devtools:session-init");
5905
8101
  async function handleSessionChange(ctx, finalizeCurrent) {
5906
- log10.info("Browser session changed \u2014 reconnecting WebSocket only");
8102
+ log12.info("Browser session changed \u2014 reconnecting WebSocket only");
5907
8103
  ctx.isScriptInjected = false;
5908
8104
  ctx.bidiAttachAttempted = false;
5909
8105
  await finalizeCurrent();
@@ -5911,17 +8107,24 @@ async function handleSessionChange(ctx, finalizeCurrent) {
5911
8107
  ctx.sessionCapturer = null;
5912
8108
  }
5913
8109
  function initReporterChain(ctx) {
5914
- ctx.testReporter = new TestReporter((suitesData) => {
5915
- if (ctx.sessionCapturer) {
5916
- ctx.sessionCapturer.sendUpstream("suites", suitesData);
5917
- }
5918
- });
5919
- ctx.testManager = new TestManager(ctx.testReporter);
8110
+ ctx.testReporter = new TestReporter(
8111
+ (suitesData) => {
8112
+ if (ctx.sessionCapturer) {
8113
+ ctx.sessionCapturer.sendUpstream("suites", suitesData);
8114
+ }
8115
+ },
8116
+ (uid) => ctx.attemptFor(uid)
8117
+ );
8118
+ ctx.testManager = new TestManager(
8119
+ ctx.testReporter,
8120
+ (uid, state) => ctx.recordOutcome(uid, state)
8121
+ );
5920
8122
  ctx.suiteManager = new SuiteManager(ctx.testReporter);
5921
8123
  ctx.browserProxy = new BrowserProxy(
5922
8124
  ctx.sessionCapturer,
5923
8125
  ctx.testManager,
5924
- () => ctx.getCurrentTest() ?? ctx.getCurrentScenarioSuite()
8126
+ () => ctx.getCurrentTest() ?? ctx.getCurrentScenarioSuite(),
8127
+ ctx.captureAssertions
5925
8128
  );
5926
8129
  }
5927
8130
  function rebindReporterToNewSession(ctx) {
@@ -5956,12 +8159,12 @@ function broadcastSessionMetadata(ctx, browser) {
5956
8159
  ctx.sessionCapturer.sendUpstream("metadata", metadata);
5957
8160
  const browserName = capabilities.browserName || desiredCapabilities.browserName || "unknown";
5958
8161
  const browserVersion = capabilities.browserVersion || capabilities.version || "";
5959
- log10.info(
8162
+ log12.info(
5960
8163
  `\u2713 Browser: ${browserName}${browserVersion ? " " + browserVersion : ""} (session: ${sessionId})`
5961
8164
  );
5962
8165
  const loggingPrefs = capabilities["goog:loggingPrefs"] || desiredCapabilities["goog:loggingPrefs"] || {};
5963
8166
  if (!loggingPrefs.performance && !ctx.bidiEnabled) {
5964
- log10.warn(
8167
+ log12.warn(
5965
8168
  "\u26A0 Network tab will be empty \u2014 add 'goog:loggingPrefs': { performance: 'ALL' } to your capabilities (or enable bidi:true)"
5966
8169
  );
5967
8170
  }
@@ -5973,17 +8176,17 @@ async function tryAttachBidi(ctx, browser) {
5973
8176
  ctx.bidiAttachAttempted = true;
5974
8177
  const driver = browser.driver;
5975
8178
  if (!driver) {
5976
- log10.warn("bidi:true set but browser.driver unavailable \u2014 skipping");
8179
+ log12.warn("bidi:true set but browser.driver unavailable \u2014 skipping");
5977
8180
  return;
5978
8181
  }
5979
- const { attachBidiHandlers, buildBidiSinks } = await import("./bidi-FIHBBGT4.js");
8182
+ const { attachBidiHandlers, buildBidiSinks } = await import("./bidi-5ALZJJNC.js");
5980
8183
  const ok = await attachBidiHandlers(
5981
8184
  driver,
5982
8185
  buildBidiSinks(ctx.sessionCapturer)
5983
8186
  );
5984
8187
  if (ok) {
5985
8188
  ctx.sessionCapturer.bidiActive = true;
5986
- log10.info("\u2713 BiDi attached \u2014 perf-log network capture disabled");
8189
+ log12.info("\u2713 BiDi attached \u2014 perf-log network capture disabled");
5987
8190
  }
5988
8191
  }
5989
8192
  async function tryStartScreencast(ctx, browser, sessionId) {
@@ -5995,7 +8198,7 @@ async function tryStartScreencast(ctx, browser, sessionId) {
5995
8198
  ctx.screencastOptions
5996
8199
  );
5997
8200
  ctx.screencastSessionId = sessionId;
5998
- log10.info(`\u{1F3AC} Starting screencast for session ${sessionId}`);
8201
+ log12.info(`\u{1F3AC} Starting screencast for session ${sessionId}`);
5999
8202
  await ctx.screencastRecorder.start(browser);
6000
8203
  }
6001
8204
  async function ensureSessionInitialized(ctx, browser, finalizeCurrentScreencast2) {
@@ -6025,7 +8228,7 @@ async function ensureSessionInitialized(ctx, browser, finalizeCurrentScreencast2
6025
8228
  if (ctx.mode !== "trace") {
6026
8229
  const connected = await ctx.sessionCapturer.waitForConnection(3e3);
6027
8230
  if (!connected) {
6028
- log10.error("\u274C Worker WebSocket failed to connect!");
8231
+ log12.error("\u274C Worker WebSocket failed to connect!");
6029
8232
  }
6030
8233
  }
6031
8234
  if (!ctx.testReporter) {
@@ -6041,24 +8244,28 @@ async function finalizeCurrentScreencast(ctx) {
6041
8244
  if (!ctx.screencastRecorder || !ctx.screencastSessionId) {
6042
8245
  return;
6043
8246
  }
6044
- await finalizeScreencast({
6045
- recorder: ctx.screencastRecorder,
6046
- sessionId: ctx.screencastSessionId,
6047
- filenamePrefix: "nightwatch-video",
6048
- outputDir: resolveAdapterOutputDir({
6049
- testFilePath: ctx.browserProxy?.getCurrentTestFullPath?.() ?? void 0,
6050
- configPath: ctx.configPath
6051
- }),
6052
- captureFormat: ctx.screencastOptions.captureFormat,
6053
- sendUpstream: (scope, data) => ctx.sessionCapturer?.sendUpstream(scope, data),
6054
- onLog: (level, message) => log10[level](message)
6055
- });
8247
+ if (ctx.mode === "trace") {
8248
+ await ctx.screencastRecorder.stop();
8249
+ } else {
8250
+ await finalizeScreencast({
8251
+ recorder: ctx.screencastRecorder,
8252
+ sessionId: ctx.screencastSessionId,
8253
+ filenamePrefix: "nightwatch-video",
8254
+ outputDir: resolveAdapterOutputDir({
8255
+ testFilePath: ctx.browserProxy?.getCurrentTestFullPath?.() ?? void 0,
8256
+ configPath: ctx.configPath
8257
+ }),
8258
+ captureFormat: ctx.screencastOptions.captureFormat,
8259
+ sendUpstream: (scope, data) => ctx.sessionCapturer?.sendUpstream(scope, data),
8260
+ onLog: (level, message) => log12[level](message)
8261
+ });
8262
+ }
6056
8263
  ctx.screencastRecorder = void 0;
6057
8264
  ctx.screencastSessionId = void 0;
6058
8265
  }
6059
8266
 
6060
8267
  // src/index.ts
6061
- var log11 = logger11("@wdio/nightwatch-devtools");
8268
+ var log13 = logger13("@wdio/nightwatch-devtools");
6062
8269
  var NightwatchDevToolsPlugin = class {
6063
8270
  options;
6064
8271
  sessionCapturer;
@@ -6083,30 +8290,60 @@ var NightwatchDevToolsPlugin = class {
6083
8290
  #specRanges = [];
6084
8291
  /** Set of spec files already flushed to disk. */
6085
8292
  #flushedSpecs = /* @__PURE__ */ new Set();
8293
+ /** Every trace/video artifact seen this run (retained or not), for the
8294
+ * end-of-run artifacts manifest. Populated via the context's onArtifact. */
8295
+ #artifacts = [];
8296
+ /** In-flight fire-and-forget eager/boundary slice flushes, awaited at finalize
8297
+ * so the last slice's write + its manifest entry land before teardown. */
8298
+ #traceFlushes = [];
6086
8299
  #getRerunLabel() {
6087
8300
  return process.env[REUSE_ENV.RERUN_ENTRY_TYPE] === "test" ? process.env[REUSE_ENV.RERUN_LABEL]?.trim() : void 0;
6088
8301
  }
6089
8302
  #screencastOptions;
6090
8303
  #screencastRecorder;
6091
8304
  #screencastSessionId;
8305
+ // Snapshotted before each recorder is nulled, so the export isn't blank.
8306
+ #filmstripFrames = [];
6092
8307
  #bidiEnabled = false;
6093
8308
  #bidiAttachAttempted = false;
8309
+ /** Wall-clock ms at the current test/scenario start — the lower bound of that
8310
+ * test's action-snapshot and video-frame window (per-test artifact slicing). */
8311
+ #currentTestStartWallTime = 0;
8312
+ // Nightwatch `--retries` and cross-worker reruns may reset this in-process
8313
+ // tracker; only retries that re-enter this process's start hook are counted.
8314
+ #attemptTracker = new TestAttemptTracker();
6094
8315
  constructor(options = {}) {
6095
8316
  const mode = options.mode ?? "live";
6096
- const ignore = mode === "trace" && options.screencast?.enabled === true;
8317
+ const wantRecorder = mode === "trace" && (options.filmstrip === true || options.video !== void 0 && options.video !== "off");
8318
+ const ignore = mode === "trace" && !wantRecorder && options.screencast?.enabled === true;
6097
8319
  if (ignore) {
6098
- log11.warn("trace mode: ignoring screencast option (live-mode feature)");
8320
+ log13.warn("trace mode: ignoring screencast option (live-mode feature)");
8321
+ }
8322
+ let screencast = ignore ? {} : options.screencast ?? {};
8323
+ if (wantRecorder) {
8324
+ screencast = { ...options.screencast ?? {}, enabled: true };
6099
8325
  }
6100
- const screencast = ignore ? {} : options.screencast ?? {};
6101
8326
  this.options = {
6102
8327
  port: options.port ?? 3e3,
6103
8328
  hostname: options.hostname ?? "localhost",
6104
8329
  screencast,
6105
8330
  bidi: options.bidi ?? false,
8331
+ captureAssertions: options.captureAssertions ?? true,
6106
8332
  mode,
6107
8333
  traceFormat: options.traceFormat ?? "zip",
6108
- traceGranularity: options.traceGranularity ?? "session"
8334
+ traceGranularity: options.traceGranularity ?? "session",
8335
+ tracePolicy: options.tracePolicy ?? "on",
8336
+ filmstrip: options.filmstrip ?? true,
8337
+ screenshot: options.screenshot ?? "off",
8338
+ video: options.video ?? "off",
8339
+ // No live Allure signal in Nightwatch (produce-only), so no auto-detect:
8340
+ // off by default, opt-in via the option.
8341
+ emitArtifactsManifest: options.emitArtifactsManifest ?? false
6109
8342
  };
8343
+ const policyWarning = tracePolicyModeWarning(options.tracePolicy, mode);
8344
+ if (policyWarning) {
8345
+ log13.warn(policyWarning);
8346
+ }
6110
8347
  this.#screencastOptions = { ...SCREENCAST_DEFAULTS, ...screencast };
6111
8348
  this.#bidiEnabled = options.bidi === true;
6112
8349
  }
@@ -6140,6 +8377,9 @@ var NightwatchDevToolsPlugin = class {
6140
8377
  get bidiEnabled() {
6141
8378
  return self.#bidiEnabled;
6142
8379
  },
8380
+ get captureAssertions() {
8381
+ return self.options.captureAssertions;
8382
+ },
6143
8383
  get sessionCapturer() {
6144
8384
  return self.sessionCapturer;
6145
8385
  },
@@ -6254,7 +8494,11 @@ var NightwatchDevToolsPlugin = class {
6254
8494
  clearExecutionData: () => {
6255
8495
  self.testReporter.clearExecutionData();
6256
8496
  self.suiteManager.clearExecutionData();
8497
+ self.#attemptTracker.reset();
6257
8498
  },
8499
+ recordAttempt: (uid, specFile) => self.#attemptTracker.recordStart(uid, specFile),
8500
+ recordOutcome: (uid, state) => self.#attemptTracker.recordOutcome(uid, state),
8501
+ attemptFor: (uid) => self.#attemptTracker.attemptFor(uid),
6258
8502
  buildMetadataOptions: () => self.#buildMetadataOptions(),
6259
8503
  ensureSessionInitialized: (b) => self.#ensureSessionInitialized(b),
6260
8504
  wrapBrowserOnce: (b) => self.#wrapBrowserOnce(b),
@@ -6263,10 +8507,28 @@ var NightwatchDevToolsPlugin = class {
6263
8507
  setCucumberRunner: (v) => {
6264
8508
  self.#isCucumberRunner = v;
6265
8509
  },
6266
- getRerunLabel: () => self.#getRerunLabel()
8510
+ getRerunLabel: () => self.#getRerunLabel(),
8511
+ get traceMode() {
8512
+ return self.options.mode === "trace";
8513
+ },
8514
+ get traceGranularity() {
8515
+ return self.options.traceGranularity;
8516
+ },
8517
+ get specRanges() {
8518
+ return self.#specRanges;
8519
+ },
8520
+ get flushedSpecs() {
8521
+ return self.#flushedSpecs;
8522
+ },
8523
+ flushTraceRange: (range) => self.#flushSpecTrace(range),
8524
+ emitTestArtifacts: (uid, failed) => self.#emitTestArtifacts(uid, failed)
6267
8525
  };
6268
8526
  return this.#internals;
6269
8527
  }
8528
+ /** Boundary cast: currentTest is Nightwatch's loose bag; only uid is read. */
8529
+ #currentTestUid() {
8530
+ return this.#currentTest?.uid;
8531
+ }
6270
8532
  #handleReuseMode() {
6271
8533
  handleReuseMode(this.#getInternals());
6272
8534
  }
@@ -6282,6 +8544,12 @@ var NightwatchDevToolsPlugin = class {
6282
8544
  internals.handleReuse = () => this.#handleReuseMode();
6283
8545
  internals.plugin = this;
6284
8546
  await runPluginBefore(internals);
8547
+ if (this.options.captureAssertions) {
8548
+ wireAssertCapture(
8549
+ () => this.sessionCapturer,
8550
+ () => this.#currentTestUid()
8551
+ );
8552
+ }
6285
8553
  }
6286
8554
  async #ensureSessionInitialized(browser) {
6287
8555
  await ensureSessionInitialized(
@@ -6291,9 +8559,13 @@ var NightwatchDevToolsPlugin = class {
6291
8559
  );
6292
8560
  }
6293
8561
  async #finalizeCurrentScreencast() {
8562
+ if ((this.options.filmstrip || this.options.video !== "off") && this.#screencastRecorder) {
8563
+ this.#filmstripFrames.push(...this.#screencastRecorder.frames);
8564
+ }
6294
8565
  await finalizeCurrentScreencast(this.#getInternals());
6295
8566
  }
6296
8567
  async cucumberBefore(browser, pickle) {
8568
+ this.#currentTestStartWallTime = Date.now();
6297
8569
  await cucumberBefore(this.#getInternals(), browser, pickle);
6298
8570
  }
6299
8571
  async cucumberAfter(browser, result, pickle) {
@@ -6322,12 +8594,13 @@ var NightwatchDevToolsPlugin = class {
6322
8594
  #pickCurrentTestName(currentTest, testNames, processedTests) {
6323
8595
  return pickCurrentTestName(currentTest, testNames, processedTests);
6324
8596
  }
6325
- async #startNextTest(currentSuite, currentTestName, processedTests) {
8597
+ async #startNextTest(currentSuite, currentTestName, processedTests, specFile) {
6326
8598
  await startNextTest(
6327
8599
  this.#getInternals(),
6328
8600
  currentSuite,
6329
8601
  currentTestName,
6330
- processedTests
8602
+ processedTests,
8603
+ specFile
6331
8604
  );
6332
8605
  }
6333
8606
  async #closePreviousRunningTest(currentSuite, testFile, currentTest) {
@@ -6345,6 +8618,7 @@ var NightwatchDevToolsPlugin = class {
6345
8618
  if (this.#isCucumberRunner) {
6346
8619
  return;
6347
8620
  }
8621
+ this.#currentTestStartWallTime = Date.now();
6348
8622
  await this.#ensureSessionInitialized(browser);
6349
8623
  const currentTest = browser.currentTest;
6350
8624
  if (!currentTest) {
@@ -6364,23 +8638,7 @@ var NightwatchDevToolsPlugin = class {
6364
8638
  });
6365
8639
  }
6366
8640
  if (fullPath) {
6367
- const prevRange = recordSpecBoundary(
6368
- {
6369
- specRanges: this.#specRanges,
6370
- flushedSpecs: this.#flushedSpecs,
6371
- capturer: this.sessionCapturer,
6372
- actionSnapshots: this.sessionCapturer.actionSnapshots
6373
- },
6374
- fullPath,
6375
- this.options.traceGranularity
6376
- );
6377
- if (prevRange) {
6378
- void this.#flushSpecTrace(prevRange).catch(
6379
- (err) => log11.warn(
6380
- `Failed to flush trace for spec "${prevRange.specFile}": ${errorMessage(err)}`
6381
- )
6382
- );
6383
- }
8641
+ recordSpecSliceBoundary(this.#getInternals(), fullPath);
6384
8642
  }
6385
8643
  await this.#closePreviousRunningTest(currentSuite, testFile, currentTest);
6386
8644
  const processedTests = this.testManager.getProcessedTests(testFile);
@@ -6390,7 +8648,12 @@ var NightwatchDevToolsPlugin = class {
6390
8648
  processedTests
6391
8649
  );
6392
8650
  if (currentTestName) {
6393
- await this.#startNextTest(currentSuite, currentTestName, processedTests);
8651
+ await this.#startNextTest(
8652
+ currentSuite,
8653
+ currentTestName,
8654
+ processedTests,
8655
+ fullPath
8656
+ );
6394
8657
  }
6395
8658
  this.#wrapBrowserOnce(browser);
6396
8659
  }
@@ -6401,36 +8664,81 @@ var NightwatchDevToolsPlugin = class {
6401
8664
  if (browser && this.sessionCapturer) {
6402
8665
  try {
6403
8666
  await this.#closeOutTestcases(browser);
8667
+ if (this.options.captureAssertions) {
8668
+ await captureNativeAssertions(
8669
+ this.sessionCapturer,
8670
+ browser,
8671
+ browser.currentTest,
8672
+ this.#currentTestUid(),
8673
+ this.browserProxy.drainNativeAssertCalls()
8674
+ );
8675
+ }
6404
8676
  await this.sessionCapturer.captureTrace(browser);
8677
+ flushTestSlice(this.#getInternals());
8678
+ const results = browser.currentTest?.results;
8679
+ const failed = !!results && ((results.errors ?? 0) > 0 || (results.failed ?? 0) > 0);
8680
+ await this.#emitTestArtifacts(this.#currentTestUid(), failed);
6405
8681
  } catch (err) {
6406
- log11.error(`Failed to capture trace: ${errorMessage(err)}`);
8682
+ log13.error(`Failed to capture trace: ${errorMessage(err)}`);
6407
8683
  }
6408
8684
  }
6409
8685
  }
6410
8686
  async #closeOutTestcases(browser) {
6411
8687
  await closeOutTestcases(this.#getInternals(), browser);
6412
8688
  }
8689
+ /** Directory for produced artifacts — next to the test file, else the config,
8690
+ * else cwd (mirrors #traceContext's resolution). */
8691
+ get #outputDir() {
8692
+ return resolveAdapterOutputDir({
8693
+ testFilePath: this.browserProxy?.getCurrentTestFullPath?.() ?? void 0,
8694
+ configPath: this.#configPath
8695
+ });
8696
+ }
8697
+ /** Produce (never attach — no live Allure API) this test's per-test screenshot
8698
+ * and video slice. No-op outside trace mode + `test` granularity (core-gated).
8699
+ * Shared by the per-test `afterEach` and the cucumber per-scenario finalize. */
8700
+ async #emitTestArtifacts(uid, failed) {
8701
+ const attempt = uid ? this.#attemptTracker.attemptFor(uid) : void 0;
8702
+ await emitTestArtifacts({
8703
+ mode: this.options.mode,
8704
+ granularity: this.options.traceGranularity,
8705
+ screenshotPolicy: this.options.screenshot,
8706
+ videoPolicy: this.options.video,
8707
+ failed,
8708
+ actionSnapshots: this.sessionCapturer.actionSnapshots,
8709
+ frames: this.#screencastRecorder?.frames ?? this.#filmstripFrames,
8710
+ startWallTime: this.#currentTestStartWallTime,
8711
+ outcomes: uid ? this.#attemptTracker.forTest(uid, attempt) : [],
8712
+ uid,
8713
+ attempt,
8714
+ sessionId: this.sessionCapturer?.metadata?.sessionId,
8715
+ outputDir: this.#outputDir,
8716
+ captureFormat: this.#screencastOptions.captureFormat,
8717
+ onArtifact: (a) => this.#artifacts.push(a),
8718
+ onLog: (level, msg) => log13[level](msg)
8719
+ });
8720
+ }
6413
8721
  async after(browser) {
6414
8722
  await this.#finalizeCurrentScreencast();
6415
8723
  try {
6416
8724
  await this.#finalizeAllSuites(browser);
6417
8725
  this.#logRunSummary();
6418
8726
  if (this.options.mode === "trace") {
6419
- await this.#writeTraceZipIfNeeded();
8727
+ await this.#writeTraceIfNeeded();
6420
8728
  await this.sessionCapturer?.closeWebSocket();
6421
8729
  await stopBackend();
6422
8730
  return;
6423
8731
  }
6424
8732
  if (!this.#devtoolsBrowser) {
6425
8733
  this.testReporter?.updateSuites();
6426
- log11.info("\u267B Rerun complete \u2014 flushing WebSocket...");
8734
+ log13.info("\u267B Rerun complete \u2014 flushing WebSocket...");
6427
8735
  await this.sessionCapturer?.closeWebSocket();
6428
8736
  return;
6429
8737
  }
6430
- log11.info("\u{1F4A1} Please close the DevTools browser window to finish...");
8738
+ log13.info("\u{1F4A1} Please close the DevTools browser window to finish...");
6431
8739
  await this.#waitForDevtoolsBrowserClose();
6432
8740
  } catch (err) {
6433
- log11.error(`Failed to stop backend: ${errorMessage(err)}`);
8741
+ log13.error(`Failed to stop backend: ${errorMessage(err)}`);
6434
8742
  }
6435
8743
  }
6436
8744
  async #finalizeAllSuites(browser) {
@@ -6439,90 +8747,56 @@ var NightwatchDevToolsPlugin = class {
6439
8747
  #logRunSummary() {
6440
8748
  logRunSummary(this.#getInternals());
6441
8749
  }
6442
- async #flushSpecTrace(range, nextRange) {
6443
- if (this.#flushedSpecs.has(range.specFile)) {
6444
- return void 0;
6445
- }
6446
- this.#flushedSpecs.add(range.specFile);
8750
+ /** Thin wrapper so boundary flushes and the final flush share one path.
8751
+ * flushRangeLogged logs+swallows a failed flush (shared spec/test string) so
8752
+ * the fire-and-forget boundary callers don't each re-implement the guard. */
8753
+ #flushSpecTrace(range) {
6447
8754
  const sessionId = this.sessionCapturer.metadata?.sessionId;
6448
8755
  if (!sessionId) {
6449
- return void 0;
6450
- }
6451
- const allMetadata = /* @__PURE__ */ new Map();
6452
- if (this.suiteManager) {
6453
- for (const suite of this.suiteManager.getAllSuites().values()) {
6454
- for (const entry of suite.tests) {
6455
- if (typeof entry === "string") {
6456
- continue;
6457
- }
6458
- allMetadata.set(entry.uid, {
6459
- title: entry.fullTitle,
6460
- specFile: entry.file
6461
- });
6462
- }
6463
- }
8756
+ return Promise.resolve(void 0);
6464
8757
  }
6465
- const tracePath = await writeSpecTrace({
6466
- range,
6467
- nextRange,
6468
- capturer: this.sessionCapturer,
6469
- actionSnapshots: this.sessionCapturer.actionSnapshots,
6470
- sessionId,
6471
- outputDir: resolveAdapterOutputDir({ configPath: this.#configPath }),
6472
- format: this.options.traceFormat,
6473
- testMetadata: allMetadata
6474
- });
6475
- log11.info(`Trace for spec "${range.specFile}" saved to ${tracePath}`);
6476
- return tracePath;
8758
+ const flush = flushRangeLogged(this.#traceContext(sessionId), range);
8759
+ this.#traceFlushes.push(flush);
8760
+ return flush;
6477
8761
  }
6478
- async #writeTraceZipIfNeeded() {
6479
- if (this.options.mode !== "trace" || !this.sessionCapturer) {
6480
- return;
6481
- }
6482
- const sessionId = this.sessionCapturer.metadata?.sessionId;
6483
- if (!sessionId) {
6484
- return;
6485
- }
6486
- try {
6487
- if (this.sessionCapturer.snapshotCaptures.length) {
6488
- await Promise.allSettled(this.sessionCapturer.snapshotCaptures);
6489
- }
6490
- if (this.options.traceGranularity === "spec") {
6491
- for (const range of this.#specRanges) {
6492
- if (!this.#flushedSpecs.has(range.specFile)) {
6493
- await this.#flushSpecTrace(range);
6494
- }
6495
- }
6496
- return;
6497
- }
6498
- const testMetadata = /* @__PURE__ */ new Map();
6499
- if (this.suiteManager) {
6500
- for (const suite of this.suiteManager.getAllSuites().values()) {
6501
- for (const entry of suite.tests) {
6502
- if (typeof entry === "string") {
6503
- continue;
6504
- }
6505
- testMetadata.set(entry.uid, {
6506
- title: entry.fullTitle,
6507
- specFile: entry.file
6508
- });
6509
- }
6510
- }
6511
- }
6512
- const snapshots = this.sessionCapturer.actionSnapshots;
6513
- const tracePath = await writeTraceZip(this.sessionCapturer, {
6514
- outputDir: resolveAdapterOutputDir({
6515
- configPath: this.#configPath
6516
- }),
6517
- sessionId,
6518
- actionSnapshots: snapshots.length ? snapshots : void 0,
8762
+ /** Assemble the framework-agnostic trace-export context from plugin state.
8763
+ * Output dir ignores the spec range — nightwatch writes next to config. */
8764
+ #traceContext(sessionId) {
8765
+ return buildTraceContext(
8766
+ {
8767
+ mode: this.options.mode,
8768
+ policy: this.options.tracePolicy,
8769
+ granularity: this.options.traceGranularity,
6519
8770
  format: this.options.traceFormat,
6520
- testMetadata
6521
- });
6522
- log11.info(`Trace saved to ${tracePath}`);
6523
- } catch (err) {
6524
- log11.warn(`trace write failed: ${errorMessage(err)}`);
8771
+ capturer: this.sessionCapturer,
8772
+ suites: this.suiteManager.getAllSuites().values(),
8773
+ outcomes: this.#attemptTracker,
8774
+ ranges: this.#specRanges,
8775
+ flushed: this.#flushedSpecs,
8776
+ artifacts: this.#artifacts,
8777
+ traceFlushes: this.#traceFlushes,
8778
+ emitArtifactsManifest: this.options.emitArtifactsManifest,
8779
+ // Accumulated (finalized-session) frames plus the live recorder's — a
8780
+ // mid-run per-spec/per-test flush fires before the recorder is drained
8781
+ // into #filmstripFrames, so its frames live only on the recorder still;
8782
+ // at the final write the recorder is already nulled, so no double-count.
8783
+ screencastFrames: this.options.filmstrip ? [
8784
+ ...this.#filmstripFrames,
8785
+ ...this.#screencastRecorder?.frames ?? []
8786
+ ] : void 0,
8787
+ configPath: this.#configPath,
8788
+ testFilePath: this.browserProxy?.getCurrentTestFullPath?.() ?? void 0,
8789
+ log: (level, msg) => log13[level](msg)
8790
+ },
8791
+ sessionId
8792
+ );
8793
+ }
8794
+ async #writeTraceIfNeeded() {
8795
+ const sessionId = this.sessionCapturer?.metadata?.sessionId;
8796
+ if (this.options.mode !== "trace" || !this.sessionCapturer || !sessionId) {
8797
+ return;
6525
8798
  }
8799
+ await finalizeTraceExport(this.#traceContext(sessionId));
6526
8800
  }
6527
8801
  async #waitForDevtoolsBrowserClose() {
6528
8802
  await waitForDevtoolsBrowserClose(this.#getInternals());