ccqa 1.57.2 → 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,21 +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
- const wallOffsets = /* @__PURE__ */ new Map();
13881
- let maxTimestamp = -Infinity;
13879
+ function parseTraceSteps(jsonl) {
13880
+ const calls = /* @__PURE__ */ new Map();
13881
+ const viewports = /* @__PURE__ */ new Map();
13882
13882
  for (const line of jsonl.split("\n")) {
13883
13883
  if (line.trim() === "") continue;
13884
13884
  let parsed;
@@ -13889,69 +13889,72 @@ function parseTraceEvents(jsonl) {
13889
13889
  }
13890
13890
  if (typeof parsed !== "object" || parsed === null) continue;
13891
13891
  const event = parsed;
13892
- for (const field of [
13893
- "timestamp",
13894
- "startTime",
13895
- "endTime"
13896
- ]) {
13897
- const value = event[field];
13898
- if (typeof value === "number") maxTimestamp = Math.max(maxTimestamp, value);
13899
- }
13900
- const type = event.type;
13901
- if (type === "screencast-frame") {
13902
- if (typeof event.sha1 === "string" && typeof event.timestamp === "number") frames.push({
13903
- sha1: event.sha1,
13904
- timestamp: event.timestamp,
13905
- ...typeof event.frameSwapWallTime === "number" ? { swapWallTime: event.frameSwapWallTime } : {}
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
13906
13899
  });
13907
- } else if (type === "context-options") {
13908
- if (typeof event.wallTime === "number" && typeof event.monotonicTime === "number") {
13909
- if (!wallOffsets.has(event.origin)) wallOffsets.set(event.origin, event.wallTime - event.monotonicTime);
13910
- }
13911
- } else if (type === "action") {
13912
- 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, {
13913
13904
  title: titleOf$1(event),
13914
13905
  startTime: event.startTime,
13915
- endTime: event.endTime,
13916
- ...errorOf(event)
13917
- });
13918
- } else if (type === "before") {
13919
- if (typeof event.callId === "string" && typeof event.startTime === "number") pendingByCallId.set(event.callId, {
13920
- title: titleOf$1(event),
13921
- startTime: event.startTime
13906
+ ...parent !== void 0 ? { parent } : {},
13907
+ ...typeof event.pageId === "string" ? { pageId: event.pageId } : {},
13908
+ ...typeof event.beforeSnapshot === "string" ? { beforeSnapshot: event.beforeSnapshot } : {}
13922
13909
  });
13923
- } else if (type === "after") {
13924
- if (typeof event.callId === "string" && typeof event.endTime === "number") {
13925
- const pending = pendingByCallId.get(event.callId);
13926
- if (pending !== void 0) {
13927
- pendingByCallId.delete(event.callId);
13928
- steps.push({
13929
- ...pending,
13930
- endTime: event.endTime,
13931
- ...errorOf(event)
13932
- });
13933
- }
13934
- }
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));
13935
13917
  }
13936
13918
  }
13937
- for (const pending of pendingByCallId.values()) steps.push({
13938
- ...pending,
13939
- endTime: maxTimestamp
13940
- });
13941
- const offset = wallOffsets.get("testRunner") ?? wallOffsets.values().next().value;
13942
- const painted = offset !== void 0 && frames.every((f) => f.swapWallTime !== void 0);
13943
- const placed = frames.map(({ sha1, timestamp, swapWallTime }) => ({
13944
- sha1,
13945
- timestamp: painted ? swapWallTime - offset : timestamp
13946
- }));
13947
- placed.sort((a, b) => a.timestamp - b.timestamp);
13948
- steps.sort((a, b) => a.startTime - b.startTime);
13949
- return {
13950
- frames: placed,
13951
- 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
+ };
13952
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");
13953
13956
  }
13954
- /** 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. */
13955
13958
  function errorOf(event) {
13956
13959
  const error = event.error;
13957
13960
  if (typeof error === "string") return { error };
@@ -13966,23 +13969,131 @@ function titleOf$1(event) {
13966
13969
  if (typeof event.title === "string") return event.title;
13967
13970
  return "";
13968
13971
  }
13972
+ //#endregion
13973
+ //#region src/targets/playwright/resolve-playwright.ts
13969
13974
  /**
13970
- * One paint interval at 60fps. An assertion passes on the DOM, so a step can
13971
- * end before the frame showing that DOM is painted — at most one interval later.
13972
- */
13973
- const ONE_FRAME_MS = 17;
13974
- /**
13975
- * The frame showing the page as it was at `time`: the last one painted within
13976
- * a frame interval of it, or — when the step began before any frame was
13977
- * captured — the first frame after. Null when the trace holds no frames at
13978
- * all. `frames` comes from {@link parseTraceEvents}, sorted by paint time.
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.
13979
13977
  *
13980
- * A step's start takes the same allowance as its end: it is the same boundary
13981
- * as the previous step's end, and would otherwise show that step unfinished.
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.
13982
13982
  */
13983
- function frameAt(frames, time) {
13984
- const painted = time + ONE_FRAME_MS;
13985
- return frames.findLast((f) => f.timestamp <= painted) ?? 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()));
13986
14097
  }
