ccqa 1.60.0 → 1.60.2

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 {
@@ -1649,10 +1650,10 @@ async function saveRecording(ref, actions, cleanup = []) {
1649
1650
  *
1650
1651
  * One directory, not one per attempt: a project whose tests belong to its own
1651
1652
  * runner never calls `ccqa run`, so this stands in for the run report the
1652
- * evidence table reads — and what stands in for it is the attempt that passed,
1653
- * which there is only ever one of. `ccqa generate` clears it before each
1654
- * attempt and removes it when none passed, because screenshots of a failing
1655
- * run are a debugging aid and this table presents them as the case working.
1653
+ * evidence table reads — and what stands in for it is the attempt that passed
1654
+ * last, the version generation keeps. `ccqa generate` replaces it only on a
1655
+ * pass and removes it when none passed, because screenshots of a failing run
1656
+ * are a debugging aid and this table presents them as the case working.
1656
1657
  */
1657
1658
  function caseRunDir(ref) {
1658
1659
  return join(ref.dir, EVIDENCE_DIR);
@@ -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;
@@ -6486,7 +6535,7 @@ function parseStepComment(line) {
6486
6535
  * every step then reads as deciding nothing and every rewrite is rejected.
6487
6536
  */
6488
6537
  function stepLines(source) {
6489
- return source.replace(STEP_CALL, (call) => call.replace(/\s*\n\s*/g, " ")).split("\n");
6538
+ return source.replace(STEP_CALL, (call) => "\n".repeat(call.split("\n").length - 1) + call.replace(/\s*\n\s*/g, " ")).split("\n");
6490
6539
  }
6491
6540
  /**
6492
6541
  * The step id a `test.step` title names — the block form only.
@@ -8289,6 +8338,31 @@ function exportedNames(source) {
8289
8338
  }
8290
8339
  //#endregion
8291
8340
  //#region src/targets/emitted-review.ts
8341
+ /**
8342
+ * A mechanical read of the code a generation just wrote, before it is called
8343
+ * done.
8344
+ *
8345
+ * The fix loop already runs the project's own test command and checks, so
8346
+ * what reaches review is code that compiles, lints and passes. None of that
8347
+ * answers whether it is code a reviewer would take: an assertion scoped to
8348
+ * nothing passes, a locator named for a row that matches page-wide text
8349
+ * passes, a test that checks five things the case never asked about passes.
8350
+ * Those are what this reads for, and its findings go back through the same
8351
+ * fix loop rather than to a human.
8352
+ *
8353
+ * Every rule here was calibrated against a large hand-written suite: a rule
8354
+ * that fires on code people wrote and reviewed is a rule that is wrong,
8355
+ * however reasonable it sounds. Two candidates died that way —
8356
+ * "a locator named `…Row` must use `getByRole("row")`" fired across a
8357
+ * sizable share of the hand-written page objects, because that suite names
8358
+ * elements for what they look like rather than for their ARIA role, and
8359
+ * "never `.first()` in an assertion" fired on deliberate uses that each
8360
+ * carried the comment their own guidelines ask for. A third died against
8361
+ * ccqa's own output: the step comment a generated test opens each step with
8362
+ * looked like narration, and is what the evidence table reads back to say
8363
+ * which assertion belongs to which step. Keep that bar for anything added
8364
+ * here.
8365
+ */
8292
8366
  function reviewEmittedFiles(input) {
8293
8367
  const locators = collectLocators(input.files);
8294
8368
  const said = input.caseText.join("\n");
@@ -8297,7 +8371,7 @@ function reviewEmittedFiles(input) {
8297
8371
  ...containerOfPageText(file, source),
8298
8372
  ...unjustifiedFirst(file, source),
8299
8373
  ...describeEcho(file, source),
8300
- ...unaskedAssertions(file, source, locators, said),
8374
+ ...unaskedAssertions(file, source, locators, said, input.blockSteps),
8301
8375
  ...weakerTwin(file, source),
8302
8376
  ...unreached(file, source, input)
8303
8377
  ])];
@@ -8552,9 +8626,26 @@ function assertedStrings(text, locators) {
8552
8626
  * precondition broke rather than four steps later, is worth doing and is
8553
8627
  * written down when it is.
8554
8628
  */
8555
- function unaskedAssertions(file, source, locators, said) {
8629
+ function unaskedAssertions(file, source, locators, said, blockSteps = /* @__PURE__ */ new Set()) {
8556
8630
  const all = lines(source);
8631
+ const boundaries = stepLines(source);
8632
+ let step = null;
8633
+ let depth = null;
8557
8634
  return all.flatMap((text, i) => {
8635
+ const opened = parseStepComment(boundaries[i] ?? "");
8636
+ if (opened !== null) {
8637
+ step = opened;
8638
+ depth = boundaries[i].includes("test.step(") ? 0 : null;
8639
+ }
8640
+ if (depth !== null) {
8641
+ const before = depth;
8642
+ depth += (text.match(/\{/g)?.length ?? 0) - (text.match(/\}/g)?.length ?? 0);
8643
+ if (before > 0 && depth <= 0) {
8644
+ step = null;
8645
+ depth = null;
8646
+ }
8647
+ }
8648
+ if (step !== null && blockSteps.has(step)) return [];
8558
8649
  if (!/\bexpect(?:\.soft)?\(/.test(text)) return [];
8559
8650
  const strings = assertedStrings(text, locators);
8560
8651
  if (strings.length === 0 || strings.some((s) => said.includes(s))) return [];
@@ -14964,12 +15055,14 @@ async function finalizeAndVerify(p) {
14964
15055
  review
14965
15056
  };
14966
15057
  }
15058
+ const FINDINGS_LEFT_OPEN = "generated with review findings still open — the files are kept and the findings are recorded against the case, but nothing acted on them";
14967
15059
  /**
14968
15060
  * The runCommand verification loop. One round asks the project's own commands,
14969
- * then the review, then the run — and a round is over when all three are
14970
- * clean. On anything else the output goes to Claude for a corrected set and
15061
+ * then the review and the run together — and a round is over when all three
15062
+ * are clean. On anything else the output goes to Claude for a corrected set and
14971
15063
  * the next round asks all three again: a fix written for a failing run can
14972
- * break a rule the round before it cleared.
15064
+ * break a rule the round before it cleared, and one written for a finding can
15065
+ * break the run.
14973
15066
  *
14974
15067
  * `fix.mode` mirrors the agent-browser target's fix UX (which only ever
14975
15068
  * prompts inside its own fix loop, never for the first write):
@@ -14981,8 +15074,9 @@ async function finalizeAndVerify(p) {
14981
15074
  * verify decides pass/fail.
14982
15075
  *
14983
15076
  * Exhaustion (or a declined / skipped fix) keeps the files on disk and reports
14984
- * `passed: false`. Targets without a runCommand are generate-only here and
14985
- * pass trivially.
15077
+ * `passed: false` — unless an earlier round's files passed their run: those
15078
+ * are put back and the generate passes with the findings open. Targets without
15079
+ * a runCommand are generate-only here and pass trivially.
14986
15080
  */
14987
15081
  async function runVerificationLoop(p, state) {
14988
15082
  const runCommand = p.ctx.targetConfig.runCommand;
@@ -15012,33 +15106,61 @@ async function runVerificationLoop(p, state) {
15012
15106
  const askReviewer = maxRetries > 0;
15013
15107
  let reviewerGone = false;
15014
15108
  if (!askReviewer && p.reading) info("no fix round could act on a review — the reviewer is not asked; the mechanical read still runs");
15109
+ let passing = null;
15110
+ const original = /* @__PURE__ */ new Map();
15111
+ const settle = async (attempt) => {
15112
+ if (passing === null) {
15113
+ await clearCaseRun(p.ctx.ref);
15114
+ return finish(false);
15115
+ }
15116
+ if (passing.attempt !== attempt) {
15117
+ await restoreFiles(state, passing.files, original);
15118
+ review = passing.review;
15119
+ warn("the final round did not pass — restored the files of the last round that did");
15120
+ }
15121
+ warn(FINDINGS_LEFT_OPEN);
15122
+ return finish(true);
15123
+ };
15124
+ if (captures) await clearCaseRun(p.ctx.ref);
15015
15125
  for (let attempt = 0;; attempt++) {
15016
15126
  let failing = await runCheckCommands(p.ctx);
15017
15127
  let asked = [];
15018
15128
  if (failing === null) {
15019
15129
  await refreshFromDisk(state);
15020
- const [reading, mechanical] = await Promise.all([readingOfEmitted(p, state, askReviewer && !reviewerGone), reviewOfEmitted(p.ctx, state, p.writeRoots)]);
15130
+ const questions = [
15131
+ readingOfEmitted(p, state, askReviewer && !reviewerGone),
15132
+ reviewOfEmitted(p.ctx, state, p.writeRoots),
15133
+ verifyRun(p, state, runCommand, attempt, captures)
15134
+ ];
15135
+ const [reading, mechanical, run] = await Promise.all(questions).catch(async (err) => {
15136
+ await Promise.allSettled(questions);
15137
+ await clearCaseRun(p.ctx.ref);
15138
+ throw err;
15139
+ });
15021
15140
  review = reading;
15022
- if (review?.reviewerFailed) reviewerGone = true;
15023
- const unchecked = uncheckedSteps(review, spent);
15024
- const violations = guideViolations(review, spent);
15141
+ if (run.exitCode === 0) passing = {
15142
+ files: new Map(state),
15143
+ review,
15144
+ attempt
15145
+ };
15146
+ const heard = review?.reviewerFailed ? void 0 : review;
15147
+ if (review && !heard) reviewerGone = true;
15148
+ const unchecked = uncheckedSteps(heard, spent);
15149
+ const violations = guideViolations(heard, spent);
15025
15150
  const reads = allReadings(mechanical, unchecked, violations);
15026
- if (reads !== null && attempt < maxRetries) {
15027
- asked = [...unchecked?.asked ?? [], ...violations?.asked ?? []];
15028
- failing = reads;
15029
- } else {
15030
- if (reads !== null) warn("generated with review findings still open — the files are kept and the findings are recorded against the case, but nothing acted on them");
15031
- const run = await verifyRun(p, state, runCommand, attempt, captures);
15032
- if (run.exitCode === 0) return finish(true);
15033
- failing = run;
15151
+ asked = [...unchecked?.asked ?? [], ...violations?.asked ?? []];
15152
+ if (run.exitCode !== 0) failing = reads === null ? run : allReadings(run, reads) ?? run;
15153
+ else if (reads !== null && attempt < maxRetries) failing = reads;
15154
+ else {
15155
+ if (reads !== null) warn(FINDINGS_LEFT_OPEN);
15156
+ return finish(true);
15034
15157
  }
15035
15158
  }
15036
15159
  const outputTail = scrubEnvValues(tail(failing.output), outputScrub);
15037
15160
  if (attempt >= maxRetries) {
15038
- warn(`verification still failing after ${maxRetries} fix attempt(s) — generated files kept`);
15161
+ warn(`verification still failing after ${maxRetries} fix attempt(s)`);
15039
15162
  logOutputTail(outputTail, warn);
15040
- await clearCaseRun(p.ctx.ref);
15041
- return finish(false);
15163
+ return settle(attempt);
15042
15164
  }
15043
15165
  fix(`verification failed (exit ${failing.exitCode}) — requesting a fix (${attempt + 1}/${maxRetries})`);
15044
15166
  logOutputTail(outputTail, fix);
@@ -15076,9 +15198,12 @@ async function runVerificationLoop(p, state) {
15076
15198
  continue;
15077
15199
  }
15078
15200
  if (p.ctx.fix.mode === "interactive" && !await confirmFixWrite(output.files, p.ctx.cwd)) {
15079
- info("fix not applied (declined) — keeping current files");
15080
- await clearCaseRun(p.ctx.ref);
15081
- return finish(false);
15201
+ info("fix not applied (declined)");
15202
+ return settle(attempt);
15203
+ }
15204
+ for (const f of output.files) {
15205
+ const rel = relative(p.ctx.cwd, resolve(p.ctx.cwd, f.path));
15206
+ if (!state.has(rel) && !original.has(rel)) original.set(rel, await readFile(resolve(p.ctx.cwd, f.path), "utf8").catch(() => null));
15082
15207
  }
15083
15208
  await writeGeneratedFiles(p.ctx.cwd, output.files, state);
15084
15209
  review = void 0;
@@ -15086,17 +15211,12 @@ async function runVerificationLoop(p, state) {
15086
15211
  }
15087
15212
  /**
15088
15213
  * One verification run of the files as they are now, in the shape the fix loop
15089
- * consumes. The round's last question and its most expensive: it is asked only
15090
- * of files the project's own commands and the review have already cleared.
15214
+ * consumes. Asked only of files the project's own commands have cleared.
15091
15215
  */
15092
15216
  async function verifyRun(p, state, runCommand, attempt, captures) {
15093
15217
  const testFiles = [...state.entries()].filter(([, f]) => f.kind === "test").map(([rel]) => rel);
15094
15218
  const artifactsDir = await mkdtemp(join(tmpdir(), "ccqa-verify-artifacts-"));
15095
- const evidenceDir = captures ? caseRunDir(p.ctx.ref) : null;
15096
- if (evidenceDir) {
15097
- await clearCaseRun(p.ctx.ref);
15098
- await mkdir(evidenceDir, { recursive: true });
15099
- }
15219
+ const evidenceDir = captures ? await mkdtemp(join(tmpdir(), "ccqa-verify-evidence-")) : null;
15100
15220
  let command = substituteArtifactsDir(substituteRunCommandFiles(runCommand, testFiles), artifactsDir);
15101
15221
  let traceUnavailable;
15102
15222
  if (evidenceDir) {
@@ -15120,6 +15240,8 @@ async function verifyRun(p, state, runCommand, attempt, captures) {
15120
15240
  warn: (message) => warn(`${p.ctx.ref.id}: ${message}`)
15121
15241
  });
15122
15242
  if (unavailable) warn(unavailable);
15243
+ await clearCaseRun(p.ctx.ref);
15244
+ await cp(evidenceDir, caseRunDir(p.ctx.ref), { recursive: true });
15123
15245
  }
15124
15246
  return {
15125
15247
  ...result,
@@ -15130,6 +15252,10 @@ async function verifyRun(p, state, runCommand, attempt, captures) {
15130
15252
  recursive: true,
15131
15253
  force: true
15132
15254
  });
15255
+ if (evidenceDir) await rm(evidenceDir, {
15256
+ recursive: true,
15257
+ force: true
15258
+ });
15133
15259
  }
15134
15260
  }
15135
15261
  /**
@@ -15150,6 +15276,10 @@ async function runCheckCommands(ctx) {
15150
15276
  }
15151
15277
  return null;
15152
15278
  }
15279
+ function blockStepIds(ctx) {
15280
+ const included = new Set(collectIncludedBlockNames(ctx.spec));
15281
+ return new Set(ctx.steps.filter((s) => included.has(s.source)).map((s) => s.id));
15282
+ }
15153
15283
  /**
15154
15284
  * The mechanical read of what this attempt wrote, shaped like a failed check
15155
15285
  * so the fix loop carries it the same way.
@@ -15167,6 +15297,7 @@ async function reviewOfEmitted(ctx, state, writeRoots) {
15167
15297
  ...ctx.expectations,
15168
15298
  ...ctx.cleanupExpectations
15169
15299
  ],
15300
+ blockSteps: blockStepIds(ctx),
15170
15301
  testPath: ctx.testPath
15171
15302
  });
15172
15303
  if (findings.length === 0) return null;
@@ -15212,6 +15343,23 @@ async function refreshFromDisk(state) {
15212
15343
  }
15213
15344
  }
15214
15345
  /**
15346
+ * Put the files back as `to` holds them. A path written after `to` was taken
15347
+ * goes back to what it held before the loop wrote it, or away if it was new.
15348
+ */
15349
+ async function restoreFiles(state, to, original) {
15350
+ for (const [rel, f] of state) {
15351
+ if (to.has(rel)) continue;
15352
+ const was = original.get(rel) ?? null;
15353
+ if (was === null) await rm(f.abs, { force: true });
15354
+ else await writeFile(f.abs, was, "utf8");
15355
+ }
15356
+ state.clear();
15357
+ for (const [rel, f] of to) {
15358
+ await writeFile(f.abs, f.contents, "utf8");
15359
+ state.set(rel, f);
15360
+ }
15361
+ }
15362
+ /**
15215
15363
  * The reading's findings, shaped like a failed check so the fix loop carries
15216
15364
  * them the same way — or null when there is nothing to act on.
15217
15365
  *
@@ -15224,9 +15372,8 @@ async function refreshFromDisk(state) {
15224
15372
  *
15225
15373
  * What the round asks for is verified like any other rewrite, and that can
15226
15374
  * end red: an assertion strengthened to check what the case claims may simply
15227
- * not hold. The generate then reports failed — on the test run, not on this —
15228
- * and the files are kept. That is the honest outcome, and the alternative
15229
- * (restoring what passed weakly) would hide a case the product does not meet.
15375
+ * not hold. The loop then goes back to the last version that passed, and the
15376
+ * finding stays open on the record rather than hidden.
15230
15377
  *
15231
15378
  * A review that could not be obtained is not acted on either: spending a round
15232
15379
  * answering a question nobody asked is worse than leaving it unanswered. Nor
@@ -15372,7 +15519,7 @@ function allReadings(...reports) {
15372
15519
  const found = reports.filter((r) => r !== null);
15373
15520
  if (found.length === 0) return null;
15374
15521
  return {
15375
- exitCode: 1,
15522
+ exitCode: found[0].exitCode,
15376
15523
  command: found.map((r) => r.command).join(" + "),
15377
15524
  output: found.map((r) => `${r.command}\n\n${r.output}`).join("\n\n")
15378
15525
  };
@@ -25286,12 +25433,11 @@ function isPassiveAction(action) {
25286
25433
  * The previous recording as the commands that perform it, for the trace prompt:
25287
25434
  * a re-record is usually one moved screen in an otherwise intact route.
25288
25435
  */
25289
- function renderPreviousRecording(actions, sessionName, currentStepIds) {
25290
- const current = new Set([SETUP_STEP_ID, ...currentStepIds]);
25436
+ function renderPreviousRecording(actions, sessionName, replayable) {
25291
25437
  const brokenStep = actions.find(failedToReplay)?.stepId;
25292
25438
  const byStep = /* @__PURE__ */ new Map();
25293
25439
  for (const action of actions) {
25294
- if (action.stepId === void 0 || !current.has(action.stepId)) continue;
25440
+ if (action.stepId === void 0 || !replayable.has(action.stepId)) continue;
25295
25441
  const command = previousCommand(action, sessionName, action.stepId === brokenStep);
25296
25442
  if (command === null) continue;
25297
25443
  const lines = byStep.get(action.stepId) ?? [];
@@ -25303,9 +25449,9 @@ function renderPreviousRecording(actions, sessionName, currentStepIds) {
25303
25449
  ## Previous recording
25304
25450
 
25305
25451
  This case was recorded before. Below is what that recording did, step by step,
25306
- as the commands that perform it. The application may have moved since, and the
25307
- steps above may have been edited: **the steps above are the contract, this is a
25308
- map.**
25452
+ as the commands that perform it, for the steps whose text has not changed since.
25453
+ The application may have moved since: **the steps above are the contract, this
25454
+ is a map.**
25309
25455
 
25310
25456
  For a step listed here, this replaces the snapshot-first routine of the
25311
25457
  Execution Workflow:
@@ -25319,19 +25465,16 @@ Execution Workflow:
25319
25465
  3. When it reports a failure, the commands before it are recorded and the
25320
25466
  page is where they left it: finish that step with the Execution Workflow —
25321
25467
  snapshot, find what works, record it — without re-running what was
25322
- recorded. A step whose instruction or \`Expected\` asks for something these
25323
- commands do not do skips \`replay_step\` and uses the Execution Workflow
25324
- from the start. The next step goes back to \`replay_step\`.
25325
- 4. A step you record yourself takes its assertions from its current
25468
+ recorded. The next step goes back to \`replay_step\`.
25469
+ 4. A step you finish yourself takes its assertions from its current
25326
25470
  \`Expected\`: keep a previous check only while it verifies what
25327
- \`Expected\` says now. A step whose \`Expected\` has changed is one to
25328
- record yourself, since \`replay_step\` records the previous checks as
25329
- they are.
25471
+ \`Expected\` says now.
25330
25472
  5. A command marked \`# did not replay last time\` is where the previous
25331
25473
  recording's own validation found the route broken: expect that step to
25332
25474
  need the Execution Workflow, and do not keep a form that only half works.
25333
25475
 
25334
- A step with no section here is recorded the usual way.
25476
+ A step with no section here — new, or edited since that recording — is
25477
+ recorded the usual way.
25335
25478
 
25336
25479
  ${[...byStep].map(([stepId, lines]) => `### ${stepId}\n\n\`\`\`bash\n${lines.join("\n")}\n\`\`\``).join("\n\n")}
25337
25480
  `;
@@ -25404,7 +25547,7 @@ ${input.instruction}
25404
25547
  const expectationsText = renderExpectations(input.expectations ?? []) + renderCleanupExpectations(input.cleanupExpectations ?? []);
25405
25548
  const contextText = renderContext(input.context ?? []);
25406
25549
  const conventionsText = renderConventions(input.conventions ?? []);
25407
- const previousText = renderPreviousRecording(input.previousRecording ?? [], sessionName, input.steps.map((s) => s.id));
25550
+ const previousText = input.previousRecording ? renderPreviousRecording(input.previousRecording.actions, sessionName, input.previousRecording.replayable) : "";
25408
25551
  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.
25409
25552
 
25410
25553
  ## Session
@@ -25541,6 +25684,7 @@ find nth <index> "<ALLOWED-css>" <action>
25541
25684
  6. If repeated labels make every ALLOWED selector ambiguous → use the \`find\` subset above.
25542
25685
  7. Never guess. If a selector fails, take a fresh snapshot before retrying.
25543
25686
  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).
25687
+ 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.
25544
25688
 
25545
25689
  ### Special input types
25546
25690
 
@@ -25890,6 +26034,39 @@ ${conventions.map((c) => `### ${c.heading}\n\n${c.body}`).join("\n\n")}
25890
26034
  `;
25891
26035
  }
25892
26036
  //#endregion
26037
+ //#region src/ir/step-digests.ts
26038
+ /**
26039
+ * What each step was asked to do when it was recorded, by step id. Case-level
26040
+ * checks land on whichever step the recorder chose, so they are part of every
26041
+ * step's; setup has no text of its own and stands for the case's context.
26042
+ */
26043
+ function stepDigests(testCase) {
26044
+ return {
26045
+ [SETUP_STEP_ID]: digest(testCase.context),
26046
+ ...Object.fromEntries(testCase.steps.filter(isExpandedActionStep).map((s) => [s.id, digest([
26047
+ s.instruction,
26048
+ s.expected,
26049
+ testCase.expectations
26050
+ ])])),
26051
+ ...Object.fromEntries(testCase.cleanup.map((s) => [s.id, digest([
26052
+ s.instruction,
26053
+ s.expected,
26054
+ testCase.cleanupExpectations
26055
+ ])]))
26056
+ };
26057
+ }
26058
+ /**
26059
+ * The steps whose previous commands may be replayed: those still asked to do
26060
+ * what they were then. An edited, added or renumbered step, or any step of a
26061
+ * recording without digests, would replay what the old step asked.
26062
+ */
26063
+ function unchangedSteps(previous, current) {
26064
+ return new Set(Object.keys(current).filter((id) => previous?.[id] !== void 0 && previous[id] === current[id]));
26065
+ }
26066
+ function digest(value) {
26067
+ return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16);
26068
+ }
26069
+ //#endregion
25893
26070
  //#region src/cli/trace-tools.ts
25894
26071
  const TRACE_TOOLS_SERVER = "ccqa";
25895
26072
  /** A wedged command must not hold the whole trace; agent-browser's own waits are far shorter. */
@@ -25935,10 +26112,12 @@ async function runCommands(commands, input) {
25935
26112
  const checkpoint = input.beforeAbCommand(extractCcqaStepFromBashCommand(cmd) ?? void 0);
25936
26113
  if (checkpoint) return stop(checkpoint);
25937
26114
  info(`$ ${scrubEnvValues(cmd, input.envScrubMap)}`);
25938
- const result = await runShell(cmd, {
26115
+ const env = {
25939
26116
  ...process.env,
25940
26117
  ...input.env
25941
- });
26118
+ };
26119
+ let result = await runShell(cmd, env);
26120
+ if (result.status !== 0 && scrollCoveredIntoView(["--session", input.sessionName], abPositionalTokens(cmd), result)) result = await runShell(cmd, env);
25942
26121
  const out = result.stdout.trim();
25943
26122
  if (!(result.status === 0 && (recorded?.holds ? recorded.holds(out) : true))) {
25944
26123
  const detail = (result.stderr || out || `exit ${result.status ?? "timeout"}`).trim().slice(0, 600);
@@ -25979,7 +26158,7 @@ function cap(out) {
25979
26158
  }
25980
26159
  function replayStep(step, input) {
25981
26160
  const actions = input.previous.filter((a) => a.stepId === step && a.action !== "snapshot").map(({ replayUnstable: _u, replayReason: _r, ...a }) => a);
25982
- if (actions.length === 0) return `No previous commands for ${step}. Record it with the Execution Workflow.`;
26161
+ 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.`;
25983
26162
  const { passed, unchecked, failed } = replayUntilFailure(actions, {
25984
26163
  sessionName: input.sessionName,
25985
26164
  envOverrides: { CCQA_RUN_ID: input.env["CCQA_RUN_ID"] ?? input.sessionName }
@@ -26041,9 +26220,12 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
26041
26220
  for (const w of conventions.warnings) warn(w);
26042
26221
  const caseRef = testCase.ref;
26043
26222
  const previous = await tryGetRecording(caseRef);
26223
+ const digests = stepDigests(testCase);
26224
+ const replayable = unchangedSteps(previous?.stepDigests, digests);
26044
26225
  const previousActions = previous ? [...previous.actions, ...previous.cleanup ?? []] : [];
26045
- if (previousActions.length > 0) meta("previous", opts.freshIr ? "ignored (--fresh-ir)" : `${previousActions.length} action(s) offered as a map`);
26046
- const offersMap = !opts.freshIr && previousActions.length > 0;
26226
+ const replayableActions = previousActions.filter((a) => a.stepId !== void 0 && replayable.has(a.stepId));
26227
+ 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)");
26228
+ const offersMap = !opts.freshIr && replayableActions.length > 0;
26047
26229
  const baseSystemPrompt = buildTraceSystemPrompt({
26048
26230
  title: testCase.title,
26049
26231
  steps,
@@ -26056,7 +26238,10 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
26056
26238
  ...testCase.cleanupExpectations.length > 0 ? { cleanupExpectations: testCase.cleanupExpectations } : {},
26057
26239
  ...testCase.context.length > 0 ? { context: testCase.context } : {},
26058
26240
  ...opts.instruction ? { instruction: opts.instruction } : {},
26059
- ...offersMap ? { previousRecording: previousActions } : {}
26241
+ ...offersMap ? { previousRecording: {
26242
+ actions: previousActions,
26243
+ replayable
26244
+ } } : {}
26060
26245
  });
26061
26246
  const promptBundle = await loadPromptBundle(opts.hubContext ?? null, "record", opts.cwd ?? process.cwd());
26062
26247
  if (promptBundle !== null) meta("prompt", promptBundle.loaded.join(" + "));
@@ -26115,7 +26300,7 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
26115
26300
  runId: sessionName
26116
26301
  });
26117
26302
  const traceTools = buildTraceTools({
26118
- previous: offersMap ? previousActions : [],
26303
+ previous: offersMap ? replayableActions : [],
26119
26304
  sessionName,
26120
26305
  env: invokeBase.env ?? {},
26121
26306
  envScrubMap,
@@ -26185,7 +26370,7 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
26185
26370
  const routeActions = validatedActions.filter((a) => !cleanupIds.has(a.stepId ?? ""));
26186
26371
  const cleanupActions = validatedActions.filter((a) => cleanupIds.has(a.stepId ?? ""));
26187
26372
  if (overallStatus === "passed") {
26188
- const saved = await saveRecording(caseRef, routeActions, cleanupActions);
26373
+ const saved = await saveRecording(caseRef, routeActions, cleanupActions, digests);
26189
26374
  recordingPath = saved.path;
26190
26375
  if (previous) await reportRouteDiff(caseRef, previous, saved.recording, steps);
26191
26376
  else await removeRouteDiff(caseRef);
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.60.0",
3
+ "version": "1.60.2",
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.60.0",
3
+ "version": "1.60.2",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {