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 +199 -21
- package/dist/package.json +1 -1
- package/dist/runtime/test-helpers.mjs +41 -1
- package/package.json +1 -1
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: {
|
|
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 (
|
|
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
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
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
|
|
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))
|
|
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
|
|
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
|
|
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
|
@@ -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
|
-
|
|
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) {
|