13987
14098
  //#endregion
13988
14099
  //#region src/targets/playwright/trace-capture.ts
@@ -13993,15 +14104,11 @@ function frameAt(frames, time) {
13993
14104
  * `test.step` blocks and there is no capture call to place a screenshot at
13994
14105
  * either boundary. So the screenshots are not taken during the run at all —
13995
14106
  * ccqa asks Playwright for a trace on the invocations it owns, and afterwards
13996
- * reads the step boundaries and the screencast out of the archive. Nothing in
13997
- * the consumer's repository changes, and nothing ccqa does here can turn a
13998
- * passing test red: the whole path runs after the command has exited, and a
13999
- * trace it cannot read costs the report its pictures and says so.
14000
- *
14001
- * The frames are the trace's own screencast, which Playwright records
14002
- * downscaled — they are the filmstrip a reviewer already sees in the trace
14003
- * viewer, not `page.screenshot()` output. That is the price of taking the
14004
- * 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.
14005
14112
  */
14006
14113
  /** Shell characters that would hand an appended flag to a different program. */
14007
14114
  const SHELL_OPERATORS = /[|;&<>`$]/;
@@ -14043,82 +14150,124 @@ function amendForTrace(command, outputDir) {
14043
14150
  const flags = [/(^|\s)--trace[=\s]/.test(command) ? "" : "--trace=on", directsOutput ? "" : `--output=${quoteForShell(outputDir)}`].filter(Boolean);
14044
14151
  return { command: flags.length > 0 ? `${command} ${flags.join(" ")}` : command };
14045
14152
  }
14153
+ /** Rendering one archive stops here, so a slow viewer cannot stall the report. */
14154
+ const ARCHIVE_BUDGET_MS = 6e4;
14046
14155
  /**
14047
- * Make sure `evidenceDir` holds the spec's per-step screenshots, reading the
14048
- * trace the run left in `artifactsDir` when nothing else wrote them. Returns
14049
- * null when the directory has evidence in it, and otherwise the reason there
14050
- * 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.
14051
14161
  *
14052
14162
  * A spec generated before the capture calls left the committed file writes its
14053
- * own frames as it runs; those are the test's own and are left alone.
14054
- *
14055
- * Every step of every trace found is written, not just the first archive's: a
14056
- * retried test leaves one archive per attempt, and the later one overwrites
14057
- * 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.
14058
14164
  */
14059
14165
  async function captureStepEvidence(args) {
14060
14166
  if ((await readdir(args.evidenceDir).catch(() => [])).length > 0) return null;
14061
- 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("/")));
14062
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";
14063
14169
  let written = 0;
14064
- let frames = 0;
14065
- const failures = [];
14170
+ let named = 0;
14171
+ let firstError;
14066
14172
  for (const archive of archives) try {
14067
- const archived = await writeArchiveEvidence(archive, args.evidenceDir);
14173
+ const archived = await writeArchiveEvidence(archive, args);
14068
14174
  written += archived.written;
14069
- frames += archived.frames;
14175
+ named += archived.named;
14176
+ firstError ??= archived.firstError;
14070
14177
  } catch (err) {
14071
- failures.push(`${archive}: ${err instanceof Error ? err.message : String(err)}`);
14178
+ firstError ??= errMessage(err);
14072
14179
  }
14073
14180
  if (written > 0) return null;
14074
- if (failures.length > 0) return `ccqa could not read the run's Playwright trace (${failures[0]})`;
14075
- 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)";
14076
- 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)";
14077
14184
  }
