ccqa 1.60.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;
@@ -25286,12 +25335,11 @@ function isPassiveAction(action) {
25286
25335
  * The previous recording as the commands that perform it, for the trace prompt:
25287
25336
  * a re-record is usually one moved screen in an otherwise intact route.
25288
25337
  */
25289
- function renderPreviousRecording(actions, sessionName, currentStepIds) {
25290
- const current = new Set([SETUP_STEP_ID, ...currentStepIds]);
25338
+ function renderPreviousRecording(actions, sessionName, replayable) {
25291
25339
  const brokenStep = actions.find(failedToReplay)?.stepId;
25292
25340
  const byStep = /* @__PURE__ */ new Map();
25293
25341
  for (const action of actions) {
25294
- if (action.stepId === void 0 || !current.has(action.stepId)) continue;
25342
+ if (action.stepId === void 0 || !replayable.has(action.stepId)) continue;
25295
25343
  const command = previousCommand(action, sessionName, action.stepId === brokenStep);
25296
25344
  if (command === null) continue;
25297
25345
  const lines = byStep.get(action.stepId) ?? [];
@@ -25303,9 +25351,9 @@ function renderPreviousRecording(actions, sessionName, currentStepIds) {
25303
25351
  ## Previous recording
25304
25352
 
25305
25353
  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.**
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.**
25309
25357
 
25310
25358
  For a step listed here, this replaces the snapshot-first routine of the
25311
25359
  Execution Workflow:
@@ -25319,19 +25367,16 @@ Execution Workflow:
25319
25367
  3. When it reports a failure, the commands before it are recorded and the
25320
25368
  page is where they left it: finish that step with the Execution Workflow —
25321
25369
  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
25370
+ recorded. The next step goes back to \`replay_step\`.
25371
+ 4. A step you finish yourself takes its assertions from its current
25326
25372
  \`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.
25373
+ \`Expected\` says now.
25330
25374
  5. A command marked \`# did not replay last time\` is where the previous
25331
25375
  recording's own validation found the route broken: expect that step to
25332
25376
  need the Execution Workflow, and do not keep a form that only half works.
25333
25377
 
25334
- 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.
25335
25380
 
25336
25381
  ${[...byStep].map(([stepId, lines]) => `### ${stepId}\n\n\`\`\`bash\n${lines.join("\n")}\n\`\`\``).join("\n\n")}
25337
25382
  `;
@@ -25404,7 +25449,7 @@ ${input.instruction}
25404
25449
  const expectationsText = renderExpectations(input.expectations ?? []) + renderCleanupExpectations(input.cleanupExpectations ?? []);
25405
25450
  const contextText = renderContext(input.context ?? []);
25406
25451
  const conventionsText = renderConventions(input.conventions ?? []);
25407
- 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) : "";
25408
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.
25409
25454
 
25410
25455
  ## Session
@@ -25541,6 +25586,7 @@ find nth <index> "<ALLOWED-css>" <action>
25541
25586
  6. If repeated labels make every ALLOWED selector ambiguous → use the \`find\` subset above.
25542
25587
  7. Never guess. If a selector fails, take a fresh snapshot before retrying.
25543
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.
25544
25590
 
25545
25591
  ### Special input types
25546
25592
 
@@ -25890,6 +25936,39 @@ ${conventions.map((c) => `### ${c.heading}\n\n${c.body}`).join("\n\n")}
25890
25936
  `;
25891
25937
  }
25892
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
25893
25972
  //#region src/cli/trace-tools.ts
25894
25973
  const TRACE_TOOLS_SERVER = "ccqa";
25895
25974
  /** A wedged command must not hold the whole trace; agent-browser's own waits are far shorter. */
@@ -25935,10 +26014,12 @@ async function runCommands(commands, input) {
25935
26014
  const checkpoint = input.beforeAbCommand(extractCcqaStepFromBashCommand(cmd) ?? void 0);
25936
26015
  if (checkpoint) return stop(checkpoint);
25937
26016
  info(`$ ${scrubEnvValues(cmd, input.envScrubMap)}`);
25938
- const result = await runShell(cmd, {
26017
+ const env = {
25939
26018
  ...process.env,
25940
26019
  ...input.env
25941
- });
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);
25942
26023
  const out = result.stdout.trim();
25943
26024
  if (!(result.status === 0 && (recorded?.holds ? recorded.holds(out) : true))) {
25944
26025
  const detail = (result.stderr || out || `exit ${result.status ?? "timeout"}`).trim().slice(0, 600);
@@ -25979,7 +26060,7 @@ function cap(out) {
25979
26060
  }
25980
26061
  function replayStep(step, input) {
25981
26062
  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.`;
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.`;
25983
26064
  const { passed, unchecked, failed } = replayUntilFailure(actions, {
25984
26065
  sessionName: input.sessionName,
25985
26066
  envOverrides: { CCQA_RUN_ID: input.env["CCQA_RUN_ID"] ?? input.sessionName }
@@ -26041,9 +26122,12 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
26041
26122
  for (const w of conventions.warnings) warn(w);
26042
26123
  const caseRef = testCase.ref;
26043
26124
  const previous = await tryGetRecording(caseRef);
26125
+ const digests = stepDigests(testCase);
26126
+ const replayable = unchangedSteps(previous?.stepDigests, digests);
26044
26127
  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;
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;
26047
26131
  const baseSystemPrompt = buildTraceSystemPrompt({
26048
26132
  title: testCase.title,
26049
26133
  steps,
@@ -26056,7 +26140,10 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
26056
26140
  ...testCase.cleanupExpectations.length > 0 ? { cleanupExpectations: testCase.cleanupExpectations } : {},
26057
26141
  ...testCase.context.length > 0 ? { context: testCase.context } : {},
26058
26142
  ...opts.instruction ? { instruction: opts.instruction } : {},
26059
- ...offersMap ? { previousRecording: previousActions } : {}
26143
+ ...offersMap ? { previousRecording: {
26144
+ actions: previousActions,
26145
+ replayable
26146
+ } } : {}
26060
26147
  });
26061
26148
  const promptBundle = await loadPromptBundle(opts.hubContext ?? null, "record", opts.cwd ?? process.cwd());
26062
26149
  if (promptBundle !== null) meta("prompt", promptBundle.loaded.join(" + "));
@@ -26115,7 +26202,7 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
26115
26202
  runId: sessionName
26116
26203
  });
26117
26204
  const traceTools = buildTraceTools({
26118
- previous: offersMap ? previousActions : [],
26205
+ previous: offersMap ? replayableActions : [],
26119
26206
  sessionName,
26120
26207
  env: invokeBase.env ?? {},
26121
26208
  envScrubMap,
@@ -26185,7 +26272,7 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
26185
26272
  const routeActions = validatedActions.filter((a) => !cleanupIds.has(a.stepId ?? ""));
26186
26273
  const cleanupActions = validatedActions.filter((a) => cleanupIds.has(a.stepId ?? ""));
26187
26274
  if (overallStatus === "passed") {
26188
- const saved = await saveRecording(caseRef, routeActions, cleanupActions);
26275
+ const saved = await saveRecording(caseRef, routeActions, cleanupActions, digests);
26189
26276
  recordingPath = saved.path;
26190
26277
  if (previous) await reportRouteDiff(caseRef, previous, saved.recording, steps);
26191
26278
  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.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.60.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": {