ccqa 1.57.1 → 1.57.3

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/bin/ccqa.mjs CHANGED
@@ -8154,19 +8154,19 @@ function aliasCandidates(specifier, tsconfig) {
8154
8154
  }
8155
8155
  /** A path → the file it names: itself, `<path><ext>`, or `<path>/index<ext>`. */
8156
8156
  async function resolveFile(pathAbs) {
8157
- if (await isFile(pathAbs)) return pathAbs;
8157
+ if (await isFile$1(pathAbs)) return pathAbs;
8158
8158
  const rewritten = pathAbs.replace(/\.(js|mjs|cjs)$/, "");
8159
8159
  if (rewritten !== pathAbs) {
8160
- for (const ext of EXTENSIONS) if (await isFile(rewritten + ext)) return rewritten + ext;
8160
+ for (const ext of EXTENSIONS) if (await isFile$1(rewritten + ext)) return rewritten + ext;
8161
8161
  }
8162
- for (const ext of EXTENSIONS) if (await isFile(pathAbs + ext)) return pathAbs + ext;
8162
+ for (const ext of EXTENSIONS) if (await isFile$1(pathAbs + ext)) return pathAbs + ext;
8163
8163
  for (const ext of EXTENSIONS) {
8164
8164
  const indexed = join(pathAbs, `index${ext}`);
8165
- if (await isFile(indexed)) return indexed;
8165
+ if (await isFile$1(indexed)) return indexed;
8166
8166
  }
8167
8167
  return null;
8168
8168
  }
