ccqa 0.3.3 → 0.3.5

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
@@ -4,7 +4,7 @@ import { Command } from "commander";
4
4
  import { accessSync, readFileSync } from "node:fs";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises";
7
- import { dirname, join, resolve } from "node:path";
7
+ import { delimiter, dirname, join, resolve } from "node:path";
8
8
  import { query } from "@anthropic-ai/claude-agent-sdk";
9
9
  import matter from "gray-matter";
10
10
  import { spawn } from "node:child_process";
@@ -706,10 +706,10 @@ function bundledVitestConfigPath() {
706
706
  }
707
707
  //#endregion
708
708
  //#region src/runtime/spawn-vitest.ts
709
- const require = createRequire(import.meta.url);
709
+ const require$1 = createRequire(import.meta.url);
710
710
  function resolveVitestBin() {
711
- const pkgPath = require.resolve("vitest/package.json");
712
- const pkg = require(pkgPath);
711
+ const pkgPath = require$1.resolve("vitest/package.json");
712
+ const pkg = require$1(pkgPath);
713
713
  const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.vitest;
714
714
  if (!binRel) throw new Error(`vitest package.json has no bin entry (resolved at ${pkgPath})`);
715
715
  return resolve(dirname(pkgPath), binRel);
@@ -761,6 +761,88 @@ function waitExit(child) {
761
761
  });
762
762
  }
763
763
  //#endregion
764
+ //#region src/runtime/agent-browser-bin.ts
765
+ const require = createRequire(import.meta.url);
766
+ /**
767
+ * Resolves the directory containing the `agent-browser` shim that npm/pnpm
768
+ * exposes on PATH for the peer-installed package. Used by `ccqa trace` to
769
+ * prepend this directory to PATH so the Claude subprocess can invoke
770
+ * `agent-browser ...` without requiring a global install.
771
+ *
772
+ * Returns null if agent-browser cannot be resolved (peer not installed).
773
+ */
774
+ function resolveAgentBrowserBinDir() {
775
+ let pkgJsonPath;
776
+ try {
777
+ pkgJsonPath = require.resolve("agent-browser/package.json");
778
+ } catch {
779
+ return null;
780
+ }
781
+ return join(dirname(pkgJsonPath), "node_modules", ".bin");
782
+ }
783
+ /**
784
+ * Returns a PATH string with the agent-browser shim directory prepended,
785
+ * so `agent-browser ...` resolves without a global install. Falls back to
786
+ * the original PATH when the package can't be resolved.
787
+ */
788
+ function pathWithAgentBrowserShim(currentPath) {
789
+ const path = currentPath ?? "";
790
+ const dir = resolveAgentBrowserBinDir();
791
+ if (!dir) return path;
792
+ if (path.split(delimiter).includes(dir)) return path;
793
+ return dir + delimiter + path;
794
+ }
795
+ //#endregion
796
+ //#region src/runtime/env-vars.ts
797
+ const ENV_VAR_RE = /\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g;
798
+ /**
799
+ * Returns true if the value contains at least one `$VAR` or `${VAR}` reference.
800
+ */
801
+ function hasEnvRef(value) {
802
+ ENV_VAR_RE.lastIndex = 0;
803
+ return ENV_VAR_RE.test(value);
804
+ }
805
+ /**
806
+ * Resolve every `$VAR` / `${VAR}` reference against the current process env.
807
+ *
808
+ * Missing variables expand to the empty string, mirroring `sh` behaviour.
809
+ * Throwing would force ccqa to be invoked with every var set even for
810
+ * unused setups, which is more user-hostile than letting the test fail
811
+ * downstream with a clearer message ("login form rejected: empty password").
812
+ */
813
+ function resolveEnvRefs(value) {
814
+ return value.replace(ENV_VAR_RE, (_, braced, plain) => {
815
+ const name = braced ?? plain ?? "";
816
+ return process.env[name] ?? "";
817
+ });
818
+ }
819
+ /**
820
+ * Embed `$VAR` / `${VAR}` as a JS template-literal expression that reads
821
+ * `process.env.VAR ?? ""` at runtime. Used by `ccqa generate` so the test
822
+ * script never bakes in the secret value.
823
+ *
824
+ * Returns a JavaScript string-literal expression (template literal when env
825
+ * refs are present, plain string literal otherwise).
826
+ *
827
+ * Examples:
828
+ * "${PASSWORD}" -> '`${process.env.PASSWORD ?? ""}`'
829
+ * "user-${SUFFIX}@x.com" -> '`user-${process.env.SUFFIX ?? ""}@x.com`'
830
+ * "literal value" -> '"literal value"'
831
+ */
832
+ function envRefsToJsExpression(value) {
833
+ if (!hasEnvRef(value)) return JSON.stringify(value);
834
+ const escaped = value.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, (match, offset, source) => {
835
+ ENV_VAR_RE.lastIndex = 0;
836
+ let m;
837
+ while ((m = ENV_VAR_RE.exec(source)) !== null) if (m.index === offset) return "${";
838
+ return "\\${";
839
+ });
840
+ ENV_VAR_RE.lastIndex = 0;
841
+ return `\`${escaped.replace(ENV_VAR_RE, (_, braced, plain) => {
842
+ return `\${process.env.${braced ?? plain ?? ""} ?? ""}`;
843
+ })}\``;
844
+ }
845
+ //#endregion
764
846
  //#region src/cli/trace.ts