14078
- /** One archive's steps, written as evidence, and the frames it had to write them from. */
14079
- async function writeArchiveEvidence(archive, evidenceDir) {
14080
- const entries = await readZip(archive);
14081
- const { frames, steps } = parseTraceEvents(entries.names.filter((name) => name.endsWith(".trace")).map((name) => entries.read(name)?.toString("utf8") ?? "").join("\n"));
14082
- 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 {
14083
14212
  written: 0,
14084
- frames: 0
14213
+ named: steps.length
14085
14214
  };
14086
- await mkdir(evidenceDir, { recursive: true });
14215
+ const renderer = await (args.openRenderer ?? openSnapshotRenderer)(args.playwrightFrom, archive);
14216
+ const deadline = Date.now() + ARCHIVE_BUDGET_MS;
14087
14217
  let written = 0;
14088
- for (const step of steps) {
14089
- const label = parseStepLabel(step.title);
14090
- if (label === null) continue;
14091
- const stem = sanitizeStepId(label.stepId);
14092
- const after = await writeFrame(entries, frames, step.endTime, evidenceDir, stem);
14093
- if (after === null) continue;
14094
- const before = await writeFrame(entries, frames, step.startTime, evidenceDir, `${stem}.before`);
14095
- await writeFile(join(evidenceDir, `${stem}.json`), `${JSON.stringify({
14096
- stepId: label.stepId,
14097
- source: label.source,
14098
- pngFile: after,
14099
- ...before !== null ? { beforePngFile: before } : {},
14100
- ...step.error !== void 0 ? { failureSummary: step.error } : {},
14101
- url: null,
14102
- title: null,
14103
- capturedAt: (/* @__PURE__ */ new Date()).toISOString()
14104
- }, null, 2)}\n`, "utf8");
14105
- 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)})`));
14106
14264
  }
14107
14265
  return {
14108
14266
  written,
14109
- frames: frames.length
14267
+ named: steps.length,
14268
+ ...firstError !== void 0 ? { firstError } : {}
14110
14269
  };
14111
14270
  }
14112
- /** The frame on screen at `time`, written under `stem`. Null when there is none. */
14113
- async function writeFrame(entries, frames, time, evidenceDir, stem) {
14114
- const frame = frameAt(frames, time);
14115
- if (frame === null) return null;
14116
- const data = entries.read(`resources/${frame.sha1}`) ?? entries.read(`resources/${frame.sha1}.jpeg`);
14117
- if (data === void 0) return null;
14118
- const file = `${stem}.jpeg`;
14119
- await writeFile(join(evidenceDir, file), data);
14120
- return file;
14121
- }
14122
14271
  //#endregion
14123
14272
  //#region src/targets/run-command-runner.ts
14124
14273
  /**
@@ -14313,6 +14462,8 @@ async function runOneSpec(ref, opts) {
14313
14462
  evidence,
14314
14463
  artifactsDir,
14315
14464
  testCase,
14465
+ caseKey: `${featureName}/${specName}`,
14466
+ playwrightFrom: [dirname(resolve(opts.cwd, testFile)), opts.cwd],
14316
14467
  ...traceUnavailable !== void 0 ? { traceUnavailable } : {}
14317
14468
  });
14318
14469
  if (outcome.exitCode === 0) return {
@@ -14367,7 +14518,9 @@ async function loadStepEvidence(args) {
14367
14518
  const evidenceDir = args.evidence.dir;
14368
14519
  const reason = args.traceUnavailable ?? await captureStepEvidence({
14369
14520
  artifactsDir: args.artifactsDir,
14370
- evidenceDir
14521
+ evidenceDir,
14522
+ playwrightFrom: args.playwrightFrom,
14523
+ warn: (message) => warn(`${args.caseKey}: ${message}`)
14371
14524
  });
14372
14525
  const descriptions = stepCaptions(args.testCase);
14373
14526
  const evidence = await loadEvidenceForSpec(evidenceDir, opts.reportDir, descriptions);
@@ -14863,7 +15016,9 @@ async function verifyRun(p, state, runCommand, attempt, captures) {
14863
15016
  if (evidenceDir && result.exitCode === 0) {
14864
15017
  const unavailable = traceUnavailable ?? await captureStepEvidence({
14865
15018
  artifactsDir,
14866
- 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}`)
14867
15022
  });
14868
15023
  if (unavailable) warn(unavailable);
14869
15024
  }
@@ -15704,7 +15859,7 @@ const CONFIG_NAMES = [
15704
15859
  ];
15705
15860
  async function acquirePlaywrightBrowser(ctx) {
15706
15861
  await sweepStaleWrappersOnce(ctx.cwd);
15707
- const chromium = await resolveChromium(ctx.cwd);
15862
+ const { chromium } = await loadPlaywright([ctx.cwd]);
15708
15863
  const port = await freePort();
15709
15864
  const server = await chromium.launchServer({ args: [`--remote-debugging-port=${port}`] });
15710
15865
  let wrapperPath;
@@ -15762,25 +15917,6 @@ async function sweepStaleWrappers(cwd) {
15762
15917
  function shellQuote(s) {
15763
15918
  return `'${s.replaceAll("'", `'\\''`)}'`;
15764
15919
  }
15765
- /**
15766
- * The consumer's Playwright, not a dependency of ccqa's: their tests speak
15767
- * their version's protocol, and the server has to be the same animal. Their
15768
- * `runCommand` runs `playwright test`, so the package is present — but under
15769
- * pnpm's isolation it may only be resolvable through `@playwright/test`.
15770
- */
15771
- async function resolveChromium(cwd) {
15772
- const fromProject = createRequire(join(cwd, "package.json"));
15773
- const origins = [];
15774
- try {
15775
- origins.push(fromProject.resolve("@playwright/test/package.json"));
15776
- } catch {}
15777
- for (const name of ["playwright", "playwright-core"]) for (const origin of [null, ...origins]) try {
15778
- const mod = await import(pathToFileURL((origin === null ? fromProject : createRequire(origin)).resolve(name)).href);
15779
- const chromium = mod.chromium ?? mod.default?.chromium;
15780
- if (chromium !== void 0) return chromium;
15781
- } catch {}
15782
- 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`);
15783
- }
15784
15920
  async function writeWrapperConfig(ctx) {
15785
15921
  const existing = CONFIG_NAMES.find((name) => existsSync(join(ctx.cwd, name)));
15786
15922
  const wrapperName = `ccqa-coverage.${slug(ctx.featureName)}--${slug(ctx.specName)}.playwright.config.ts`;
@@ -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
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.57.2",
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.2",
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": {