ccqa 1.58.3 → 1.59.0

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.58.3",
3
+ "version": "1.59.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
@@ -415,7 +415,7 @@ function formatDuration(ms) {
415
415
  return `${minutes} minute${minutes === 1 ? "" : "s"}`;
416
416
  }
417
417
  async function invokeClaudeStreaming(options, onEvent) {
418
- const { prompt, systemPrompt, allowedTools, disableThinking = false, mcpServers, maxTurns, timeoutMs, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false, envScrubMap = [], relaxAbConstraints = false, additionalDirectories } = options;
418
+ const { prompt, systemPrompt, allowedTools, disableThinking = false, effort, bashGuard, mcpServers, maxTurns, timeoutMs, env, model, cwd, onAbAction, onAbActionFailed, beforeAbCommand, silenceBashLog = false, envScrubMap = [], relaxAbConstraints = false, additionalDirectories } = options;
419
419
  const resolvedModel = resolveModel(model);
420
420
  const mergedEnv = buildInvocationEnv(env);
421
421
  const abortController = new AbortController();
@@ -443,52 +443,35 @@ async function invokeClaudeStreaming(options, onEvent) {
443
443
  env: mergedEnv,
444
444
  ...mcpServers ? { mcpServers } : {},
445
445
  ...disableThinking ? { thinking: { type: "disabled" } } : {},
446
- hooks: onAbAction || onAbActionFailed ? {
446
+ ...effort ? { effort } : {},
447
+ hooks: onAbAction || onAbActionFailed || bashGuard || beforeAbCommand ? {
447
448
  PreToolUse: [{ hooks: [async (input) => {
448
449
  if (input.hook_event_name !== "PreToolUse") return {};
449
450
  if (input.tool_name !== "Bash") return {};
450
451
  const cmd = input.tool_input?.["command"];
451
452
  if (typeof cmd !== "string") return {};
453
+ const guarded = bashGuard?.(cmd);
454
+ if (guarded) return {
455
+ decision: "block",
456
+ reason: guarded
457
+ };
452
458
  if (!relaxAbConstraints) {
453
- if (isBlockedAbSubcommand(cmd)) return {
459
+ const reason = abCommandBlockReason(cmd, envScrubMap);
460
+ if (reason !== null) return {
454
461
  decision: "block",
455
- reason: "This agent-browser subcommand is not allowed because it cannot be recorded as a structured test action. Use only the standard commands: click, check, fill, select, hover, press, wait, find (with role/text/label/placeholder/alt/title/testid/first/last/nth). Take a fresh snapshot to find the correct selector."
462
+ reason
456
463
  };
457
- if (hasRefSelector(cmd)) return {
464
+ const checkpoint = beforeAbCommand?.(extractCcqaStepFromBashCommand(cmd) ?? void 0);
465
+ if (checkpoint) return {
458
466
  decision: "block",
459
- reason: "@ref selectors (like @e14) are session-specific and change every run. They cannot be used in generated tests. Use one of the allowed selector formats instead: [aria-label='...'], text=..., [placeholder='...'], or [type='password']. Take a fresh snapshot and find the element's aria-label or visible text. If an allowed selector already clicks the element but nothing happens, the element is clipped by an inner scroll container: `scrollintoview` it (addressed by a CSS selector, not `text=`) and click again — a @ref would not have fixed that either."
460
- };
461
- const bareTag = findPositionalBareTag(cmd);
462
- if (bareTag !== null) return {
463
- decision: "block",
464
- reason: `\`find ${bareTag.locator}\` with a bare tag selector (\`${bareTag.selector}\`) is rejected: it matches every <${bareTag.selector}> on the page and is non-deterministic on replay. Pass a specific attribute selector instead, e.g. \`find ${bareTag.locator} "[aria-label='...']" ${bareTag.action}\` or \`find ${bareTag.locator} "[data-qa='...']" ${bareTag.action}\`. Take a fresh snapshot to find the right attribute.`
465
- };
466
- if (hasMultipleAbInvocations(cmd)) return {
467
- decision: "block",
468
- reason: "Run each `agent-browser` call as its own Bash command. Chaining multiple invocations with &&, ;, |, or || prevents ccqa from recording them as discrete steps and lets failed attempts leak into the trace. Issue one Bash tool call per agent-browser command."
469
- };
470
- if (hasErrorSuppression(cmd)) return {
471
- decision: "block",
472
- reason: "Do not suppress errors on `agent-browser` commands. Remove `|| true`, `|| :`, `2>/dev/null`, `; true`, and similar redirects so ccqa can detect failures and roll back unsuccessful attempts. Run the command standalone and let it surface its exit code."
473
- };
474
- const runProducedUrl = findRunProducedOpenUrl(cmd, envScrubMap);
475
- if (runProducedUrl !== null) return {
476
- decision: "block",
477
- reason: `Do not open ${runProducedUrl} — that address was produced by this run (the id in it belongs to a record this run created), so the generated test would open a record later runs do not have. Reach the page the way a person does: click through from where the run already is. If the spec's instruction really names this exact address, put the id in a profile variable so it survives replay.`
467
+ reason: checkpoint
478
468
  };
479
469
  }
480
- const assertMarker = relaxAbConstraints ? null : extractCcqaAssertFromBashCommand(cmd);
481
- const ab = relaxAbConstraints ? null : extractAbActionFromBashCommand(cmd) ?? (assertMarker !== null ? extractObservationAbAction(cmd) : null);
482
- if ((ab !== null || assertMarker !== null) && onAbAction) {
470
+ const recorded = relaxAbConstraints ? null : abActionEventFromCommand(cmd);
471
+ if (recorded !== null && onAbAction) {
483
472
  lastAbToolUseId = input.tool_use_id;
484
- lastAbAnswerHolds = assertMarker === null ? null : markerHolds(assertMarker, ab);
485
- const stepId = extractCcqaStepFromBashCommand(cmd);
486
- onAbAction({
487
- ...ab !== null ? { abAction: ab } : {},
488
- ...stepId ? { stepId } : {},
489
- ...assertMarker !== null ? { assertMarker } : {},
490
- ...hasCcqaSecretPrefix(cmd) ? { secret: true } : {}
491
- });
473
+ lastAbAnswerHolds = recorded.holds;
474
+ onAbAction(recorded.event);
492
475
  } else lastAbToolUseId = null;
493
476
  return {};
494
477
  }] }],
@@ -1076,6 +1059,41 @@ async function fireMockPreToolUseHooks(msg, options) {
1076
1059
  for (const matcher of matchers) for (const hook of matcher.hooks) await hook(input, block.id, { signal: new AbortController().signal });
1077
1060
  }
1078
1061
  }
1062
+ /**
1063
+ * Why the trace refuses this agent-browser command, or null when it may run.
1064
+ * Shared by the Bash hook and the trace's own batch tool, so both enforce the
1065
+ * same replayability contract.
1066
+ */
1067
+ function abCommandBlockReason(cmd, envScrubMap = []) {
1068
+ if (isBlockedAbSubcommand(cmd)) return "This agent-browser subcommand is not allowed because it cannot be recorded as a structured test action. Use only the standard commands: click, check, fill, select, hover, press, wait, find (with role/text/label/placeholder/alt/title/testid/first/last/nth). Take a fresh snapshot to find the correct selector.";
1069
+ if (hasRefSelector(cmd)) return "@ref selectors (like @e14) are session-specific and change every run. They cannot be used in generated tests. Use one of the allowed selector formats instead: [aria-label='...'], text=..., [placeholder='...'], or [type='password']. Take a fresh snapshot and find the element's aria-label or visible text. If an allowed selector already clicks the element but nothing happens, the element is clipped by an inner scroll container: `scrollintoview` it (addressed by a CSS selector, not `text=`) and click again — a @ref would not have fixed that either.";
1070
+ const bareTag = findPositionalBareTag(cmd);
1071
+ if (bareTag !== null) return `\`find ${bareTag.locator}\` with a bare tag selector (\`${bareTag.selector}\`) is rejected: it matches every <${bareTag.selector}> on the page and is non-deterministic on replay. Pass a specific attribute selector instead, e.g. \`find ${bareTag.locator} "[aria-label='...']" ${bareTag.action}\` or \`find ${bareTag.locator} "[data-qa='...']" ${bareTag.action}\`. Take a fresh snapshot to find the right attribute.`;
1072
+ if (hasMultipleAbInvocations(cmd)) return "Run each `agent-browser` call as its own Bash command. Chaining multiple invocations with &&, ;, |, or || prevents ccqa from recording them as discrete steps and lets failed attempts leak into the trace. Issue one Bash tool call per agent-browser command.";
1073
+ if (hasErrorSuppression(cmd)) return "Do not suppress errors on `agent-browser` commands. Remove `|| true`, `|| :`, `2>/dev/null`, `; true`, and similar redirects so ccqa can detect failures and roll back unsuccessful attempts. Run the command standalone and let it surface its exit code.";
1074
+ const runProducedUrl = findRunProducedOpenUrl(cmd, envScrubMap);
1075
+ if (runProducedUrl !== null) return `Do not open ${runProducedUrl} — that address was produced by this run (the id in it belongs to a record this run created), so the generated test would open a record later runs do not have. Reach the page the way a person does: click through from where the run already is. If the spec's instruction really names this exact address, put the id in a profile variable so it survives replay.`;
1076
+ return null;
1077
+ }
1078
+ /**
1079
+ * What recording this command produces, and — for a marked probe — what its
1080
+ * output must say for the check to hold. Null when it records nothing.
1081
+ */
1082
+ function abActionEventFromCommand(cmd) {
1083
+ const assertMarker = extractCcqaAssertFromBashCommand(cmd);
1084
+ const ab = extractAbActionFromBashCommand(cmd) ?? (assertMarker !== null ? extractObservationAbAction(cmd) : null);
1085
+ if (ab === null && assertMarker === null) return null;
1086
+ const stepId = extractCcqaStepFromBashCommand(cmd);
1087
+ return {
1088
+ event: {
1089
+ ...ab !== null ? { abAction: ab } : {},
1090
+ ...stepId ? { stepId } : {},
1091
+ ...assertMarker !== null ? { assertMarker } : {},
1092
+ ...hasCcqaSecretPrefix(cmd) ? { secret: true } : {}
1093
+ },
1094
+ holds: assertMarker === null ? null : markerHolds(assertMarker, ab)
1095
+ };
1096
+ }
1079
1097
  //#endregion
1080
1098
  //#region src/drift/auth.ts
1081
1099
  /**
@@ -414,7 +414,7 @@ function formatDuration(ms) {
414
414
  return `${minutes} minute${minutes === 1 ? "" : "s"}`;
415
415
  }
416
416
  async function invokeClaudeStreaming(options, onEvent) {
417
- const { prompt, systemPrompt, allowedTools, disableThinking = false, mcpServers, maxTurns, timeoutMs, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false, envScrubMap = [], relaxAbConstraints = false, additionalDirectories } = options;
417
+ const { prompt, systemPrompt, allowedTools, disableThinking = false, effort, bashGuard, mcpServers, maxTurns, timeoutMs, env, model, cwd, onAbAction, onAbActionFailed, beforeAbCommand, silenceBashLog = false, envScrubMap = [], relaxAbConstraints = false, additionalDirectories } = options;
418
418
  const resolvedModel = resolveModel(model);
419
419
  const mergedEnv = buildInvocationEnv(env);
420
420
  const abortController = new AbortController();
@@ -442,52 +442,35 @@ async function invokeClaudeStreaming(options, onEvent) {
442
442
  env: mergedEnv,
443
443
  ...mcpServers ? { mcpServers } : {},
444
444
  ...disableThinking ? { thinking: { type: "disabled" } } : {},
445
- hooks: onAbAction || onAbActionFailed ? {
445
+ ...effort ? { effort } : {},
446
+ hooks: onAbAction || onAbActionFailed || bashGuard || beforeAbCommand ? {
446
447
  PreToolUse: [{ hooks: [async (input) => {
447
448
  if (input.hook_event_name !== "PreToolUse") return {};
448
449
  if (input.tool_name !== "Bash") return {};
449
450
  const cmd = input.tool_input?.["command"];
450
451
  if (typeof cmd !== "string") return {};
452
+ const guarded = bashGuard?.(cmd);
453
+ if (guarded) return {
454
+ decision: "block",
455
+ reason: guarded
456
+ };
451
457
  if (!relaxAbConstraints) {
452
- if (isBlockedAbSubcommand(cmd)) return {
458
+ const reason = abCommandBlockReason(cmd, envScrubMap);
459
+ if (reason !== null) return {
453
460
  decision: "block",
454
- reason: "This agent-browser subcommand is not allowed because it cannot be recorded as a structured test action. Use only the standard commands: click, check, fill, select, hover, press, wait, find (with role/text/label/placeholder/alt/title/testid/first/last/nth). Take a fresh snapshot to find the correct selector."
461
+ reason
455
462
  };
456
- if (hasRefSelector(cmd)) return {
463
+ const checkpoint = beforeAbCommand?.(extractCcqaStepFromBashCommand(cmd) ?? void 0);
464
+ if (checkpoint) return {
457
465
  decision: "block",
458
- reason: "@ref selectors (like @e14) are session-specific and change every run. They cannot be used in generated tests. Use one of the allowed selector formats instead: [aria-label='...'], text=..., [placeholder='...'], or [type='password']. Take a fresh snapshot and find the element's aria-label or visible text. If an allowed selector already clicks the element but nothing happens, the element is clipped by an inner scroll container: `scrollintoview` it (addressed by a CSS selector, not `text=`) and click again — a @ref would not have fixed that either."
459
- };
460
- const bareTag = findPositionalBareTag(cmd);
461
- if (bareTag !== null) return {
462
- decision: "block",
463
- reason: `\`find ${bareTag.locator}\` with a bare tag selector (\`${bareTag.selector}\`) is rejected: it matches every <${bareTag.selector}> on the page and is non-deterministic on replay. Pass a specific attribute selector instead, e.g. \`find ${bareTag.locator} "[aria-label='...']" ${bareTag.action}\` or \`find ${bareTag.locator} "[data-qa='...']" ${bareTag.action}\`. Take a fresh snapshot to find the right attribute.`
464
- };
465
- if (hasMultipleAbInvocations(cmd)) return {
466
- decision: "block",
467
- reason: "Run each `agent-browser` call as its own Bash command. Chaining multiple invocations with &&, ;, |, or || prevents ccqa from recording them as discrete steps and lets failed attempts leak into the trace. Issue one Bash tool call per agent-browser command."
468
- };
469
- if (hasErrorSuppression(cmd)) return {
470
- decision: "block",
471
- reason: "Do not suppress errors on `agent-browser` commands. Remove `|| true`, `|| :`, `2>/dev/null`, `; true`, and similar redirects so ccqa can detect failures and roll back unsuccessful attempts. Run the command standalone and let it surface its exit code."
472
- };
473
- const runProducedUrl = findRunProducedOpenUrl(cmd, envScrubMap);
474
- if (runProducedUrl !== null) return {
475
- decision: "block",
476
- reason: `Do not open ${runProducedUrl} — that address was produced by this run (the id in it belongs to a record this run created), so the generated test would open a record later runs do not have. Reach the page the way a person does: click through from where the run already is. If the spec's instruction really names this exact address, put the id in a profile variable so it survives replay.`
466
+ reason: checkpoint
477
467
  };
478
468
  }
479
- const assertMarker = relaxAbConstraints ? null : extractCcqaAssertFromBashCommand(cmd);
480
- const ab = relaxAbConstraints ? null : extractAbActionFromBashCommand(cmd) ?? (assertMarker !== null ? extractObservationAbAction(cmd) : null);
481
- if ((ab !== null || assertMarker !== null) && onAbAction) {
469
+ const recorded = relaxAbConstraints ? null : abActionEventFromCommand(cmd);
470
+ if (recorded !== null && onAbAction) {
482
471
  lastAbToolUseId = input.tool_use_id;
483
- lastAbAnswerHolds = assertMarker === null ? null : markerHolds(assertMarker, ab);
484
- const stepId = extractCcqaStepFromBashCommand(cmd);
485
- onAbAction({
486
- ...ab !== null ? { abAction: ab } : {},
487
- ...stepId ? { stepId } : {},
488
- ...assertMarker !== null ? { assertMarker } : {},
489
- ...hasCcqaSecretPrefix(cmd) ? { secret: true } : {}
490
- });
472
+ lastAbAnswerHolds = recorded.holds;
473
+ onAbAction(recorded.event);
491
474
  } else lastAbToolUseId = null;
492
475
  return {};
493
476
  }] }],
@@ -1075,6 +1058,41 @@ async function fireMockPreToolUseHooks(msg, options) {
1075
1058
  for (const matcher of matchers) for (const hook of matcher.hooks) await hook(input, block.id, { signal: new AbortController().signal });
1076
1059
  }
1077
1060
  }
1061
+ /**
1062
+ * Why the trace refuses this agent-browser command, or null when it may run.
1063
+ * Shared by the Bash hook and the trace's own batch tool, so both enforce the
1064
+ * same replayability contract.
1065
+ */
1066
+ function abCommandBlockReason(cmd, envScrubMap = []) {
1067
+ if (isBlockedAbSubcommand(cmd)) return "This agent-browser subcommand is not allowed because it cannot be recorded as a structured test action. Use only the standard commands: click, check, fill, select, hover, press, wait, find (with role/text/label/placeholder/alt/title/testid/first/last/nth). Take a fresh snapshot to find the correct selector.";
1068
+ if (hasRefSelector(cmd)) return "@ref selectors (like @e14) are session-specific and change every run. They cannot be used in generated tests. Use one of the allowed selector formats instead: [aria-label='...'], text=..., [placeholder='...'], or [type='password']. Take a fresh snapshot and find the element's aria-label or visible text. If an allowed selector already clicks the element but nothing happens, the element is clipped by an inner scroll container: `scrollintoview` it (addressed by a CSS selector, not `text=`) and click again — a @ref would not have fixed that either.";
1069
+ const bareTag = findPositionalBareTag(cmd);
1070
+ if (bareTag !== null) return `\`find ${bareTag.locator}\` with a bare tag selector (\`${bareTag.selector}\`) is rejected: it matches every <${bareTag.selector}> on the page and is non-deterministic on replay. Pass a specific attribute selector instead, e.g. \`find ${bareTag.locator} "[aria-label='...']" ${bareTag.action}\` or \`find ${bareTag.locator} "[data-qa='...']" ${bareTag.action}\`. Take a fresh snapshot to find the right attribute.`;
1071
+ if (hasMultipleAbInvocations(cmd)) return "Run each `agent-browser` call as its own Bash command. Chaining multiple invocations with &&, ;, |, or || prevents ccqa from recording them as discrete steps and lets failed attempts leak into the trace. Issue one Bash tool call per agent-browser command.";
1072
+ if (hasErrorSuppression(cmd)) return "Do not suppress errors on `agent-browser` commands. Remove `|| true`, `|| :`, `2>/dev/null`, `; true`, and similar redirects so ccqa can detect failures and roll back unsuccessful attempts. Run the command standalone and let it surface its exit code.";
1073
+ const runProducedUrl = findRunProducedOpenUrl(cmd, envScrubMap);
1074
+ if (runProducedUrl !== null) return `Do not open ${runProducedUrl} — that address was produced by this run (the id in it belongs to a record this run created), so the generated test would open a record later runs do not have. Reach the page the way a person does: click through from where the run already is. If the spec's instruction really names this exact address, put the id in a profile variable so it survives replay.`;
1075
+ return null;
1076
+ }
1077
+ /**
1078
+ * What recording this command produces, and — for a marked probe — what its
1079
+ * output must say for the check to hold. Null when it records nothing.
1080
+ */
1081
+ function abActionEventFromCommand(cmd) {
1082
+ const assertMarker = extractCcqaAssertFromBashCommand(cmd);
1083
+ const ab = extractAbActionFromBashCommand(cmd) ?? (assertMarker !== null ? extractObservationAbAction(cmd) : null);
1084
+ if (ab === null && assertMarker === null) return null;
1085
+ const stepId = extractCcqaStepFromBashCommand(cmd);
1086
+ return {
1087
+ event: {
1088
+ ...ab !== null ? { abAction: ab } : {},
1089
+ ...stepId ? { stepId } : {},
1090
+ ...assertMarker !== null ? { assertMarker } : {},
1091
+ ...hasCcqaSecretPrefix(cmd) ? { secret: true } : {}
1092
+ },
1093
+ holds: assertMarker === null ? null : markerHolds(assertMarker, ab)
1094
+ };
1095
+ }
1078
1096
  //#endregion
1079
1097
  //#region src/drift/auth.ts
1080
1098
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "1.58.3",
3
+ "version": "1.59.0",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {