ccqa 1.59.0 → 1.60.1
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 +175 -70
- package/dist/package.json +1 -1
- package/dist/runtime/test-helpers.cjs +48 -0
- package/dist/runtime/test-helpers.mjs +48 -0
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -1612,13 +1612,14 @@ async function stampGeneratedTest(ref, testPathAbs) {
|
|
|
1612
1612
|
};
|
|
1613
1613
|
return writeRecording(ref, found.recording);
|
|
1614
1614
|
}
|
|
1615
|
-
function buildRecording(actions, cleanup = []) {
|
|
1615
|
+
function buildRecording(actions, cleanup = [], stepDigests) {
|
|
1616
1616
|
const origin = actions.find((a) => a.action === "navigate")?.value;
|
|
1617
1617
|
return {
|
|
1618
1618
|
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1619
1619
|
...origin ? { origin } : {},
|
|
1620
1620
|
actions,
|
|
1621
|
-
...cleanup.length > 0 ? { cleanup } : {}
|
|
1621
|
+
...cleanup.length > 0 ? { cleanup } : {},
|
|
1622
|
+
...stepDigests ? { stepDigests } : {}
|
|
1622
1623
|
};
|
|
1623
1624
|
}
|
|
1624
1625
|
/**
|
|
@@ -1634,9 +1635,9 @@ function parseRecording(content, path) {
|
|
|
1634
1635
|
return recording;
|
|
1635
1636
|
}
|
|
1636
1637
|
const LEGACY_RECORDING_FILES = ["actions.json", "route.md"];
|
|
1637
|
-
async function saveRecording(ref, actions, cleanup = []) {
|
|
1638
|
+
async function saveRecording(ref, actions, cleanup = [], stepDigests) {
|
|
1638
1639
|
await mkdir(ref.dir, { recursive: true });
|
|
1639
|
-
const recording = buildRecording(actions, cleanup);
|
|
1640
|
+
const recording = buildRecording(actions, cleanup, stepDigests);
|
|
1640
1641
|
const recordingPath = await writeRecording(ref, recording);
|
|
1641
1642
|
await Promise.all([...LEGACY_RECORDING_FILES, FAILED_RECORDING_FILE].map((f) => unlink(join(ref.dir, f)).catch(() => {})));
|
|
1642
1643
|
return {
|
|
@@ -6313,6 +6314,54 @@ function subcommand(args) {
|
|
|
6313
6314
|
*/
|
|
6314
6315
|
function spawnAB(args, opts) {
|
|
6315
6316
|
const timeoutMs = opts?.timeoutMs ?? PROCESS_HARD_TIMEOUT_MS;
|
|
6317
|
+
const result = spawnABRetrying(args, timeoutMs);
|
|
6318
|
+
if (result.status === 0) return result;
|
|
6319
|
+
const session = args[0] === "--session" ? args.slice(0, 2) : [];
|
|
6320
|
+
return scrollCoveredIntoView(session, args.slice(session.length), result) ? spawnABRetrying(args, timeoutMs) : result;
|
|
6321
|
+
}
|
|
6322
|
+
const COVERED_PATTERN = /Element '(.+?)' is covered by /;
|
|
6323
|
+
/**
|
|
6324
|
+
* When agent-browser refused `command` because another element (a sticky
|
|
6325
|
+
* footer, a banner) covers the target's click point, scroll the target to the
|
|
6326
|
+
* centre and say whether the command is worth running again. The refusal comes
|
|
6327
|
+
* before any input is dispatched, so running it again cannot act twice.
|
|
6328
|
+
*/
|
|
6329
|
+
function scrollCoveredIntoView(session, command, failed) {
|
|
6330
|
+
const covered = COVERED_PATTERN.exec(`${failed.stdout}\n${failed.stderr}`)?.[1];
|
|
6331
|
+
if (covered === void 0) return false;
|
|
6332
|
+
const scroll = command[0] === "find" ? scrollToFound(command, session) : ["scrollintoview", covered];
|
|
6333
|
+
return scroll !== null && spawnABOnce([...session, ...scroll], PROCESS_HARD_TIMEOUT_MS).status === 0;
|
|
6334
|
+
}
|
|
6335
|
+
/** The `find` forms whose element can be addressed again; the rest stay refused. */
|
|
6336
|
+
function scrollToFound(find, session) {
|
|
6337
|
+
const [, by = "", value = ""] = find;
|
|
6338
|
+
switch (by) {
|
|
6339
|
+
case "testid": return ["scrollintoview", `[data-testid=${JSON.stringify(value)}]`];
|
|
6340
|
+
case "first": return scrollToNth(value, "0");
|
|
6341
|
+
case "last": return scrollToNth(value, "els.length - 1");
|
|
6342
|
+
case "nth": return scrollToNth(find[3] ?? "", String(Number(value)));
|
|
6343
|
+
case "role": return scrollToRole(find, session);
|
|
6344
|
+
default: return null;
|
|
6345
|
+
}
|
|
6346
|
+
}
|
|
6347
|
+
function scrollToNth(css, index) {
|
|
6348
|
+
return ["eval", `{ const els = document.querySelectorAll(${JSON.stringify(css)}); els[${index}]?.scrollIntoView({ block: "center" }); }`];
|
|
6349
|
+
}
|
|
6350
|
+
/** The snapshot's ref for the element `find role` matches: same role, name by `--exact` or case-insensitive substring. */
|
|
6351
|
+
function scrollToRole(find, session) {
|
|
6352
|
+
const role = (find[2] ?? "").toLowerCase();
|
|
6353
|
+
const flag = find.indexOf("--name");
|
|
6354
|
+
const name = flag === -1 ? null : find[flag + 1] ?? "";
|
|
6355
|
+
const named = (label) => name === null || (find.includes("--exact") ? label === name : label.toLowerCase().includes(name.toLowerCase()));
|
|
6356
|
+
const snapshot = spawnABOnce([
|
|
6357
|
+
...session,
|
|
6358
|
+
"snapshot",
|
|
6359
|
+
"-i"
|
|
6360
|
+
], PROCESS_HARD_TIMEOUT_MS);
|
|
6361
|
+
for (const [, r, label = "", ref] of snapshot.stdout.matchAll(/^\s*- (\S+)(?: "(.*)")?.*\bref=(e\d+)/gm)) if (r.toLowerCase() === role && named(label)) return ["scrollintoview", `@${ref}`];
|
|
6362
|
+
return null;
|
|
6363
|
+
}
|
|
6364
|
+
function spawnABRetrying(args, timeoutMs) {
|
|
6316
6365
|
let result = spawnABOnce(args, timeoutMs);
|
|
6317
6366
|
let elapsed = 0;
|
|
6318
6367
|
let attempt = 0;
|
|
@@ -14836,6 +14885,12 @@ function tail(output) {
|
|
|
14836
14885
|
const trimmed = output.trim();
|
|
14837
14886
|
return trimmed.length <= OUTPUT_TAIL_CHARS ? trimmed : trimmed.slice(-OUTPUT_TAIL_CHARS);
|
|
14838
14887
|
}
|
|
14888
|
+
const LOGGED_TAIL_LINES = 40;
|
|
14889
|
+
/** The end of an output tail that is already scrubbed, one log line per line. */
|
|
14890
|
+
function logOutputTail(scrubbed, write) {
|
|
14891
|
+
if (scrubbed === "") return;
|
|
14892
|
+
for (const line of scrubbed.split("\n").slice(-LOGGED_TAIL_LINES)) write(` | ${line}`);
|
|
14893
|
+
}
|
|
14839
14894
|
/**
|
|
14840
14895
|
* Where new support files may be created.
|
|
14841
14896
|
*
|
|
@@ -15027,16 +15082,19 @@ async function runVerificationLoop(p, state) {
|
|
|
15027
15082
|
failing = run;
|
|
15028
15083
|
}
|
|
15029
15084
|
}
|
|
15085
|
+
const outputTail = scrubEnvValues(tail(failing.output), outputScrub);
|
|
15030
15086
|
if (attempt >= maxRetries) {
|
|
15031
15087
|
warn(`verification still failing after ${maxRetries} fix attempt(s) — generated files kept`);
|
|
15088
|
+
logOutputTail(outputTail, warn);
|
|
15032
15089
|
await clearCaseRun(p.ctx.ref);
|
|
15033
15090
|
return finish(false);
|
|
15034
15091
|
}
|
|
15035
15092
|
fix(`verification failed (exit ${failing.exitCode}) — requesting a fix (${attempt + 1}/${maxRetries})`);
|
|
15093
|
+
logOutputTail(outputTail, fix);
|
|
15036
15094
|
const fixPrompt = buildLlmFixPrompt({
|
|
15037
15095
|
targetId: p.target,
|
|
15038
15096
|
command: failing.command,
|
|
15039
|
-
outputTail
|
|
15097
|
+
outputTail,
|
|
15040
15098
|
files: [...state.entries()].map(([path, f]) => ({
|
|
15041
15099
|
path,
|
|
15042
15100
|
contents: f.contents,
|
|
@@ -25277,12 +25335,11 @@ function isPassiveAction(action) {
|
|
|
25277
25335
|
* The previous recording as the commands that perform it, for the trace prompt:
|
|
25278
25336
|
* a re-record is usually one moved screen in an otherwise intact route.
|
|
25279
25337
|
*/
|
|
25280
|
-
function renderPreviousRecording(actions, sessionName,
|
|
25281
|
-
const current = new Set([SETUP_STEP_ID, ...currentStepIds]);
|
|
25338
|
+
function renderPreviousRecording(actions, sessionName, replayable) {
|
|
25282
25339
|
const brokenStep = actions.find(failedToReplay)?.stepId;
|
|
25283
25340
|
const byStep = /* @__PURE__ */ new Map();
|
|
25284
25341
|
for (const action of actions) {
|
|
25285
|
-
if (action.stepId === void 0 || !
|
|
25342
|
+
if (action.stepId === void 0 || !replayable.has(action.stepId)) continue;
|
|
25286
25343
|
const command = previousCommand(action, sessionName, action.stepId === brokenStep);
|
|
25287
25344
|
if (command === null) continue;
|
|
25288
25345
|
const lines = byStep.get(action.stepId) ?? [];
|
|
@@ -25294,9 +25351,9 @@ function renderPreviousRecording(actions, sessionName, currentStepIds) {
|
|
|
25294
25351
|
## Previous recording
|
|
25295
25352
|
|
|
25296
25353
|
This case was recorded before. Below is what that recording did, step by step,
|
|
25297
|
-
as the commands that perform it
|
|
25298
|
-
|
|
25299
|
-
map.**
|
|
25354
|
+
as the commands that perform it, for the steps whose text has not changed since.
|
|
25355
|
+
The application may have moved since: **the steps above are the contract, this
|
|
25356
|
+
is a map.**
|
|
25300
25357
|
|
|
25301
25358
|
For a step listed here, this replaces the snapshot-first routine of the
|
|
25302
25359
|
Execution Workflow:
|
|
@@ -25310,19 +25367,16 @@ Execution Workflow:
|
|
|
25310
25367
|
3. When it reports a failure, the commands before it are recorded and the
|
|
25311
25368
|
page is where they left it: finish that step with the Execution Workflow —
|
|
25312
25369
|
snapshot, find what works, record it — without re-running what was
|
|
25313
|
-
recorded.
|
|
25314
|
-
|
|
25315
|
-
from the start. The next step goes back to \`replay_step\`.
|
|
25316
|
-
4. A step you record yourself takes its assertions from its current
|
|
25370
|
+
recorded. The next step goes back to \`replay_step\`.
|
|
25371
|
+
4. A step you finish yourself takes its assertions from its current
|
|
25317
25372
|
\`Expected\`: keep a previous check only while it verifies what
|
|
25318
|
-
\`Expected\` says now.
|
|
25319
|
-
record yourself, since \`replay_step\` records the previous checks as
|
|
25320
|
-
they are.
|
|
25373
|
+
\`Expected\` says now.
|
|
25321
25374
|
5. A command marked \`# did not replay last time\` is where the previous
|
|
25322
25375
|
recording's own validation found the route broken: expect that step to
|
|
25323
25376
|
need the Execution Workflow, and do not keep a form that only half works.
|
|
25324
25377
|
|
|
25325
|
-
A step with no section here
|
|
25378
|
+
A step with no section here — new, or edited since that recording — is
|
|
25379
|
+
recorded the usual way.
|
|
25326
25380
|
|
|
25327
25381
|
${[...byStep].map(([stepId, lines]) => `### ${stepId}\n\n\`\`\`bash\n${lines.join("\n")}\n\`\`\``).join("\n\n")}
|
|
25328
25382
|
`;
|
|
@@ -25395,7 +25449,7 @@ ${input.instruction}
|
|
|
25395
25449
|
const expectationsText = renderExpectations(input.expectations ?? []) + renderCleanupExpectations(input.cleanupExpectations ?? []);
|
|
25396
25450
|
const contextText = renderContext(input.context ?? []);
|
|
25397
25451
|
const conventionsText = renderConventions(input.conventions ?? []);
|
|
25398
|
-
const previousText = renderPreviousRecording(input.previousRecording
|
|
25452
|
+
const previousText = input.previousRecording ? renderPreviousRecording(input.previousRecording.actions, sessionName, input.previousRecording.replayable) : "";
|
|
25399
25453
|
return `You are an expert QA engineer executing a browser E2E test. Execute each step precisely and record every browser action as a structured log line.
|
|
25400
25454
|
|
|
25401
25455
|
## Session
|
|
@@ -25532,6 +25586,7 @@ find nth <index> "<ALLOWED-css>" <action>
|
|
|
25532
25586
|
6. If repeated labels make every ALLOWED selector ambiguous → use the \`find\` subset above.
|
|
25533
25587
|
7. Never guess. If a selector fails, take a fresh snapshot before retrying.
|
|
25534
25588
|
8. **A click that reports success but changes nothing means the element sits outside the visible part of an inner scroll container** — the event dispatches, the app never reacts, and no error is printed. Run \`scrollintoview\` on the element and click again. Scroll to the element itself, addressed by a CSS selector (\`scrollintoview "button.primary-action"\`); \`scrollintoview "text=…"\` can land on a text node that leaves the control still clipped. Do not read this as a product bug or reach for a \`@ref\` — verify by checking that the DOM changed (e.g. \`get count\` on what the click should have produced).
|
|
25589
|
+
9. **A click refused with \`is covered by\`** (a sticky footer or banner over the element) is not a product bug: \`run_commands\` scrolls the element to the centre and clicks again, so send it there.
|
|
25535
25590
|
|
|
25536
25591
|
### Special input types
|
|
25537
25592
|
|
|
@@ -25881,6 +25936,39 @@ ${conventions.map((c) => `### ${c.heading}\n\n${c.body}`).join("\n\n")}
|
|
|
25881
25936
|
`;
|
|
25882
25937
|
}
|
|
25883
25938
|
//#endregion
|
|
25939
|
+
//#region src/ir/step-digests.ts
|
|
25940
|
+
/**
|
|
25941
|
+
* What each step was asked to do when it was recorded, by step id. Case-level
|
|
25942
|
+
* checks land on whichever step the recorder chose, so they are part of every
|
|
25943
|
+
* step's; setup has no text of its own and stands for the case's context.
|
|
25944
|
+
*/
|
|
25945
|
+
function stepDigests(testCase) {
|
|
25946
|
+
return {
|
|
25947
|
+
[SETUP_STEP_ID]: digest(testCase.context),
|
|
25948
|
+
...Object.fromEntries(testCase.steps.filter(isExpandedActionStep).map((s) => [s.id, digest([
|
|
25949
|
+
s.instruction,
|
|
25950
|
+
s.expected,
|
|
25951
|
+
testCase.expectations
|
|
25952
|
+
])])),
|
|
25953
|
+
...Object.fromEntries(testCase.cleanup.map((s) => [s.id, digest([
|
|
25954
|
+
s.instruction,
|
|
25955
|
+
s.expected,
|
|
25956
|
+
testCase.cleanupExpectations
|
|
25957
|
+
])]))
|
|
25958
|
+
};
|
|
25959
|
+
}
|
|
25960
|
+
/**
|
|
25961
|
+
* The steps whose previous commands may be replayed: those still asked to do
|
|
25962
|
+
* what they were then. An edited, added or renumbered step, or any step of a
|
|
25963
|
+
* recording without digests, would replay what the old step asked.
|
|
25964
|
+
*/
|
|
25965
|
+
function unchangedSteps(previous, current) {
|
|
25966
|
+
return new Set(Object.keys(current).filter((id) => previous?.[id] !== void 0 && previous[id] === current[id]));
|
|
25967
|
+
}
|
|
25968
|
+
function digest(value) {
|
|
25969
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16);
|
|
25970
|
+
}
|
|
25971
|
+
//#endregion
|
|
25884
25972
|
//#region src/cli/trace-tools.ts
|
|
25885
25973
|
const TRACE_TOOLS_SERVER = "ccqa";
|
|
25886
25974
|
/** A wedged command must not hold the whole trace; agent-browser's own waits are far shorter. */
|
|
@@ -25926,10 +26014,12 @@ async function runCommands(commands, input) {
|
|
|
25926
26014
|
const checkpoint = input.beforeAbCommand(extractCcqaStepFromBashCommand(cmd) ?? void 0);
|
|
25927
26015
|
if (checkpoint) return stop(checkpoint);
|
|
25928
26016
|
info(`$ ${scrubEnvValues(cmd, input.envScrubMap)}`);
|
|
25929
|
-
const
|
|
26017
|
+
const env = {
|
|
25930
26018
|
...process.env,
|
|
25931
26019
|
...input.env
|
|
25932
|
-
}
|
|
26020
|
+
};
|
|
26021
|
+
let result = await runShell(cmd, env);
|
|
26022
|
+
if (result.status !== 0 && scrollCoveredIntoView(["--session", input.sessionName], abPositionalTokens(cmd), result)) result = await runShell(cmd, env);
|
|
25933
26023
|
const out = result.stdout.trim();
|
|
25934
26024
|
if (!(result.status === 0 && (recorded?.holds ? recorded.holds(out) : true))) {
|
|
25935
26025
|
const detail = (result.stderr || out || `exit ${result.status ?? "timeout"}`).trim().slice(0, 600);
|
|
@@ -25970,7 +26060,7 @@ function cap(out) {
|
|
|
25970
26060
|
}
|
|
25971
26061
|
function replayStep(step, input) {
|
|
25972
26062
|
const actions = input.previous.filter((a) => a.stepId === step && a.action !== "snapshot").map(({ replayUnstable: _u, replayReason: _r, ...a }) => a);
|
|
25973
|
-
if (actions.length === 0) return `No previous commands for ${step}. Record it with the Execution Workflow.`;
|
|
26063
|
+
if (actions.length === 0) return `No previous commands for ${step}: it is new or its text changed since the last recording. Record it with the Execution Workflow.`;
|
|
25974
26064
|
const { passed, unchecked, failed } = replayUntilFailure(actions, {
|
|
25975
26065
|
sessionName: input.sessionName,
|
|
25976
26066
|
envOverrides: { CCQA_RUN_ID: input.env["CCQA_RUN_ID"] ?? input.sessionName }
|
|
@@ -26032,9 +26122,12 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
|
|
|
26032
26122
|
for (const w of conventions.warnings) warn(w);
|
|
26033
26123
|
const caseRef = testCase.ref;
|
|
26034
26124
|
const previous = await tryGetRecording(caseRef);
|
|
26125
|
+
const digests = stepDigests(testCase);
|
|
26126
|
+
const replayable = unchangedSteps(previous?.stepDigests, digests);
|
|
26035
26127
|
const previousActions = previous ? [...previous.actions, ...previous.cleanup ?? []] : [];
|
|
26036
|
-
|
|
26037
|
-
|
|
26128
|
+
const replayableActions = previousActions.filter((a) => a.stepId !== void 0 && replayable.has(a.stepId));
|
|
26129
|
+
if (previous) meta("previous", opts.freshIr ? "ignored (--fresh-ir)" : previous.stepDigests ? `${[...replayable].filter((id) => id !== SETUP_STEP_ID).length} unchanged step(s) offered as a map` : "not offered (recorded before step digests)");
|
|
26130
|
+
const offersMap = !opts.freshIr && replayableActions.length > 0;
|
|
26038
26131
|
const baseSystemPrompt = buildTraceSystemPrompt({
|
|
26039
26132
|
title: testCase.title,
|
|
26040
26133
|
steps,
|
|
@@ -26047,7 +26140,10 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
|
|
|
26047
26140
|
...testCase.cleanupExpectations.length > 0 ? { cleanupExpectations: testCase.cleanupExpectations } : {},
|
|
26048
26141
|
...testCase.context.length > 0 ? { context: testCase.context } : {},
|
|
26049
26142
|
...opts.instruction ? { instruction: opts.instruction } : {},
|
|
26050
|
-
...offersMap ? { previousRecording:
|
|
26143
|
+
...offersMap ? { previousRecording: {
|
|
26144
|
+
actions: previousActions,
|
|
26145
|
+
replayable
|
|
26146
|
+
} } : {}
|
|
26051
26147
|
});
|
|
26052
26148
|
const promptBundle = await loadPromptBundle(opts.hubContext ?? null, "record", opts.cwd ?? process.cwd());
|
|
26053
26149
|
if (promptBundle !== null) meta("prompt", promptBundle.loaded.join(" + "));
|
|
@@ -26106,7 +26202,7 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
|
|
|
26106
26202
|
runId: sessionName
|
|
26107
26203
|
});
|
|
26108
26204
|
const traceTools = buildTraceTools({
|
|
26109
|
-
previous: offersMap ?
|
|
26205
|
+
previous: offersMap ? replayableActions : [],
|
|
26110
26206
|
sessionName,
|
|
26111
26207
|
env: invokeBase.env ?? {},
|
|
26112
26208
|
envScrubMap,
|
|
@@ -26176,7 +26272,7 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
|
|
|
26176
26272
|
const routeActions = validatedActions.filter((a) => !cleanupIds.has(a.stepId ?? ""));
|
|
26177
26273
|
const cleanupActions = validatedActions.filter((a) => cleanupIds.has(a.stepId ?? ""));
|
|
26178
26274
|
if (overallStatus === "passed") {
|
|
26179
|
-
const saved = await saveRecording(caseRef, routeActions, cleanupActions);
|
|
26275
|
+
const saved = await saveRecording(caseRef, routeActions, cleanupActions, digests);
|
|
26180
26276
|
recordingPath = saved.path;
|
|
26181
26277
|
if (previous) await reportRouteDiff(caseRef, previous, saved.recording, steps);
|
|
26182
26278
|
else await removeRouteDiff(caseRef);
|
|
@@ -29038,12 +29134,12 @@ function releaseAll(locks, holder) {
|
|
|
29038
29134
|
}
|
|
29039
29135
|
//#endregion
|
|
29040
29136
|
//#region src/hub/core/audit-need.ts
|
|
29041
|
-
function auditNeed(drift,
|
|
29042
|
-
const entry = drift.specs[key];
|
|
29137
|
+
function auditNeed(drift, spec, range, deployTimes) {
|
|
29138
|
+
const entry = drift.specs[spec.key];
|
|
29043
29139
|
if (!entry) return { because: "neverAudited" };
|
|
29044
|
-
const since = freshness(entry.gitHead, key, range);
|
|
29140
|
+
const since = freshness(entry.gitHead, spec.key, range);
|
|
29045
29141
|
switch (since.kind) {
|
|
29046
|
-
case "current": return { because: "current" };
|
|
29142
|
+
case "current": return specMovedSince(spec.changedAt, entry.gitHead, entry.at, deployTimes) ? { because: "deployReached" } : { because: "current" };
|
|
29047
29143
|
case "touched": return { because: "deployReached" };
|
|
29048
29144
|
case "unanswerable": return {
|
|
29049
29145
|
because: "cannotTell",
|
|
@@ -29052,13 +29148,52 @@ function auditNeed(drift, key, range) {
|
|
|
29052
29148
|
default: throw new Error(`unhandled freshness: ${String(since)}`);
|
|
29053
29149
|
}
|
|
29054
29150
|
}
|
|
29151
|
+
/**
|
|
29152
|
+
* When each deployed commit reached the environment. A baseline read at that
|
|
29153
|
+
* commit cannot have seen anything committed after it was deployed.
|
|
29154
|
+
*/
|
|
29155
|
+
function deployedAt(log) {
|
|
29156
|
+
const out = /* @__PURE__ */ new Map();
|
|
29157
|
+
for (const entry of log.entries) out.set(entry.sha, entry.at);
|
|
29158
|
+
return out;
|
|
29159
|
+
}
|
|
29160
|
+
/**
|
|
29161
|
+
* Has the spec moved since the baseline was taken?
|
|
29162
|
+
*
|
|
29163
|
+
* A verdict is a claim about a (spec, product) pair, so either side moving
|
|
29164
|
+
* invalidates it. The deploy log covers the product side; this covers the
|
|
29165
|
+
* other one. Without it a spec repaired and merged stays `needsRepair` until
|
|
29166
|
+
* a deploy happens to reach it, and a run that passed against the previous
|
|
29167
|
+
* spec keeps answering `verified` for the new one.
|
|
29168
|
+
*
|
|
29169
|
+
* Compared against when the baseline commit was *deployed*, not when the audit
|
|
29170
|
+
* or run happened: the tree read at that commit predates its deployment, so an
|
|
29171
|
+
* edit after it is definitely not in it. Falls back to the baseline's own
|
|
29172
|
+
* timestamp when the log cannot place the commit.
|
|
29173
|
+
*
|
|
29174
|
+
* One-directional. A later edit time proves the baseline is stale; an earlier
|
|
29175
|
+
* one proves nothing, and this answers false rather than guessing.
|
|
29176
|
+
*/
|
|
29177
|
+
function specMovedSince(changedAt, baselineSha, baselineAt, deployTimes) {
|
|
29178
|
+
if (!changedAt) return null;
|
|
29179
|
+
return isLater(changedAt, baselineSha && deployTimes.get(baselineSha) || baselineAt) ? changedAt : null;
|
|
29180
|
+
}
|
|
29181
|
+
/**
|
|
29182
|
+
* Compared as instants, never as strings: a spec's edit time carries its
|
|
29183
|
+
* committer's offset, and `+09:00` sorts after `Z` for an earlier moment.
|
|
29184
|
+
* An unparseable side answers false, the same as a missing one.
|
|
29185
|
+
*/
|
|
29186
|
+
function isLater(a, b) {
|
|
29187
|
+
return Date.parse(a) > Date.parse(b);
|
|
29188
|
+
}
|
|
29055
29189
|
/** True for every answer but `current`. */
|
|
29056
29190
|
function needsAudit(need) {
|
|
29057
29191
|
return need.because !== "current";
|
|
29058
29192
|
}
|
|
29059
29193
|
function computeAuditNeed(input) {
|
|
29060
29194
|
const range = buildRange(input.log, input.touchIndex);
|
|
29061
|
-
|
|
29195
|
+
const deployTimes = deployedAt(input.log);
|
|
29196
|
+
return Object.fromEntries(input.specs.map((spec) => [spec.key, heldBy(input.locks, spec.key, input.now) ? { because: "held" } : auditNeed(input.drift, spec, range, deployTimes)]));
|
|
29062
29197
|
}
|
|
29063
29198
|
//#endregion
|
|
29064
29199
|
//#region src/drift/audit-selection.ts
|
|
@@ -29620,7 +29755,8 @@ async function readChunk(cwd, paths, byRelPath, out) {
|
|
|
29620
29755
|
let when = "";
|
|
29621
29756
|
for (const line of stdout.split("\n")) {
|
|
29622
29757
|
if (line.startsWith("\0")) {
|
|
29623
|
-
|
|
29758
|
+
const ms = Date.parse(line.slice(1).trim());
|
|
29759
|
+
when = Number.isNaN(ms) ? "" : new Date(ms).toISOString();
|
|
29624
29760
|
continue;
|
|
29625
29761
|
}
|
|
29626
29762
|
const abs = byRelPath.get(line.trim());
|
|
@@ -32244,36 +32380,6 @@ function createGetCoverageEdgesHandler(config) {
|
|
|
32244
32380
|
}
|
|
32245
32381
|
//#endregion
|
|
32246
32382
|
//#region src/hub/core/rerun.ts
|
|
32247
|
-
/**
|
|
32248
|
-
* When each deployed commit reached the environment. A baseline read at that
|
|
32249
|
-
* commit cannot have seen anything committed after it was deployed.
|
|
32250
|
-
*/
|
|
32251
|
-
function deployedAt(log) {
|
|
32252
|
-
const out = /* @__PURE__ */ new Map();
|
|
32253
|
-
for (const entry of log.entries) out.set(entry.sha, entry.at);
|
|
32254
|
-
return out;
|
|
32255
|
-
}
|
|
32256
|
-
/**
|
|
32257
|
-
* Has the spec moved since the baseline was taken?
|
|
32258
|
-
*
|
|
32259
|
-
* A verdict is a claim about a (spec, product) pair, so either side moving
|
|
32260
|
-
* invalidates it. The deploy log covers the product side; this covers the
|
|
32261
|
-
* other one. Without it a spec repaired and merged stays `needsRepair` until
|
|
32262
|
-
* a deploy happens to reach it, and a run that passed against the previous
|
|
32263
|
-
* spec keeps answering `verified` for the new one.
|
|
32264
|
-
*
|
|
32265
|
-
* Compared against when the baseline commit was *deployed*, not when the audit
|
|
32266
|
-
* or run happened: the tree read at that commit predates its deployment, so an
|
|
32267
|
-
* edit after it is definitely not in it. Falls back to the baseline's own
|
|
32268
|
-
* timestamp when the log cannot place the commit.
|
|
32269
|
-
*
|
|
32270
|
-
* One-directional. A later edit time proves the baseline is stale; an earlier
|
|
32271
|
-
* one proves nothing, and this answers false rather than guessing.
|
|
32272
|
-
*/
|
|
32273
|
-
function specMovedSince(changedAt, baselineSha, baselineAt, deployTimes) {
|
|
32274
|
-
if (!changedAt) return null;
|
|
32275
|
-
return changedAt > (baselineSha && deployTimes.get(baselineSha) || baselineAt) ? changedAt : null;
|
|
32276
|
-
}
|
|
32277
32383
|
function computeRerun(input) {
|
|
32278
32384
|
const { specs, ledger, log, touchIndex, drift, locks, attestations, dismissals, now } = input;
|
|
32279
32385
|
const range = buildRange(log, touchIndex);
|
|
@@ -32285,7 +32391,7 @@ function computeRerun(input) {
|
|
|
32285
32391
|
lastGreen: ledger.green[spec.key] ?? null,
|
|
32286
32392
|
lastRed: ledger.red[spec.key] ?? null
|
|
32287
32393
|
};
|
|
32288
|
-
let audit = auditState(drift, spec
|
|
32394
|
+
let audit = auditState(drift, spec, range, deployTimes);
|
|
32289
32395
|
let execution = executionState(coords, (sha) => freshness(sha, spec.key, range));
|
|
32290
32396
|
const driftEntry = drift.specs[spec.key];
|
|
32291
32397
|
const dismissal = dismissals.specs[spec.key];
|
|
@@ -32293,7 +32399,6 @@ function computeRerun(input) {
|
|
|
32293
32399
|
if (dismissed) audit = { audit: "clean" };
|
|
32294
32400
|
const auditMoved = specMovedSince(spec.changedAt, driftEntry?.gitHead ?? null, driftEntry?.at ?? "", deployTimes);
|
|
32295
32401
|
const runMoved = specMovedSince(spec.changedAt, coords.lastRun?.deployedSha ?? null, coords.lastRun?.at ?? "", deployTimes);
|
|
32296
|
-
if (auditMoved && audit.audit !== "due") audit = { audit: "due" };
|
|
32297
32402
|
if (runMoved && execution.execution === "passed") execution = { execution: "stale" };
|
|
32298
32403
|
const manualState = readAttestation(attestations.specs[spec.key], spec, coords, range, log, deployTimes);
|
|
32299
32404
|
const lapsedOverRed = manualState?.kind === "lapsed" && manualState.because !== "newerRed";
|
|
@@ -32338,7 +32443,7 @@ function computeRerun(input) {
|
|
|
32338
32443
|
*/
|
|
32339
32444
|
function readAttestation(attest, spec, { lastRun, lastRed }, range, log, deployTimes) {
|
|
32340
32445
|
if (!attest) return null;
|
|
32341
|
-
const newerRed = lastRed !== null && lastRed.at
|
|
32446
|
+
const newerRed = lastRed !== null && isLater(lastRed.at, attest.at);
|
|
32342
32447
|
if (newerRed && lastRed.runId === lastRun?.runId) return {
|
|
32343
32448
|
kind: "lapsed",
|
|
32344
32449
|
attest,
|
|
@@ -32384,8 +32489,8 @@ function readAttestation(attest, spec, { lastRun, lastRed }, range, log, deployT
|
|
|
32384
32489
|
* rule out is treated as having landed (ADR-0014). The hole is kept as an
|
|
32385
32490
|
* annotation so the answer stays explicable.
|
|
32386
32491
|
*/
|
|
32387
|
-
function auditState(drift,
|
|
32388
|
-
const need = auditNeed(drift,
|
|
32492
|
+
function auditState(drift, spec, range, deployTimes) {
|
|
32493
|
+
const need = auditNeed(drift, spec, range, deployTimes);
|
|
32389
32494
|
switch (need.because) {
|
|
32390
32495
|
case "neverAudited":
|
|
32391
32496
|
case "deployReached": return { audit: "due" };
|
|
@@ -32394,7 +32499,7 @@ function auditState(drift, key, range) {
|
|
|
32394
32499
|
...need.reason ? { auditAssumedReached: need.reason } : {}
|
|
32395
32500
|
};
|
|
32396
32501
|
case "current": {
|
|
32397
|
-
const label = drift.specs[key].label;
|
|
32502
|
+
const label = drift.specs[spec.key].label;
|
|
32398
32503
|
if (label === null) return { audit: "clean" };
|
|
32399
32504
|
if (label === "UNKNOWN") return { audit: "undecided" };
|
|
32400
32505
|
if (label === "PRODUCT_BUG" || label === "ENVIRONMENT") return { audit: "clean" };
|
package/dist/package.json
CHANGED
|
@@ -118,6 +118,54 @@ function subcommand(args) {
|
|
|
118
118
|
*/
|
|
119
119
|
function spawnAB(args, opts) {
|
|
120
120
|
const timeoutMs = opts?.timeoutMs ?? PROCESS_HARD_TIMEOUT_MS;
|
|
121
|
+
const result = spawnABRetrying(args, timeoutMs);
|
|
122
|
+
if (result.status === 0) return result;
|
|
123
|
+
const session = args[0] === "--session" ? args.slice(0, 2) : [];
|
|
124
|
+
return scrollCoveredIntoView(session, args.slice(session.length), result) ? spawnABRetrying(args, timeoutMs) : result;
|
|
125
|
+
}
|
|
126
|
+
const COVERED_PATTERN = /Element '(.+?)' is covered by /;
|
|
127
|
+
/**
|
|
128
|
+
* When agent-browser refused `command` because another element (a sticky
|
|
129
|
+
* footer, a banner) covers the target's click point, scroll the target to the
|
|
130
|
+
* centre and say whether the command is worth running again. The refusal comes
|
|
131
|
+
* before any input is dispatched, so running it again cannot act twice.
|
|
132
|
+
*/
|
|
133
|
+
function scrollCoveredIntoView(session, command, failed) {
|
|
134
|
+
const covered = COVERED_PATTERN.exec(`${failed.stdout}\n${failed.stderr}`)?.[1];
|
|
135
|
+
if (covered === void 0) return false;
|
|
136
|
+
const scroll = command[0] === "find" ? scrollToFound(command, session) : ["scrollintoview", covered];
|
|
137
|
+
return scroll !== null && spawnABOnce([...session, ...scroll], PROCESS_HARD_TIMEOUT_MS).status === 0;
|
|
138
|
+
}
|
|
139
|
+
/** The `find` forms whose element can be addressed again; the rest stay refused. */
|
|
140
|
+
function scrollToFound(find, session) {
|
|
141
|
+
const [, by = "", value = ""] = find;
|
|
142
|
+
switch (by) {
|
|
143
|
+
case "testid": return ["scrollintoview", `[data-testid=${JSON.stringify(value)}]`];
|
|
144
|
+
case "first": return scrollToNth(value, "0");
|
|
145
|
+
case "last": return scrollToNth(value, "els.length - 1");
|
|
146
|
+
case "nth": return scrollToNth(find[3] ?? "", String(Number(value)));
|
|
147
|
+
case "role": return scrollToRole(find, session);
|
|
148
|
+
default: return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function scrollToNth(css, index) {
|
|
152
|
+
return ["eval", `{ const els = document.querySelectorAll(${JSON.stringify(css)}); els[${index}]?.scrollIntoView({ block: "center" }); }`];
|
|
153
|
+
}
|
|
154
|
+
/** The snapshot's ref for the element `find role` matches: same role, name by `--exact` or case-insensitive substring. */
|
|
155
|
+
function scrollToRole(find, session) {
|
|
156
|
+
const role = (find[2] ?? "").toLowerCase();
|
|
157
|
+
const flag = find.indexOf("--name");
|
|
158
|
+
const name = flag === -1 ? null : find[flag + 1] ?? "";
|
|
159
|
+
const named = (label) => name === null || (find.includes("--exact") ? label === name : label.toLowerCase().includes(name.toLowerCase()));
|
|
160
|
+
const snapshot = spawnABOnce([
|
|
161
|
+
...session,
|
|
162
|
+
"snapshot",
|
|
163
|
+
"-i"
|
|
164
|
+
], PROCESS_HARD_TIMEOUT_MS);
|
|
165
|
+
for (const [, r, label = "", ref] of snapshot.stdout.matchAll(/^\s*- (\S+)(?: "(.*)")?.*\bref=(e\d+)/gm)) if (r.toLowerCase() === role && named(label)) return ["scrollintoview", `@${ref}`];
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
function spawnABRetrying(args, timeoutMs) {
|
|
121
169
|
let result = spawnABOnce(args, timeoutMs);
|
|
122
170
|
let elapsed = 0;
|
|
123
171
|
let attempt = 0;
|
|
@@ -118,6 +118,54 @@ function subcommand(args) {
|
|
|
118
118
|
*/
|
|
119
119
|
function spawnAB(args, opts) {
|
|
120
120
|
const timeoutMs = opts?.timeoutMs ?? PROCESS_HARD_TIMEOUT_MS;
|
|
121
|
+
const result = spawnABRetrying(args, timeoutMs);
|
|
122
|
+
if (result.status === 0) return result;
|
|
123
|
+
const session = args[0] === "--session" ? args.slice(0, 2) : [];
|
|
124
|
+
return scrollCoveredIntoView(session, args.slice(session.length), result) ? spawnABRetrying(args, timeoutMs) : result;
|
|
125
|
+
}
|
|
126
|
+
const COVERED_PATTERN = /Element '(.+?)' is covered by /;
|
|
127
|
+
/**
|
|
128
|
+
* When agent-browser refused `command` because another element (a sticky
|
|
129
|
+
* footer, a banner) covers the target's click point, scroll the target to the
|
|
130
|
+
* centre and say whether the command is worth running again. The refusal comes
|
|
131
|
+
* before any input is dispatched, so running it again cannot act twice.
|
|
132
|
+
*/
|
|
133
|
+
function scrollCoveredIntoView(session, command, failed) {
|
|
134
|
+
const covered = COVERED_PATTERN.exec(`${failed.stdout}\n${failed.stderr}`)?.[1];
|
|
135
|
+
if (covered === void 0) return false;
|
|
136
|
+
const scroll = command[0] === "find" ? scrollToFound(command, session) : ["scrollintoview", covered];
|
|
137
|
+
return scroll !== null && spawnABOnce([...session, ...scroll], PROCESS_HARD_TIMEOUT_MS).status === 0;
|
|
138
|
+
}
|
|
139
|
+
/** The `find` forms whose element can be addressed again; the rest stay refused. */
|
|
140
|
+
function scrollToFound(find, session) {
|
|
141
|
+
const [, by = "", value = ""] = find;
|
|
142
|
+
switch (by) {
|
|
143
|
+
case "testid": return ["scrollintoview", `[data-testid=${JSON.stringify(value)}]`];
|
|
144
|
+
case "first": return scrollToNth(value, "0");
|
|
145
|
+
case "last": return scrollToNth(value, "els.length - 1");
|
|
146
|
+
case "nth": return scrollToNth(find[3] ?? "", String(Number(value)));
|
|
147
|
+
case "role": return scrollToRole(find, session);
|
|
148
|
+
default: return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function scrollToNth(css, index) {
|
|
152
|
+
return ["eval", `{ const els = document.querySelectorAll(${JSON.stringify(css)}); els[${index}]?.scrollIntoView({ block: "center" }); }`];
|
|
153
|
+
}
|
|
154
|
+
/** The snapshot's ref for the element `find role` matches: same role, name by `--exact` or case-insensitive substring. */
|
|
155
|
+
function scrollToRole(find, session) {
|
|
156
|
+
const role = (find[2] ?? "").toLowerCase();
|
|
157
|
+
const flag = find.indexOf("--name");
|
|
158
|
+
const name = flag === -1 ? null : find[flag + 1] ?? "";
|
|
159
|
+
const named = (label) => name === null || (find.includes("--exact") ? label === name : label.toLowerCase().includes(name.toLowerCase()));
|
|
160
|
+
const snapshot = spawnABOnce([
|
|
161
|
+
...session,
|
|
162
|
+
"snapshot",
|
|
163
|
+
"-i"
|
|
164
|
+
], PROCESS_HARD_TIMEOUT_MS);
|
|
165
|
+
for (const [, r, label = "", ref] of snapshot.stdout.matchAll(/^\s*- (\S+)(?: "(.*)")?.*\bref=(e\d+)/gm)) if (r.toLowerCase() === role && named(label)) return ["scrollintoview", `@${ref}`];
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
function spawnABRetrying(args, timeoutMs) {
|
|
121
169
|
let result = spawnABOnce(args, timeoutMs);
|
|
122
170
|
let elapsed = 0;
|
|
123
171
|
let attempt = 0;
|