ccqa 0.3.4 → 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
@@ -793,6 +793,56 @@ function pathWithAgentBrowserShim(currentPath) {
793
793
  return dir + delimiter + path;
794
794
  }
795
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
796
846
  //#region src/cli/trace.ts
797
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) => {
798
848
  const { featureName, specName } = parseSpecPath(specPath);
@@ -892,7 +942,7 @@ async function runSetups(setups, sessionName) {
892
942
  let script = await readFile(scriptPath, "utf-8").catch(() => {
893
943
  throw new Error(`Setup test script not found: ${scriptPath}. Run \`ccqa generate-setup ${ref.name}\` first.`);
894
944
  });
895
- 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));
896
946
  script = script.replace(/process\.env\.AGENT_BROWSER_SESSION\s*=\s*`.+`;/, `process.env.AGENT_BROWSER_SESSION = ${JSON.stringify(sessionName)};`);
897
947
  const tmpPath = join(getSetupDir(ref.name), `_run.spec.ts`);
898
948
  await writeFile(tmpPath, script, "utf-8");
@@ -1282,9 +1332,17 @@ function extractTestBody(script) {
1282
1332
  }
1283
1333
  function replacePlaceholders(body, params) {
1284
1334
  let result = body;
1285
- 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);
1286
1341
  return result;
1287
1342
  }
1343
+ function escapeRegExp(s) {
1344
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1345
+ }
1288
1346
  async function autoFixWithLLM$1(script, failureLog) {
1289
1347
  try {
1290
1348
  const { result, isError } = await invokeClaudeStreaming({
@@ -1522,6 +1580,7 @@ async function runTraceSetup(name) {
1522
1580
  await ensureCcqaDir();
1523
1581
  const spec = parseSetupSpec(await readSetupSpecFile(name));
1524
1582
  const resolvedSpec = replacePlaceholdersWithDummies(spec);
1583
+ const secretsToScrub = buildSecretsToScrub(spec);
1525
1584
  meta("setup", spec.title);
1526
1585
  meta("steps", spec.steps.length);
1527
1586
  if (spec.placeholders) meta("placeholders", Object.keys(spec.placeholders).join(", "));
@@ -1542,8 +1601,12 @@ async function runTraceSetup(name) {
1542
1601
  "Grep",
1543
1602
  "Glob"
1544
1603
  ],
1604
+ env: {
1605
+ PATH: pathWithAgentBrowserShim(process.env["PATH"]),
1606
+ ANTHROPIC_API_KEY: ""
1607
+ },
1545
1608
  onAbAction: (abAction) => {
1546
- const action = parseAbAction(abAction);
1609
+ const action = parseAbAction(scrubSecrets(abAction, secretsToScrub));
1547
1610
  if (action) traceActions.push(action);
1548
1611
  },
1549
1612
  onAbActionFailed: () => {
@@ -1565,7 +1628,7 @@ async function runTraceSetup(name) {
1565
1628
  if (routeStep.status === "FAILED") overallStatus = "failed";
1566
1629
  }
1567
1630
  } else if (trimmed.startsWith("AB_ACTION|snapshot|") || trimmed.startsWith("AB_ACTION|assert|")) {
1568
- const action = parseAbAction(trimmed);
1631
+ const action = parseAbAction(scrubSecrets(trimmed, secretsToScrub));
1569
1632
  if (action) traceActions.push(action);
1570
1633
  }
1571
1634
  }
@@ -1591,7 +1654,7 @@ function replacePlaceholdersWithDummies(spec) {
1591
1654
  const dummies = spec.placeholders;
1592
1655
  const resolve = (text) => {
1593
1656
  let result = text;
1594
- 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));
1595
1658
  return result;
1596
1659
  };
1597
1660
  return {
@@ -1603,6 +1666,38 @@ function replacePlaceholdersWithDummies(spec) {
1603
1666
  }))
1604
1667
  };
1605
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
+ }
1606
1701
  //#endregion
1607
1702
  //#region src/cli/generate-setup.ts
