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 +312 -176
- package/dist/{evidence-constants-BkPzGtzI.cjs → evidence-constants-BNe7Q305.cjs} +2 -2
- package/dist/{evidence-constants-BLXD0LRY.mjs → evidence-constants-WMpQPI8P.mjs} +2 -2
- package/dist/package.json +1 -1
- package/dist/runtime/step-evidence.cjs +1 -1
- package/dist/runtime/step-evidence.mjs +1 -1
- package/dist/runtime/test-helpers.cjs +1 -1
- package/dist/runtime/test-helpers.mjs +1 -1
- package/package.json +1 -1
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` (
|
|
9342
|
-
*
|
|
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,
|
|
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
|
|
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
|
|
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
|
|
13870
|
-
*
|
|
13871
|
-
*
|
|
13872
|
-
* `
|
|
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
|
|
13877
|
-
const
|
|
13878
|
-
const
|
|
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
|
-
|
|
13893
|
-
|
|
13894
|
-
|
|
13895
|
-
|
|
13896
|
-
|
|
13897
|
-
|
|
13898
|
-
|
|
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 === "
|
|
13908
|
-
if (typeof event.
|
|
13909
|
-
|
|
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
|
-
|
|
13916
|
-
...
|
|
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
|
-
}
|
|
13924
|
-
|
|
13925
|
-
|
|
13926
|
-
|
|
13927
|
-
|
|
13928
|
-
|
|
13929
|
-
|
|
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
|
-
|
|
13938
|
-
|
|
13939
|
-
|
|
13940
|
-
|
|
13941
|
-
|
|
13942
|
-
|
|
13943
|
-
|
|
13944
|
-
|
|
13945
|
-
|
|
13946
|
-
|
|
13947
|
-
|
|
13948
|
-
|
|
13949
|
-
|
|
13950
|
-
|
|
13951
|
-
|
|
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
|
|
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
|
-
*
|
|
13971
|
-
*
|
|
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
|
-
*
|
|
13981
|
-
*
|
|
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
|
|
13984
|
-
const
|
|
13985
|
-
|
|
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
|
-
*
|
|
13997
|
-
* the consumer's repository changes, and nothing ccqa does here can
|
|
13998
|
-
* passing test red: the whole path runs after the command has exited,
|
|
13999
|
-
* trace it cannot read costs the report its pictures and says
|
|
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,
|
|
14048
|
-
* trace the run left in `artifactsDir` when nothing else wrote them.
|
|
14049
|
-
* null when the directory has evidence in it, and otherwise the reason
|
|
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
|
|
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)
|
|
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
|
|
14065
|
-
|
|
14170
|
+
let named = 0;
|
|
14171
|
+
let firstError;
|
|
14066
14172
|
for (const archive of archives) try {
|
|
14067
|
-
const archived = await writeArchiveEvidence(archive, args
|
|
14173
|
+
const archived = await writeArchiveEvidence(archive, args);
|
|
14068
14174
|
written += archived.written;
|
|
14069
|
-
|
|
14175
|
+
named += archived.named;
|
|
14176
|
+
firstError ??= archived.firstError;
|
|
14070
14177
|
} catch (err) {
|
|
14071
|
-
|
|
14178
|
+
firstError ??= errMessage(err);
|
|
14072
14179
|
}
|
|
14073
14180
|
if (written > 0) return null;
|
|
14074
|
-
if (
|
|
14075
|
-
if (
|
|
14076
|
-
return "the run's Playwright trace holds no
|
|
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
|
-
/**
|
|
14079
|
-
|
|
14080
|
-
|
|
14081
|
-
|
|
14082
|
-
|
|
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
|
-
|
|
14213
|
+
named: steps.length
|
|
14085
14214
|
};
|
|
14086
|
-
await
|
|
14215
|
+
const renderer = await (args.openRenderer ?? openSnapshotRenderer)(args.playwrightFrom, archive);
|
|
14216
|
+
const deadline = Date.now() + ARCHIVE_BUDGET_MS;
|
|
14087
14217
|
let written = 0;
|
|
14088
|
-
|
|
14089
|
-
|
|
14090
|
-
|
|
14091
|
-
|
|
14092
|
-
|
|
14093
|
-
|
|
14094
|
-
|
|
14095
|
-
|
|
14096
|
-
|
|
14097
|
-
|
|
14098
|
-
|
|
14099
|
-
|
|
14100
|
-
|
|
14101
|
-
|
|
14102
|
-
|
|
14103
|
-
|
|
14104
|
-
|
|
14105
|
-
|
|
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
|
-
|
|
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
|
|
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` (
|
|
9
|
-
*
|
|
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` (
|
|
9
|
-
*
|
|
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,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_evidence_constants = require("../evidence-constants-
|
|
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-
|
|
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-
|
|
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-
|
|
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";
|