765
847
  const traceCommand = new Command("trace").argument("<feature/spec>", "Spec to trace (e.g. tasks/create-and-complete)").description("Run agent-browser, verify assertions, and record structured actions").action(async (specPath) => {
766
848
  const { featureName, specName } = parseSpecPath(specPath);
@@ -801,7 +883,10 @@ async function runTrace(featureName, specName) {
801
883
  "Grep",
802
884
  "Glob"
803
885
  ],
804
- env: { AGENT_BROWSER_SESSION: sessionName },
886
+ env: {
887
+ AGENT_BROWSER_SESSION: sessionName,
888
+ PATH: pathWithAgentBrowserShim(process.env["PATH"])
889
+ },
805
890
  onAbAction: (abAction) => {
806
891
  const action = parseAbAction(abAction);
807
892
  if (action) traceActions.push(action);
@@ -857,7 +942,7 @@ async function runSetups(setups, sessionName) {
857
942
  let script = await readFile(scriptPath, "utf-8").catch(() => {
858
943
  throw new Error(`Setup test script not found: ${scriptPath}. Run \`ccqa generate-setup ${ref.name}\` first.`);
859
944
  });
860
- for (const [key, value] of Object.entries(ref.params ?? {})) script = script.replaceAll(`{{${key}}}`, value);
945
+ for (const [key, value] of Object.entries(ref.params ?? {})) script = script.replaceAll(`{{${key}}}`, resolveEnvRefs(value));
861
946
  script = script.replace(/process\.env\.AGENT_BROWSER_SESSION\s*=\s*`.+`;/, `process.env.AGENT_BROWSER_SESSION = ${JSON.stringify(sessionName)};`);
862
947
  const tmpPath = join(getSetupDir(ref.name), `_run.spec.ts`);
863
948
  await writeFile(tmpPath, script, "utf-8");
@@ -1214,24 +1299,50 @@ async function loadSetupScripts(setups) {
1214
1299
  return result;
1215
1300
  }
1216
1301
  /**
1217
- * Extract the test body (lines inside the first test() block) from a setup test script.
1302
+ * Extract the test body (statements inside the test callback) from a setup
1303
+ * test script.
1304
+ *
1305
+ * Locates the first arrow callback (`=> {`) after a top-level `test(` call
1306
+ * and returns the text between the matching `{` and `}`. Handles both
1307
+ * single-line and multi-line `test(...)` formatting (the latter is what
1308
+ * prettier produces).
1309
+ *
1310
+ * Brace tracking is naive (string/regex/comment literals are not parsed
1311
+ * specially), but setup test scripts are themselves generated by ccqa and
1312
+ * follow a fixed shape, so this is sufficient in practice.
1218
1313
  */
1219
1314
  function extractTestBody(script) {
1220
- const lines = script.split("\n");
1221
- const startIdx = lines.findIndex((l) => /^\s*test\(/.test(l));
1222
- if (startIdx === -1) return "";
1223
- const bodyLines = [];
1224
- for (let i = startIdx + 1; i < lines.length; i++) {
1225
- if (/^\s*\}[\s,);]/.test(lines[i])) break;
1226
- bodyLines.push(lines[i]);
1315
+ const testCallMatch = /\btest\s*\(/.exec(script);
1316
+ if (!testCallMatch) return "";
1317
+ const arrowIdx = script.indexOf("=> {", testCallMatch.index);
1318
+ if (arrowIdx === -1) return "";
1319
+ const bodyStart = arrowIdx + 4;
1320
+ let depth = 1;
1321
+ let i = bodyStart;
1322
+ for (; i < script.length; i++) {
1323
+ const ch = script[i];
1324
+ if (ch === "{") depth++;
1325
+ else if (ch === "}") {
1326
+ depth--;
1327
+ if (depth === 0) break;
1328
+ }
1227
1329
  }
1228
- return bodyLines.join("\n");
1330
+ if (depth !== 0) return "";
1331
+ return script.slice(bodyStart, i).replace(/^\n/, "").replace(/\n\s*$/, "");
1229
1332
  }
1230
1333
  function replacePlaceholders(body, params) {
1231
1334
  let result = body;
1232
- for (const [key, value] of Object.entries(params)) result = result.replaceAll(`{{${key}}}`, value);
1335
+ for (const [key, value] of Object.entries(params)) if (hasEnvRef(value)) {
1336
+ const expr = envRefsToJsExpression(value);
1337
+ const re = new RegExp(`(["'])\\{\\{${escapeRegExp(key)}\\}\\}\\1`, "g");
1338
+ result = result.replace(re, expr);
1339
+ result = result.replaceAll(`{{${key}}}`, value);
1340
+ } else result = result.replaceAll(`{{${key}}}`, value);
1233
1341
  return result;
1234
1342
  }
1343
+ function escapeRegExp(s) {
1344
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1345
+ }
1235
1346
  async function autoFixWithLLM$1(script, failureLog) {
1236
1347
  try {
1237
1348
  const { result, isError } = await invokeClaudeStreaming({
@@ -1469,6 +1580,7 @@ async function runTraceSetup(name) {
1469
1580
  await ensureCcqaDir();
1470
1581
  const spec = parseSetupSpec(await readSetupSpecFile(name));
1471
1582
  const resolvedSpec = replacePlaceholdersWithDummies(spec);
1583
+ const secretsToScrub = buildSecretsToScrub(spec);
1472
1584
  meta("setup", spec.title);
1473
1585
  meta("steps", spec.steps.length);
1474
1586
  if (spec.placeholders) meta("placeholders", Object.keys(spec.placeholders).join(", "));
@@ -1489,8 +1601,12 @@ async function runTraceSetup(name) {
1489
1601
  "Grep",
1490
1602
  "Glob"
1491
1603
  ],
1604
+ env: {
1605
+ PATH: pathWithAgentBrowserShim(process.env["PATH"]),
1606
+ ANTHROPIC_API_KEY: ""
1607
+ },
1492
1608
  onAbAction: (abAction) => {
1493
- const action = parseAbAction(abAction);
1609
+ const action = parseAbAction(scrubSecrets(abAction, secretsToScrub));
1494
1610
  if (action) traceActions.push(action);
1495
1611
  },
1496
1612
  onAbActionFailed: () => {
@@ -1512,7 +1628,7 @@ async function runTraceSetup(name) {
1512
1628
  if (routeStep.status === "FAILED") overallStatus = "failed";
1513
1629
  }
1514
1630
  } else if (trimmed.startsWith("AB_ACTION|snapshot|") || trimmed.startsWith("AB_ACTION|assert|")) {
1515
- const action = parseAbAction(trimmed);
1631
+ const action = parseAbAction(scrubSecrets(trimmed, secretsToScrub));
1516
1632
  if (action) traceActions.push(action);
1517
1633
  }
1518
1634
  }
@@ -1538,7 +1654,7 @@ function replacePlaceholdersWithDummies(spec) {
1538
1654
  const dummies = spec.placeholders;
1539
1655
  const resolve = (text) => {
1540
1656
  let result = text;
1541
- for (const [key, def] of Object.entries(dummies)) result = result.replaceAll(`{{${key}}}`, def.dummy);
1657
+ for (const [key, def] of Object.entries(dummies)) result = result.replaceAll(`{{${key}}}`, resolveEnvRefs(def.dummy));
1542
1658
  return result;
1543
1659
  };
1544
1660
  return {
@@ -1550,6 +1666,38 @@ function replacePlaceholdersWithDummies(spec) {
1550
1666
  }))
1551
1667
  };
1552
1668
  }
1669
+ /**
1670
+ * Build the substitution map used to scrub real secret values out of
1671
+ * recorded actions before they are written to actions.json.
1672
+ *
1673
+ * For each placeholder whose dummy contains env refs, store
1674
+ * <resolved-value> -> <original ${VAR} string>
1675
+ * so that an `ab fill ... <secret>` line records the placeholder string
1676
+ * instead of the secret. Empty resolved values are skipped — they would
1677
+ * otherwise replace incidental empty strings in the recorded actions.
1678
+ */
1679
+ function buildSecretsToScrub(spec) {
1680
+ const map = /* @__PURE__ */ new Map();
1681
+ if (!spec.placeholders) return map;
1682
+ const dummies = spec.placeholders;
1683
+ for (const def of Object.values(dummies)) {
1684
+ if (!hasEnvRef(def.dummy)) continue;
1685
+ const resolved = resolveEnvRefs(def.dummy);
1686
+ if (!resolved) continue;
1687
+ map.set(resolved, def.dummy);
1688
+ }
1689
+ return map;
1690
+ }
1691
+ /** Replace every occurrence of a recorded secret with its `${VAR}` placeholder. */
1692
+ function scrubSecrets(line, secrets) {
1693
+ if (secrets.size === 0) return line;
1694
+ let result = line;
1695
+ for (const [secret, placeholder] of secrets) {
1696
+ if (!result.includes(secret)) continue;
1697
+ result = result.split(secret).join(placeholder);
1698
+ }
1699
+ return result;
1700
+ }
1553
1701
  //#endregion
1554
1702
  //#region src/cli/generate-setup.ts
1555
1703
  const generateSetupCommand = new Command("generate-setup").argument("<name>", "Setup name to generate (e.g. login)").description("Clean up, validate, and templatize setup actions").option("--max-retries <n>", "Maximum number of auto-fix retries", "3").option("--from-dummy", "Resume from existing test.dummy.spec.ts (after manual fix)").action(async (name, opts) => {
@@ -1578,7 +1726,7 @@ async function runGenerateSetup(name, maxRetries, fromDummy) {
1578
1726
  meta("saved", dummyPath);
1579
1727
  }
1580
1728
  blank();
1581
- let { exitCode, output, currentScript } = await runVitest(dummyPath);
1729
+ let { exitCode, output, currentScript } = await runVitestResolved(dummyPath);
1582
1730
  if (exitCode !== 0) {
1583
1731
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1584
1732
  info(`auto-fix attempt ${attempt}/${maxRetries}...`);
@@ -1591,7 +1739,7 @@ async function runGenerateSetup(name, maxRetries, fromDummy) {
1591
1739
  await writeFile(dummyPath, fixed, "utf-8");
1592
1740
  meta("saved", dummyPath);
1593
1741
  blank();
1594
- ({exitCode, output, currentScript} = await runVitest(dummyPath));
1742
+ ({exitCode, output, currentScript} = await runVitestResolved(dummyPath));
1595
1743
  if (exitCode === 0) break;
1596
1744
  }
1597
1745
  if (exitCode !== 0) {
@@ -1662,6 +1810,36 @@ async function runVitest(scriptPath) {
1662
1810
  currentScript
1663
1811
  };
1664
1812
  }
1813
+ /**
1814
+ * Run vitest on `test.dummy.spec.ts`, but transparently expand any `${VAR}`
1815
+ * env refs to real values for the duration of the run. The original file is
1816
+ * preserved unchanged so subsequent reverse-replace still sees the env-ref
1817
+ * literals. Auto-fix edits the original file (via writeFile in callers), so
1818
+ * we always re-read it before each invocation.
1819
+ */
1820
+ async function runVitestResolved(scriptPath) {
1821
+ const original = await readFile(scriptPath, "utf8");
1822
+ if (!hasEnvRef(original)) return runVitest(scriptPath);
1823
+ const tmpPath = scriptPath.replace(/\.ts$/, ".__resolved.spec.ts");
1824
+ await writeFile(tmpPath, resolveEnvRefs(original), "utf-8");
1825
+ try {
1826
+ const { exitCode, stdout, stderr } = await spawnVitestCaptured([
1827
+ "run",
1828
+ "--config",
1829
+ bundledVitestConfigPath(),
1830
+ tmpPath
1831
+ ]);
1832
+ process.stdout.write(stdout);
1833
+ if (stderr) process.stderr.write(stderr);
1834
+ return {
1835
+ exitCode,
1836
+ output: stdout + stderr,
1837
+ currentScript: original
1838
+ };
1839
+ } finally {
1840
+ await unlink(tmpPath).catch(() => {});
1841
+ }
1842
+ }
1665
1843
  async function cleanupActions(actions) {
1666
1844
  try {
1667
1845
  const { result, isError } = await invokeClaudeStreaming({
package/dist/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {
@@ -2,7 +2,31 @@ import { createRequire } from "node:module";
2
2
  import { spawnSync } from "node:child_process";
3
3
  //#region src/runtime/test-helpers.ts
4
4
  const AB = createRequire(import.meta.url).resolve("agent-browser/bin/agent-browser.js");
5
- function spawnAB(args) {
5
+ const EAGAIN_PATTERN = /Resource temporarily unavailable|os error 35/i;
6
+ const EAGAIN_TOTAL_BUDGET_MS = 3e4;
7
+ const EAGAIN_BACKOFF_MS = [
8
+ 100,
9
+ 200,
10
+ 300,
11
+ 500,
12
+ 700,
13
+ 1e3,
14
+ 1500,
15
+ 2e3,
16
+ 2500,
17
+ 3e3,
18
+ 3e3,
19
+ 3e3,
20
+ 3e3,
21
+ 3e3,
22
+ 3e3
23
+ ];
24
+ const POST_OPEN_SETTLE_MS = 600;
25
+ function sleepSync(ms) {
26
+ const buf = new SharedArrayBuffer(4);
27
+ Atomics.wait(new Int32Array(buf), 0, 0, ms);
28
+ }
29
+ function spawnABOnce(args) {
6
30
  const result = spawnSync(AB, args, { stdio: "pipe" });
7
31
  return {
8
32
  status: result.status,
@@ -10,6 +34,21 @@ function spawnAB(args) {
10
34
  stderr: result.stderr?.toString() ?? ""
11
35
  };
12
36
  }
37
+ function spawnAB(args) {
38
+ let result = spawnABOnce(args);
39
+ let elapsed = 0;
40
+ let attempt = 0;
41
+ while (result.status !== 0 && elapsed < EAGAIN_TOTAL_BUDGET_MS) {
42
+ const combined = `${result.stdout}\n${result.stderr}`;
43
+ if (!EAGAIN_PATTERN.test(combined)) return result;
44
+ const wait = EAGAIN_BACKOFF_MS[attempt] ?? 3e3;
45
+ sleepSync(wait);
46
+ elapsed += wait;
47
+ attempt++;
48
+ result = spawnABOnce(args);
49
+ }
50
+ return result;
51
+ }
13
52
  function logStep(action, args) {
14
53
  const pretty = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
15
54
  process.stdout.write(` ▶ ${action.padEnd(14)} ${pretty}\n`);
@@ -25,6 +64,7 @@ function ab(...args) {
25
64
  logStep(command, rest);
26
65
  const result = spawnAB(args);
27
66
  if (result.status !== 0) fail(`agent-browser ${command} failed (exit ${result.status})`, result);
67
+ if (command === "open") sleepSync(POST_OPEN_SETTLE_MS);
28
68
  }
29
69
  /** Wait for element/text with an explicit timeout so long-running async ops don't hang. */
30
70
  function abWait(selector, timeoutMs = 18e4) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "type": "module",
5
5
  "description": "Browser test recorder powered by Claude Code and agent-browser",
6
6
  "repository": {