8169
- function isFile(pathAbs) {
8169
+ function isFile$1(pathAbs) {
8170
8170
  return stat(pathAbs).then((s) => s.isFile(), () => false);
8171
8171
  }
8172
8172
  /** `export const NAME` / `export function NAME` / `export class NAME`, and `export { A, B }`. */
@@ -9338,8 +9338,8 @@ function mergeFindings(undecided, fromModel) {
9338
9338
  *
9339
9339
  * Several producers write the `<id>.png` + `<id>.json` pairs —
9340
9340
  * `abStepEvidence()` / `captureFailureEvidence()` in `test-helpers.ts`
9341
- * (agent-browser replays), `targets/playwright/trace-capture.ts` (frames read
9342
- * out of a run's Playwright trace), and `ccqaStepBefore`/`ccqaStepAfter` in
9341
+ * (agent-browser replays), `targets/playwright/trace-capture.ts` (snapshots
9342
+ * rendered from a run's Playwright trace), and `ccqaStepBefore`/`ccqaStepAfter` in
9343
9343
  * `step-evidence.ts` for tests generated before the capture calls left the
9344
9344
  * committed file — and one consumer reads them back (`loadEvidenceForSpec` in
9345
9345
  * `report/evidence.ts`). They agree only on the contract here, so it is kept
@@ -13712,10 +13712,10 @@ function emptySpecRow(args) {
13712
13712
  *
13713
13713
  * Producer-agnostic on purpose. Very different writers fill these directories
13714
13714
  * — `abStepEvidence()` during an agent-browser replay, and, for a Playwright
13715
- * target, frames read back out of the run's trace once the command has exited
13715
+ * target, snapshots rendered from the run's trace once the command has exited
13716
13716
  * — and they only have to agree on the file-pair convention documented here,
13717
13717
  * not on how or when the screenshot was taken. `pngFile` names the image
13718
- * whatever its format: a trace's screencast frames are JPEG.
13718
+ * whatever its format: the ones rendered from a trace are JPEG.
13719
13719
  */
13720
13720
  /** `<reportDir>/evidence/<feature>/<spec>` — one directory per spec. */
13721
13721
  function specEvidenceDir(reportDir, feature, spec) {
@@ -13864,20 +13864,21 @@ function readLocalEntryData(buf, offset, method, compressedSize) {
13864
13864
  return null;
13865
13865
  }
13866
13866
  /**
13867
- * The screencast frames and the `test.step` calls one trace file holds.
13867
+ * The calls one trace holds, each runner-side call with the DOM at its two
13868
+ * boundaries: the before-snapshot of the first Playwright call inside it and the
13869
+ * after-snapshot of the last, whatever kind of call (action, wait, assertion).
13870
+ * Nothing the test did not itself wait for is shown — a step ending in a click
13871
+ * shows the DOM right after the click. Calls count at any depth, so a step
13872
+ * nested in another counts toward both.
13868
13873
  *
13869
- * A trace is JSON Lines, one event object per line; a corrupt or truncated
13870
- * line must not sink the whole read, so parsing is best-effort per line.
13871
- * Step events come in two shapes across Playwright versions — a `before`/
13872
- * `after` pair sharing a `callId`, or a single merged `action` event — and
13873
- * both are folded into the same `TraceStep` shape here so callers never see
13874
- * the version skew.
13874
+ * A trace is JSON Lines; a corrupt or truncated line must not sink the whole
13875
+ * read, so parsing is best-effort per line. A browser call points at the
13876
+ * runner call it ran in through `stepId`; the runner calls chain through
13877
+ * `parentId`.
13875
13878
  */
13876
- function parseTraceEvents(jsonl) {
13877
- const frames = [];
13878
- const steps = [];
13879
- const pendingByCallId = /* @__PURE__ */ new Map();
13880
- let maxTimestamp = -Infinity;
13879
+ function parseTraceSteps(jsonl) {
13880
+ const calls = /* @__PURE__ */ new Map();
13881
+ const viewports = /* @__PURE__ */ new Map();
13881
13882
  for (const line of jsonl.split("\n")) {
13882
13883
  if (line.trim() === "") continue;
13883
13884
  let parsed;
@@ -13888,58 +13889,72 @@ function parseTraceEvents(jsonl) {
13888
13889
  }
13889
13890
  if (typeof parsed !== "object" || parsed === null) continue;
13890
13891
  const event = parsed;
13891
- for (const field of [
13892
- "timestamp",
13893
- "startTime",
13894
- "endTime"
13895
- ]) {
13896
- const value = event[field];
13897
- if (typeof value === "number") maxTimestamp = Math.max(maxTimestamp, value);
13898
- }
13899
- const type = event.type;
13900
- if (type === "screencast-frame") {
13901
- if (typeof event.sha1 === "string" && typeof event.timestamp === "number") frames.push({
13902
- sha1: event.sha1,
13903
- timestamp: event.timestamp
13892
+ const callId = event.callId;
13893
+ if (event.type === "frame-snapshot") {
13894
+ const snapshot = event.snapshot;
13895
+ const viewport = snapshot?.viewport;
13896
+ if (typeof snapshot?.snapshotName === "string" && snapshot.isMainFrame !== false && typeof viewport?.width === "number" && typeof viewport.height === "number" && !viewports.has(snapshot.snapshotName)) viewports.set(snapshot.snapshotName, {
13897
+ width: viewport.width,
13898
+ height: viewport.height
13904
13899
  });
13905
- } else if (type === "action") {
13906
- if (typeof event.callId === "string" && typeof event.startTime === "number" && typeof event.endTime === "number") steps.push({
13900
+ } else if ((event.type === "before" || event.type === "action") && typeof callId === "string") {
13901
+ if (typeof event.startTime !== "number") continue;
13902
+ const parent = (typeof event.stepId === "string" && event.stepId !== callId ? event.stepId : void 0) ?? (typeof event.parentId === "string" ? event.parentId : void 0);
13903
+ calls.set(callId, {
13907
13904
  title: titleOf$1(event),
13908
13905
  startTime: event.startTime,
13909
- endTime: event.endTime,
13910
- ...errorOf(event)
13911
- });
13912
- } else if (type === "before") {
13913
- if (typeof event.callId === "string" && typeof event.startTime === "number") pendingByCallId.set(event.callId, {
13914
- title: titleOf$1(event),
13915
- startTime: event.startTime
13906
+ ...parent !== void 0 ? { parent } : {},
13907
+ ...typeof event.pageId === "string" ? { pageId: event.pageId } : {},
13908
+ ...typeof event.beforeSnapshot === "string" ? { beforeSnapshot: event.beforeSnapshot } : {}
13916
13909
  });
13917
- } else if (type === "after") {
13918
- if (typeof event.callId === "string" && typeof event.endTime === "number") {
13919
- const pending = pendingByCallId.get(event.callId);
13920
- if (pending !== void 0) {
13921
- pendingByCallId.delete(event.callId);
13922
- steps.push({
13923
- ...pending,
13924
- endTime: event.endTime,
13925
- ...errorOf(event)
13926
- });
13927
- }
13928
- }
13910
+ }
13911
+ if ((event.type === "after" || event.type === "action") && typeof callId === "string") {
13912
+ const call = calls.get(callId);
13913
+ if (call === void 0) continue;
13914
+ if (typeof event.endTime === "number") call.endTime = event.endTime;
13915
+ if (typeof event.afterSnapshot === "string") call.afterSnapshot = event.afterSnapshot;
13916
+ Object.assign(call, errorOf(event));
13929
13917
  }
13930
13918
  }
13931
- for (const pending of pendingByCallId.values()) steps.push({
13932
- ...pending,
13933
- endTime: maxTimestamp
13934
- });
13935
- frames.sort((a, b) => a.timestamp - b.timestamp);
13936
- steps.sort((a, b) => a.startTime - b.startTime);
13937
- return {
13938
- frames,
13939
- steps
13919
+ const steps = /* @__PURE__ */ new Map();
13920
+ for (const call of calls.values()) {
13921
+ if (call.pageId !== void 0) continue;
13922
+ steps.set(call, {
13923
+ title: call.title,
13924
+ ...call.error !== void 0 ? { error: call.error } : {}
13925
+ });
13926
+ }
13927
+ for (const call of calls.values()) {
13928
+ if (call.pageId === void 0) continue;
13929
+ const seen = /* @__PURE__ */ new Set();
13930
+ for (let id = call.parent; id !== void 0 && !seen.has(id); id = calls.get(id)?.parent) {
13931
+ seen.add(id);
13932
+ const step = steps.get(calls.get(id));
13933
+ if (step === void 0) continue;
13934
+ if (call.beforeSnapshot !== void 0 && (!step.first || call.startTime < step.first.startTime)) step.first = call;
13935
+ if (call.afterSnapshot !== void 0 && call.endTime !== void 0 && (!step.last || call.endTime > step.last.endTime)) step.last = call;
13936
+ }
13937
+ }
13938
+ const ref = (call, name) => {
13939
+ const viewport = viewports.get(name);
13940
+ return {
13941
+ pageId: call.pageId,
13942
+ name,
13943
+ ...viewport ? { viewport } : {}
13944
+ };
13940
13945
  };
13946
+ return [...steps.entries()].sort(([a], [b]) => a.startTime - b.startTime).map(([, { first, last, ...step }]) => ({
13947
+ ...step,
13948
+ ...first ? { before: ref(first, first.beforeSnapshot) } : {},
13949
+ ...last ? { after: ref(last, last.afterSnapshot) } : {}
13950
+ }));
13951
+ }
13952
+ /** The joined `.trace` members of an archive: the runner's steps and the browser's calls are separate files. */
13953
+ async function readTraceJsonl(archive) {
13954
+ const entries = await readZip(archive);
13955
+ return entries.names.filter((name) => name.endsWith(".trace")).map((name) => entries.read(name)?.toString("utf8") ?? "").join("\n");
13941
13956
  }
13942
- /** The step's failure message, in whichever shape the trace recorded it. */
13957
+ /** The call's failure message, in whichever shape the trace recorded it. */
13943
13958
  function errorOf(event) {
13944
13959
  const error = event.error;
13945
13960
  if (typeof error === "string") return { error };
@@ -13954,14 +13969,131 @@ function titleOf$1(event) {
13954
13969
  if (typeof event.title === "string") return event.title;
13955
13970
  return "";
13956
13971
  }
13972
+ //#endregion
13973
+ //#region src/targets/playwright/resolve-playwright.ts
13957
13974
  /**
13958
- * The frame on screen at `time`: the last one captured at or before it, or —
13959
- * when the step began before any frame was captured — the first frame after.
13960
- * Null when the trace holds no frames at all. `frames` comes from
13961
- * {@link parseTraceEvents}, which sorts them by timestamp.
13975
+ * The consumer's Playwright, not a dependency of ccqa's: its browser and its
13976
+ * trace viewer have to match the version the tests run with.
13977
+ *
13978
+ * Under pnpm's isolation `playwright-core` may only be reachable through
13979
+ * `@playwright/test` → `playwright`, so each link is resolved from the one
13980
+ * before, skipping links a project does not have. `dirs` are tried in order —
13981
+ * nearest first, since in a monorepo only the e2e package may have Playwright.
13962
13982
  */
13963
- function frameAt(frames, time) {
13964
- return frames.findLast((f) => f.timestamp <= time) ?? frames[0] ?? null;
13983
+ async function loadPlaywright(dirs) {
13984
+ for (const dir of dirs) {
13985
+ const coreDir = resolveCoreDir(dir);
13986
+ if (coreDir === null) continue;
13987
+ const mod = await import(pathToFileURL(createRequire(join(coreDir, "package.json")).resolve("playwright-core")).href);
13988
+ const chromium = mod.chromium ?? mod.default?.chromium;
13989
+ if (chromium !== void 0) return {
13990
+ coreDir,
13991
+ chromium
13992
+ };
13993
+ }
13994
+ throw new Error(`Playwright is not installed in ${dirs.join(" or ")}`);
13995
+ }
13996
+ function resolveCoreDir(dir) {
13997
+ let from = dir;
13998
+ for (const name of [
13999
+ "@playwright/test",
14000
+ "playwright",
14001
+ "playwright-core"
14002
+ ]) try {
14003
+ from = dirname(createRequire(join(from, "package.json")).resolve(`${name}/package.json`));
14004
+ if (name === "playwright-core") return from;
14005
+ } catch {}
14006
+ return null;
14007
+ }
14008
+ //#endregion
14009
+ //#region src/targets/playwright/trace-snapshot.ts
14010
+ /** A snapshot that never settles must not hold the run's report hostage. */
14011
+ const RENDER_TIMEOUT_MS = 15e3;
14012
+ const LAUNCH_TIMEOUT_MS = 3e4;
14013
+ const TRACE_PATH = "trace.zip";
14014
+ const CONTENT_TYPES = {
14015
+ ".html": "text/html; charset=utf-8",
14016
+ ".js": "text/javascript",
14017
+ ".css": "text/css",
14018
+ ".svg": "image/svg+xml",
14019
+ ".ttf": "font/ttf",
14020
+ ".zip": "application/zip"
14021
+ };
14022
+ /**
14023
+ * A renderer over one trace archive, using the first Playwright `dirs`
14024
+ * resolves. Throws when that Playwright, its viewer, or its Chromium is missing.
14025
+ */
14026
+ async function openSnapshotRenderer(dirs, archive) {
14027
+ const { coreDir, chromium } = await loadPlaywright(dirs);
14028
+ const viewerDir = join(coreDir, "lib", "vite", "traceViewer");
14029
+ if (!isFile(join(viewerDir, "snapshot.html"))) throw new Error(`the installed Playwright has no trace viewer at ${viewerDir}`);
14030
+ const server = await serve(viewerDir, archive);
14031
+ const base = `http://127.0.0.1:${server.address().port}/`;
14032
+ const browser = await chromium.launch({ timeout: LAUNCH_TIMEOUT_MS }).catch(async (err) => {
14033
+ await closeServer(server);
14034
+ throw err;
14035
+ });
14036
+ const close = async () => {
14037
+ try {
14038
+ await browser.close();
14039
+ } finally {
14040
+ await closeServer(server);
14041
+ }
14042
+ };
14043
+ try {
14044
+ const page = await browser.newPage();
14045
+ page.setDefaultTimeout(RENDER_TIMEOUT_MS);
14046
+ await page.goto(`${base}snapshot.html?trace=${TRACE_PATH}`);
14047
+ await page.waitForFunction("!!navigator.serviceWorker.controller");
14048
+ if (await page.evaluate(`fetch("contexts?trace=${TRACE_PATH}").then((r) => r.ok)`) !== true) throw new Error("the trace viewer could not load the trace");
14049
+ return {
14050
+ async render(snapshot) {
14051
+ if (snapshot.viewport) await page.setViewportSize(snapshot.viewport);
14052
+ const url = `${base}snapshot/${snapshot.pageId}?trace=${TRACE_PATH}&name=${encodeURIComponent(snapshot.name)}`;
14053
+ await page.goto(`${base}snapshot.html?trace=${TRACE_PATH}&r=${encodeURIComponent(url)}`);
14054
+ const frame = await (await page.waitForSelector("iframe")).contentFrame();
14055
+ if (frame === null) throw new Error("the trace viewer showed no snapshot frame");
14056
+ await frame.waitForURL(/\/snapshot\//, { waitUntil: "load" });
14057
+ await frame.waitForFunction("document.fonts.status === 'loaded' && Array.from(document.images).every((i) => i.complete)");
14058
+ return await frame.evaluate("!document.body || (document.body.childElementCount === 0 && document.body.textContent.trim() === '')") === false ? page.screenshot({ type: "jpeg" }) : null;
14059
+ },
14060
+ close
14061
+ };
14062
+ } catch (err) {
14063
+ await close().catch(() => {});
14064
+ throw err;
14065
+ }
14066
+ }
14067
+ /** The viewer's files and the one archive, on loopback only. */
14068
+ async function serve(viewerDir, archive) {
14069
+ const root = resolve(viewerDir);
14070
+ const server = createServer((req, res) => {
14071
+ const path = new URL(req.url ?? "/", "http://127.0.0.1").pathname;
14072
+ const file = path === `/${TRACE_PATH}` ? archive : resolve(root, `.${path}`);
14073
+ if (file !== archive && !file.startsWith(root + sep) || !isFile(file)) {
14074
+ res.statusCode = 404;
14075
+ res.end();
14076
+ return;
14077
+ }
14078
+ res.setHeader("Content-Type", CONTENT_TYPES[extname(file)] ?? "application/octet-stream");
14079
+ createReadStream(file).on("error", () => res.destroy()).pipe(res);
14080
+ });
14081
+ await new Promise((done, fail) => {
14082
+ server.once("error", fail);
14083
+ server.listen(0, "127.0.0.1", done);
14084
+ });
14085
+ return server;
14086
+ }
14087
+ function isFile(path) {
14088
+ try {
14089
+ return statSync(path).isFile();
14090
+ } catch {
14091
+ return false;
14092
+ }
14093
+ }
14094
+ function closeServer(server) {
14095
+ server.closeAllConnections();
14096
+ return new Promise((done) => server.close(() => done()));
13965
14097
  }
13966
14098
  //#endregion
13967
14099
  //#region src/targets/playwright/trace-capture.ts
@@ -13972,15 +14104,11 @@ function frameAt(frames, time) {
13972
14104
  * `test.step` blocks and there is no capture call to place a screenshot at
13973
14105
  * either boundary. So the screenshots are not taken during the run at all —
13974
14106
  * ccqa asks Playwright for a trace on the invocations it owns, and afterwards
13975
- * reads the step boundaries and the screencast out of the archive. Nothing in
13976
- * the consumer's repository changes, and nothing ccqa does here can turn a
13977
- * passing test red: the whole path runs after the command has exited, and a
13978
- * trace it cannot read costs the report its pictures and says so.
13979
- *
13980
- * The frames are the trace's own screencast, which Playwright records
13981
- * downscaled — they are the filmstrip a reviewer already sees in the trace
13982
- * viewer, not `page.screenshot()` output. That is the price of taking the
13983
- * capture code out of the committed file.
14107
+ * renders the DOM snapshots the trace took around each step's browser calls.
14108
+ * Nothing in the consumer's repository changes, and nothing ccqa does here can
14109
+ * turn a passing test red: the whole path runs after the command has exited,
14110
+ * and a trace it cannot read or render costs the report its pictures and says
14111
+ * so.
13984
14112
  */
13985
14113
  /** Shell characters that would hand an appended flag to a different program. */
13986
14114
  const SHELL_OPERATORS = /[|;&<>`$]/;
@@ -14022,82 +14150,124 @@ function amendForTrace(command, outputDir) {
14022
14150
  const flags = [/(^|\s)--trace[=\s]/.test(command) ? "" : "--trace=on", directsOutput ? "" : `--output=${quoteForShell(outputDir)}`].filter(Boolean);
14023
14151
  return { command: flags.length > 0 ? `${command} ${flags.join(" ")}` : command };
14024
14152
  }
14153
+ /** Rendering one archive stops here, so a slow viewer cannot stall the report. */
14154
+ const ARCHIVE_BUDGET_MS = 6e4;
14025
14155
  /**
14026
- * Make sure `evidenceDir` holds the spec's per-step screenshots, reading the
14027
- * trace the run left in `artifactsDir` when nothing else wrote them. Returns
14028
- * null when the directory has evidence in it, and otherwise the reason there
14029
- * is nothing to show.
14156
+ * Make sure `evidenceDir` holds the spec's per-step screenshots, rendering them
14157
+ * from the trace the run left in `artifactsDir` when nothing else wrote them.
14158
+ * Returns null when the directory has evidence in it, and otherwise the reason
14159
+ * there is nothing to show. A step that gets no picture is reported through
14160
+ * `warn` and costs only its own row.
14030
14161
  *
14031
14162
  * A spec generated before the capture calls left the committed file writes its
14032
- * own frames as it runs; those are the test's own and are left alone.
14033
- *
14034
- * Every step of every trace found is written, not just the first archive's: a
14035
- * retried test leaves one archive per attempt, and the later one overwrites
14036
- * the earlier, which is the attempt the row reports.
14163
+ * own screenshots as it runs; those are the test's own and are left alone.
14037
14164
  */
14038
14165
  async function captureStepEvidence(args) {
14039
14166
  if ((await readdir(args.evidenceDir).catch(() => [])).length > 0) return null;
14040
- const archives = (await walkFiles$1(args.artifactsDir)).filter(isTraceArchive).sort().map((rel) => join(args.artifactsDir, ...rel.split("/")));
14167
+ const archives = lastAttempts((await walkFiles$1(args.artifactsDir)).filter(isTraceArchive)).map((rel) => join(args.artifactsDir, ...rel.split("/")));
14041
14168
  if (archives.length === 0) return "the run left no Playwright trace, so there are no per-step screenshots to read — the generated test's `test.step` blocks are what ccqa reads them from";
14042
14169
  let written = 0;
14043
- let frames = 0;
14044
- const failures = [];
14170
+ let named = 0;
14171
+ let firstError;
14045
14172
  for (const archive of archives) try {
14046
- const archived = await writeArchiveEvidence(archive, args.evidenceDir);
14173
+ const archived = await writeArchiveEvidence(archive, args);
14047
14174
  written += archived.written;
14048
- frames += archived.frames;
14175
+ named += archived.named;
14176
+ firstError ??= archived.firstError;
14049
14177
  } catch (err) {
14050
- failures.push(`${archive}: ${err instanceof Error ? err.message : String(err)}`);
14178
+ firstError ??= errMessage(err);
14051
14179
  }
14052
14180
  if (written > 0) return null;
14053
- if (failures.length > 0) return `ccqa could not read the run's Playwright trace (${failures[0]})`;
14054
- if (frames === 0) return "the run's Playwright trace holds no screenshots — the step frames come from its screencast, which Playwright records only while the trace's `screenshots` option is on (`--trace=on` asks for it; a `trace` option in the project's config can turn it off)";
14055
- return "the run's Playwright trace holds no step the case names — a rewrite pass may have reshaped the `test.step` titles the draft wrote; re-run `ccqa generate` for this spec";
14181
+ if (firstError !== void 0) return `ccqa could not render the trace's snapshots: ${firstError}`;
14182
+ if (named === 0) return "the run's Playwright trace holds no step the case names — a rewrite pass may have reshaped the `test.step` titles the draft wrote; re-run `ccqa generate` for this spec";
14183
+ return "the run's Playwright trace holds no DOM snapshot for any step — Playwright records them only while the trace's `snapshots` option is on (`--trace=on` asks for it; a `trace` option in the project's config can turn it off)";
14056
14184
  }
14057
- /** One archive's steps, written as evidence, and the frames it had to write them from. */
14058
- async function writeArchiveEvidence(archive, evidenceDir) {
14059
- const entries = await readZip(archive);
14060
- const { frames, steps } = parseTraceEvents(entries.names.filter((name) => name.endsWith(".trace")).map((name) => entries.read(name)?.toString("utf8") ?? "").join("\n"));
14061
- if (frames.length === 0) return {
14185
+ /**
14186
+ * Each test's last attempt: a retry leaves `<dir>-retry<n>` beside the first
14187
+ * attempt's `<dir>`, and the attempt the row reports is the last one.
14188
+ */
14189
+ function lastAttempts(archives) {
14190
+ const last = /* @__PURE__ */ new Map();
14191
+ for (const archive of archives) {
14192
+ const match = /^(.*)-retry(\d+)$/.exec(dirname(archive));
14193
+ const test = match ? match[1] : dirname(archive);
14194
+ const attempt = match ? Number(match[2]) : 0;
14195
+ if ((last.get(test)?.attempt ?? -1) < attempt) last.set(test, {
14196
+ archive,
14197
+ attempt
14198
+ });
14199
+ }
14200
+ return [...last.values()].map((v) => v.archive).sort();
14201
+ }
14202
+ /** One archive's steps, written as evidence; how many steps the case named; the first render error. */
14203
+ async function writeArchiveEvidence(archive, args) {
14204
+ const steps = parseTraceSteps(await readTraceJsonl(archive)).flatMap((step) => {
14205
+ const label = parseStepLabel(step.title);
14206
+ return label === null ? [] : [{
14207
+ ...step,
14208
+ label
14209
+ }];
14210
+ });
14211
+ if (!steps.some((step) => step.after !== void 0)) return {
14062
14212
  written: 0,
14063
- frames: 0
14213
+ named: steps.length
14064
14214
  };
14065
- await mkdir(evidenceDir, { recursive: true });
14215
+ const renderer = await (args.openRenderer ?? openSnapshotRenderer)(args.playwrightFrom, archive);
14216
+ const deadline = Date.now() + ARCHIVE_BUDGET_MS;
14066
14217
  let written = 0;
14067
- for (const step of steps) {
14068
- const label = parseStepLabel(step.title);
14069
- if (label === null) continue;
14070
- const stem = sanitizeStepId(label.stepId);
14071
- const after = await writeFrame(entries, frames, step.endTime, evidenceDir, stem);
14072
- if (after === null) continue;
14073
- const before = await writeFrame(entries, frames, step.startTime, evidenceDir, `${stem}.before`);
14074
- await writeFile(join(evidenceDir, `${stem}.json`), `${JSON.stringify({
14075
- stepId: label.stepId,
14076
- source: label.source,
14077
- pngFile: after,
14078
- ...before !== null ? { beforePngFile: before } : {},
14079
- ...step.error !== void 0 ? { failureSummary: step.error } : {},
14080
- url: null,
14081
- title: null,
14082
- capturedAt: (/* @__PURE__ */ new Date()).toISOString()
14083
- }, null, 2)}\n`, "utf8");
14084
- written += 1;
14218
+ let firstError;
14219
+ const render = async (snapshot, stepId, end) => {
14220
+ let failure;
14221
+ try {
14222
+ const image = await renderer.render(snapshot);
14223
+ if (image !== null || end === "before") return image;
14224
+ failure = "snapshot rendered blank";
14225
+ } catch (err) {
14226
+ failure = errMessage(err);
14227
+ }
14228
+ firstError ??= failure;
14229
+ args.warn(`${stepId}: no ${end} screenshot — rendering ${snapshot.name} failed (${failure})`);
14230
+ return null;
14231
+ };
14232
+ try {
14233
+ await mkdir(args.evidenceDir, { recursive: true });
14234
+ for (const [index, step] of steps.entries()) {
14235
+ if (Date.now() > deadline) {
14236
+ args.warn(`stopped rendering after ${ARCHIVE_BUDGET_MS / 1e3}s — ${steps.length - index} step(s) left without screenshots`);
14237
+ break;
14238
+ }
14239
+ const { stepId, source } = step.label;
14240
+ if (step.after === void 0) {
14241
+ args.warn(`${stepId}: no screenshot — the trace holds no DOM snapshot of a browser call in this step`);
14242
+ continue;
14243
+ }
14244
+ const stem = sanitizeStepId(stepId);
14245
+ const after = await render(step.after, stepId, "after");
14246
+ if (after === null) continue;
14247
+ await writeFile(join(args.evidenceDir, `${stem}.jpeg`), after);
14248
+ const before = step.before ? await render(step.before, stepId, "before") : null;
14249
+ if (before !== null) await writeFile(join(args.evidenceDir, `${stem}.before.jpeg`), before);
14250
+ await writeFile(join(args.evidenceDir, `${stem}.json`), `${JSON.stringify({
14251
+ stepId,
14252
+ source,
14253
+ pngFile: `${stem}.jpeg`,
14254
+ ...before !== null ? { beforePngFile: `${stem}.before.jpeg` } : {},
14255
+ ...step.error !== void 0 ? { failureSummary: step.error } : {},
14256
+ url: null,
14257
+ title: null,
14258
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString()
14259
+ }, null, 2)}\n`, "utf8");
14260
+ written += 1;
14261
+ }
14262
+ } finally {
14263
+ await renderer.close().catch((err) => args.warn(`could not close the trace viewer (${errMessage(err)})`));
14085
14264
  }
14086
14265
  return {
14087
14266
  written,
14088
- frames: frames.length
14267
+ named: steps.length,
14268
+ ...firstError !== void 0 ? { firstError } : {}
14089
14269
  };
14090
14270
  }
14091
- /** The frame on screen at `time`, written under `stem`. Null when there is none. */
14092
- async function writeFrame(entries, frames, time, evidenceDir, stem) {
14093
- const frame = frameAt(frames, time);
14094
- if (frame === null) return null;
14095
- const data = entries.read(`resources/${frame.sha1}`) ?? entries.read(`resources/${frame.sha1}.jpeg`);
14096
- if (data === void 0) return null;
14097
- const file = `${stem}.jpeg`;
14098
- await writeFile(join(evidenceDir, file), data);
14099
- return file;
14100
- }
14101
14271
  //#endregion
14102
14272
  //#region src/targets/run-command-runner.ts
14103
14273
  /**
@@ -14292,6 +14462,8 @@ async function runOneSpec(ref, opts) {
14292
14462
  evidence,
14293
14463
  artifactsDir,
14294
14464
  testCase,
14465
+ caseKey: `${featureName}/${specName}`,
14466
+ playwrightFrom: [dirname(resolve(opts.cwd, testFile)), opts.cwd],
14295
14467
  ...traceUnavailable !== void 0 ? { traceUnavailable } : {}
14296
14468
  });
14297
14469
  if (outcome.exitCode === 0) return {
@@ -14346,7 +14518,9 @@ async function loadStepEvidence(args) {
14346
14518
  const evidenceDir = args.evidence.dir;
14347
14519
  const reason = args.traceUnavailable ?? await captureStepEvidence({
14348
14520
  artifactsDir: args.artifactsDir,
14349
- evidenceDir
14521
+ evidenceDir,
14522
+ playwrightFrom: args.playwrightFrom,
14523
+ warn: (message) => warn(`${args.caseKey}: ${message}`)
14350
14524
  });
14351
14525
  const descriptions = stepCaptions(args.testCase);
14352
14526
  const evidence = await loadEvidenceForSpec(evidenceDir, opts.reportDir, descriptions);
@@ -14842,7 +15016,9 @@ async function verifyRun(p, state, runCommand, attempt, captures) {
14842
15016
  if (evidenceDir && result.exitCode === 0) {
14843
15017
  const unavailable = traceUnavailable ?? await captureStepEvidence({
14844
15018
  artifactsDir,
14845
- evidenceDir
15019
+ evidenceDir,
15020
+ playwrightFrom: [...testFiles.map((f) => dirname(join(p.ctx.cwd, f))), p.ctx.cwd],
15021
+ warn: (message) => warn(`${p.ctx.ref.id}: ${message}`)
14846
15022
  });
14847
15023
  if (unavailable) warn(unavailable);
14848
15024
  }
@@ -15683,7 +15859,7 @@ const CONFIG_NAMES = [
15683
15859
  ];
15684
15860
  async function acquirePlaywrightBrowser(ctx) {
15685
15861
  await sweepStaleWrappersOnce(ctx.cwd);
15686
- const chromium = await resolveChromium(ctx.cwd);
15862
+ const { chromium } = await loadPlaywright([ctx.cwd]);
15687
15863
  const port = await freePort();
15688
15864
  const server = await chromium.launchServer({ args: [`--remote-debugging-port=${port}`] });
15689
15865
  let wrapperPath;
@@ -15741,25 +15917,6 @@ async function sweepStaleWrappers(cwd) {
15741
15917
  function shellQuote(s) {
15742
15918
  return `'${s.replaceAll("'", `'\\''`)}'`;
15743
15919
  }
15744
- /**
15745
- * The consumer's Playwright, not a dependency of ccqa's: their tests speak
15746
- * their version's protocol, and the server has to be the same animal. Their
15747
- * `runCommand` runs `playwright test`, so the package is present — but under
15748
- * pnpm's isolation it may only be resolvable through `@playwright/test`.
15749
- */
15750
- async function resolveChromium(cwd) {
15751
- const fromProject = createRequire(join(cwd, "package.json"));
15752
- const origins = [];
15753
- try {
15754
- origins.push(fromProject.resolve("@playwright/test/package.json"));
15755
- } catch {}
15756
- for (const name of ["playwright", "playwright-core"]) for (const origin of [null, ...origins]) try {
15757
- const mod = await import(pathToFileURL((origin === null ? fromProject : createRequire(origin)).resolve(name)).href);
15758
- const chromium = mod.chromium ?? mod.default?.chromium;
15759
- if (chromium !== void 0) return chromium;
15760
- } catch {}
15761
- throw new Error(`could not resolve Playwright from ${cwd} — the playwright target's --coverage launches the browser with the project's own Playwright, which must be installed`);
15762
- }
15763
15920
  async function writeWrapperConfig(ctx) {
15764
15921
  const existing = CONFIG_NAMES.find((name) => existsSync(join(ctx.cwd, name)));
15765
15922
  const wrapperName = `ccqa-coverage.${slug(ctx.featureName)}--${slug(ctx.specName)}.playwright.config.ts`;
@@ -27331,7 +27488,7 @@ const evidenceCommand = addLanguageOption(new Command("evidence").argument("<cas
27331
27488
  anchors = await findSourceAnchors(sourceNeedles([...recording.actions, ...recording.cleanup ?? []]), roots);
27332
27489
  }
27333
27490
  const out = resolve(cwd, opts.out ?? join(testCase.ref.dir, "evidence.md"));
27334
- await writeFile(out, scrubEnvValues(renderEvidence({
27491
+ const markdown = renderEvidence({
27335
27492
  testCase,
27336
27493
  recording,
27337
27494
  test: {
@@ -27344,7 +27501,9 @@ const evidenceCommand = addLanguageOption(new Command("evidence").argument("<cas
27344
27501
  ...resolved.targetConfig.allowExpectInCleanup ? {} : { cleanupUnchecked: testCase.cleanupExpectations },
27345
27502
  ...review ? { review } : {},
27346
27503
  ...anchors ? { anchors } : {}
27347
- }), buildProseEnvScrubMap([], [])), "utf8");
27504
+ });
27505
+ await mkdir(dirname(out), { recursive: true });
27506
+ await writeFile(out, scrubEnvValues(markdown, buildProseEnvScrubMap([], [])), "utf8");
27348
27507
  process.stdout.write(`${relative(cwd, out) || out}\n`);
27349
27508
  hint("paste it into the pull request, or attach it — ccqa writes the table, not the body");
27350
27509
  }));
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * Several producers write the `<id>.png` + `<id>.json` pairs —
7
7
  * `abStepEvidence()` / `captureFailureEvidence()` in `test-helpers.ts`
8
- * (agent-browser replays), `targets/playwright/trace-capture.ts` (frames read
9
- * out of a run's Playwright trace), and `ccqaStepBefore`/`ccqaStepAfter` in
8
+ * (agent-browser replays), `targets/playwright/trace-capture.ts` (snapshots
9
+ * rendered from a run's Playwright trace), and `ccqaStepBefore`/`ccqaStepAfter` in
10
10
  * `step-evidence.ts` for tests generated before the capture calls left the
11
11
  * committed file — and one consumer reads them back (`loadEvidenceForSpec` in
12
12
  * `report/evidence.ts`). They agree only on the contract here, so it is kept
@@ -5,8 +5,8 @@
5
5
  *
6
6
  * Several producers write the `<id>.png` + `<id>.json` pairs —
7
7
  * `abStepEvidence()` / `captureFailureEvidence()` in `test-helpers.ts`
8
- * (agent-browser replays), `targets/playwright/trace-capture.ts` (frames read
9
- * out of a run's Playwright trace), and `ccqaStepBefore`/`ccqaStepAfter` in
8
+ * (agent-browser replays), `targets/playwright/trace-capture.ts` (snapshots
9
+ * rendered from a run's Playwright trace), and `ccqaStepBefore`/`ccqaStepAfter` in
10
10
  * `step-evidence.ts` for tests generated before the capture calls left the
11
11
  * committed file — and one consumer reads them back (`loadEvidenceForSpec` in
12
12
  * `report/evidence.ts`). They agree only on the contract here, so it is kept
@@ -35,9 +35,9 @@ declare const RunSchema: z.ZodObject<{
35
35
  running: "running";
36
36
  }>;
37
37
  kind: z.ZodDefault<z.ZodEnum<{
38
+ record: "record";
38
39
  run: "run";
39
40
  drift: "drift";
40
- record: "record";
41
41
  }>>;
42
42
  drift: z.ZodDefault<z.ZodNullable<z.ZodObject<{
43
43
  specs: z.ZodNumber;
@@ -652,8 +652,8 @@ declare const ReportSpecResultSchema: z.ZodObject<{
652
652
  title: z.ZodNullable<z.ZodString>;
653
653
  target: z.ZodOptional<z.ZodString>;
654
654
  mode: z.ZodOptional<z.ZodEnum<{
655
- deterministic: "deterministic";
656
655
  live: "live";
656
+ deterministic: "deterministic";
657
657
  }>>;
658
658
  status: z.ZodEnum<{
659
659
  passed: "passed";
@@ -831,9 +831,9 @@ type ReportSpecResult = z.infer<typeof ReportSpecResultSchema>;
831
831
  declare const RunReportDataSchema: z.ZodObject<{
832
832
  schemaVersion: z.ZodLiteral<1>;
833
833
  kind: z.ZodDefault<z.ZodEnum<{
834
+ record: "record";
834
835
  run: "run";
835
836
  drift: "drift";
836
- record: "record";
837
837
  }>>;
838
838
  createdAt: z.ZodString;
839
839
  runId: z.ZodNullable<z.ZodString>;
@@ -874,8 +874,8 @@ declare const RunReportDataSchema: z.ZodObject<{
874
874
  title: z.ZodNullable<z.ZodString>;
875
875
  target: z.ZodOptional<z.ZodString>;
876
876
  mode: z.ZodOptional<z.ZodEnum<{
877
- deterministic: "deterministic";
878
877
  live: "live";
878
+ deterministic: "deterministic";
879
879
  }>>;
880
880
  status: z.ZodEnum<{
881
881
  passed: "passed";
@@ -35,9 +35,9 @@ declare const RunSchema: z.ZodObject<{
35
35
  running: "running";
36
36
  }>;
37
37
  kind: z.ZodDefault<z.ZodEnum<{
38
+ record: "record";
38
39
  run: "run";
39
40
  drift: "drift";
40
- record: "record";
41
41
  }>>;
42
42
  drift: z.ZodDefault<z.ZodNullable<z.ZodObject<{
43
43
  specs: z.ZodNumber;
@@ -652,8 +652,8 @@ declare const ReportSpecResultSchema: z.ZodObject<{
652
652
  title: z.ZodNullable<z.ZodString>;
653
653
  target: z.ZodOptional<z.ZodString>;
654
654
  mode: z.ZodOptional<z.ZodEnum<{
655
- deterministic: "deterministic";
656
655
  live: "live";
656
+ deterministic: "deterministic";
657
657
  }>>;
658
658
  status: z.ZodEnum<{
659
659
  passed: "passed";
@@ -831,9 +831,9 @@ type ReportSpecResult = z.infer<typeof ReportSpecResultSchema>;
831
831
  declare const RunReportDataSchema: z.ZodObject<{
832
832
  schemaVersion: z.ZodLiteral<1>;
833
833
  kind: z.ZodDefault<z.ZodEnum<{
834
+ record: "record";
834
835
  run: "run";
835
836
  drift: "drift";
836
- record: "record";
837
837
  }>>;
838
838
  createdAt: z.ZodString;
839
839
  runId: z.ZodNullable<z.ZodString>;
@@ -874,8 +874,8 @@ declare const RunReportDataSchema: z.ZodObject<{
874
874
  title: z.ZodNullable<z.ZodString>;
875
875
  target: z.ZodOptional<z.ZodString>;
876
876
  mode: z.ZodOptional<z.ZodEnum<{
877
- deterministic: "deterministic";
878
877
  live: "live";
878
+ deterministic: "deterministic";
879
879
  }>>;
880
880
  status: z.ZodEnum<{
881
881
  passed: "passed";
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.57.1",
3
+ "version": "1.57.3",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_evidence_constants = require("../evidence-constants-BkPzGtzI.cjs");
2
+ const require_evidence_constants = require("../evidence-constants-BNe7Q305.cjs");
3
3
  let node_fs = require("node:fs");
4
4
  let node_path = require("node:path");
5
5
  //#region src/runtime/step-evidence.ts
@@ -1,4 +1,4 @@
1
- import { i as sanitizeStepId, t as EVIDENCE_DIR_ENV } from "../evidence-constants-BLXD0LRY.mjs";
1
+ import { i as sanitizeStepId, t as EVIDENCE_DIR_ENV } from "../evidence-constants-WMpQPI8P.mjs";
2
2
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  //#region src/runtime/step-evidence.ts
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_evidence_constants = require("../evidence-constants-BkPzGtzI.cjs");
2
+ const require_evidence_constants = require("../evidence-constants-BNe7Q305.cjs");
3
3
  let node_fs = require("node:fs");
4
4
  let node_path = require("node:path");
5
5
  let node_child_process = require("node:child_process");
@@ -1,4 +1,4 @@
1
- import { i as sanitizeStepId, n as FAILURE_SOURCE, r as FAILURE_STEP_ID, t as EVIDENCE_DIR_ENV } from "../evidence-constants-BLXD0LRY.mjs";
1
+ import { i as sanitizeStepId, n as FAILURE_SOURCE, r as FAILURE_STEP_ID, t as EVIDENCE_DIR_ENV } from "../evidence-constants-WMpQPI8P.mjs";
2
2
  import { createRequire } from "node:module";
3
3
  import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs";
4
4
  import { dirname, isAbsolute, join, resolve } from "node:path";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.57.1",
3
+ "version": "1.57.3",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {