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 +132 -7
- package/dist/package.json +1 -1
- package/dist/runtime/test-helpers.mjs +23 -5
- package/package.json +1 -1
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))
|
|
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
|
|
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
|
|
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
|
@@ -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
|
|
6
|
+
const EAGAIN_TOTAL_BUDGET_MS = 3e4;
|
|
7
7
|
const EAGAIN_BACKOFF_MS = [
|
|
8
|
+
100,
|
|
8
9
|
200,
|
|
10
|
+
300,
|
|
9
11
|
500,
|
|
10
|
-
|
|
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
|
-
|
|
27
|
-
|
|
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
|
-
|
|
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) {
|