1608
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) => {
@@ -1631,7 +1726,7 @@ async function runGenerateSetup(name, maxRetries, fromDummy) {
1631
1726
  meta("saved", dummyPath);
1632
1727
  }
1633
1728
  blank();
1634
- let { exitCode, output, currentScript } = await runVitest(dummyPath);
1729
+ let { exitCode, output, currentScript } = await runVitestResolved(dummyPath);
1635
1730
  if (exitCode !== 0) {
1636
1731
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
1637
1732
  info(`auto-fix attempt ${attempt}/${maxRetries}...`);
@@ -1644,7 +1739,7 @@ async function runGenerateSetup(name, maxRetries, fromDummy) {
1644
1739
  await writeFile(dummyPath, fixed, "utf-8");
1645
1740
  meta("saved", dummyPath);
1646
1741
  blank();
1647
- ({exitCode, output, currentScript} = await runVitest(dummyPath));
1742
+ ({exitCode, output, currentScript} = await runVitestResolved(dummyPath));
1648
1743
  if (exitCode === 0) break;
1649
1744
  }
1650
1745
  if (exitCode !== 0) {
@@ -1715,6 +1810,36 @@ async function runVitest(scriptPath) {
1715
1810
  currentScript
1716
1811
  };
1717
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
+ }
1718
1843
  async function cleanupActions(actions) {
1719
1844
  try {
1720
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.4",
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": {
@@ -3,12 +3,25 @@ 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
5
  const EAGAIN_PATTERN = /Resource temporarily unavailable|os error 35/i;
6
- const EAGAIN_RETRIES = 3;
6
+ const EAGAIN_TOTAL_BUDGET_MS = 3e4;
7
7
  const EAGAIN_BACKOFF_MS = [
8
+ 100,
8
9
  200,
10
+ 300,
9
11
  500,
10
- 1e3
12
+ 700,
13
+ 1e3,
14
+ 1500,
15
+ 2e3,
16
+ 2500,
17
+ 3e3,
18
+ 3e3,
19
+ 3e3,
20
+ 3e3,
21
+ 3e3,
22
+ 3e3
11
23
  ];
24
+ const POST_OPEN_SETTLE_MS = 600;
12
25
  function sleepSync(ms) {
13
26
  const buf = new SharedArrayBuffer(4);
14
27
  Atomics.wait(new Int32Array(buf), 0, 0, ms);
@@ -23,11 +36,15 @@ function spawnABOnce(args) {
23
36
  }
24
37
  function spawnAB(args) {
25
38
  let result = spawnABOnce(args);
26
- for (let attempt = 0; attempt < EAGAIN_RETRIES; attempt++) {
27
- if (result.status === 0) return result;
39
+ let elapsed = 0;
40
+ let attempt = 0;
41
+ while (result.status !== 0 && elapsed < EAGAIN_TOTAL_BUDGET_MS) {
28
42
  const combined = `${result.stdout}\n${result.stderr}`;
29
43
  if (!EAGAIN_PATTERN.test(combined)) return result;
30
- sleepSync(EAGAIN_BACKOFF_MS[attempt] ?? 1e3);
44
+ const wait = EAGAIN_BACKOFF_MS[attempt] ?? 3e3;
45
+ sleepSync(wait);
46
+ elapsed += wait;
47
+ attempt++;
31
48
  result = spawnABOnce(args);
32
49
  }
33
50
  return result;
@@ -47,6 +64,7 @@ function ab(...args) {
47
64
  logStep(command, rest);
48
65
  const result = spawnAB(args);
49
66
  if (result.status !== 0) fail(`agent-browser ${command} failed (exit ${result.status})`, result);
67
+ if (command === "open") sleepSync(POST_OPEN_SETTLE_MS);
50
68
  }
51
69
  /** Wait for element/text with an explicit timeout so long-running async ops don't hang. */
52
70
  function abWait(selector, timeoutMs = 18e4) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccqa",
3
- "version": "0.3.4",
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": {