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 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: scrubEnvValues(tail(failing.output), outputScrub),
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, currentStepIds) {
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 || !current.has(action.stepId)) continue;
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. The application may have moved since, and the
25298
- steps above may have been edited: **the steps above are the contract, this is a
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. A step whose instruction or \`Expected\` asks for something these
25314
- commands do not do skips \`replay_step\` and uses the Execution Workflow
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. A step whose \`Expected\` has changed is one to
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 is recorded the usual way.
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 ?? [], sessionName, input.steps.map((s) => s.id));
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 result = await runShell(cmd, {
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
- if (previousActions.length > 0) meta("previous", opts.freshIr ? "ignored (--fresh-ir)" : `${previousActions.length} action(s) offered as a map`);
26037
- const offersMap = !opts.freshIr && previousActions.length > 0;
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: previousActions } : {}
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 ? previousActions : [],
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, key, range) {
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
- return Object.fromEntries(input.specs.map((spec) => [spec.key, heldBy(input.locks, spec.key, input.now) ? { because: "held" } : auditNeed(input.drift, spec.key, range)]));
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
- when = line.slice(1).trim();
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.key, range);
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 > attest.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, key, range) {
32388
- const need = auditNeed(drift, key, range);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.59.0",
3
+ "version": "1.60.1",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
@@ -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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.59.0",
3
+ "version": "1.60.1",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {