ccqa 1.1.1 → 1.2.1
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 +140 -311
- package/dist/package.json +1 -1
- package/dist/runtime/test-helpers.mjs +1 -1
- package/dist/spawn-ab-B9Il2TMd.mjs +210 -0
- package/package.json +1 -1
- package/dist/spawn-ab-Cr967J2N.mjs +0 -79
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import { n as spawnAB, t as sleepSync } from "../spawn-ab-
|
|
2
|
+
import { a as formatAgentBrowserUnavailableMessage, i as assertAgentBrowserAvailable, n as spawnAB, o as pathWithAgentBrowserShim, r as AgentBrowserUnavailableError, s as resolveAgentBrowserBin$1, t as sleepSync } from "../spawn-ab-B9Il2TMd.mjs";
|
|
3
3
|
import { HubApiError, createHubClient } from "../hub-client/index.mjs";
|
|
4
4
|
import { createRequire } from "node:module";
|
|
5
5
|
import { Command } from "commander";
|
|
6
|
-
import { accessSync, createWriteStream, existsSync, readFileSync, rmSync
|
|
6
|
+
import { accessSync, createWriteStream, existsSync, readFileSync, rmSync } from "node:fs";
|
|
7
7
|
import { fileURLToPath } from "node:url";
|
|
8
8
|
import { access, chmod, cp, mkdir, mkdtemp, open, readFile, readdir, rename, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
9
9
|
import { homedir, tmpdir } from "node:os";
|
|
10
|
-
import { basename,
|
|
10
|
+
import { basename, dirname, isAbsolute, join, normalize, posix, relative, resolve, sep } from "node:path";
|
|
11
11
|
import { parse, stringify } from "yaml";
|
|
12
12
|
import { ZodError, z } from "zod";
|
|
13
13
|
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
@@ -429,26 +429,6 @@ async function removeLegacyPerspectivesFiles(cwd) {
|
|
|
429
429
|
for (const path of candidates) if (await unlink(path).then(() => true).catch(() => false)) removed.push(path);
|
|
430
430
|
return removed;
|
|
431
431
|
}
|
|
432
|
-
/**
|
|
433
|
-
* Replace (or insert) the `relatedPaths` key in the spec. Preserves every
|
|
434
|
-
* other top-level field and the entire steps array. Returns the absolute
|
|
435
|
-
* path that was written, or null if the spec file does not exist.
|
|
436
|
-
*/
|
|
437
|
-
async function updateSpecRelatedPaths(featureName, specName, relatedPaths, cwd) {
|
|
438
|
-
const specPath = join(getSpecDir(featureName, specName, cwd), SPEC_FILE);
|
|
439
|
-
const existing = await readFile(specPath, "utf-8").catch(() => null);
|
|
440
|
-
if (existing === null) return null;
|
|
441
|
-
await writeFile(specPath, stringify(stripUndefined({
|
|
442
|
-
...parseTestSpec(existing, specPath),
|
|
443
|
-
relatedPaths: relatedPaths.length > 0 ? relatedPaths : void 0
|
|
444
|
-
}), { lineWidth: 0 }), "utf-8");
|
|
445
|
-
return specPath;
|
|
446
|
-
}
|
|
447
|
-
function stripUndefined(obj) {
|
|
448
|
-
const out = {};
|
|
449
|
-
for (const [k, v] of Object.entries(obj)) if (v !== void 0) out[k] = v;
|
|
450
|
-
return out;
|
|
451
|
-
}
|
|
452
432
|
const LEGACY_RECORDING_FILES = ["actions.json", "route.md"];
|
|
453
433
|
async function saveRecording(featureName, specName, actions, cwd) {
|
|
454
434
|
const specDir = getSpecDir(featureName, specName, cwd);
|
|
@@ -699,10 +679,10 @@ function bundledVitestConfigPath() {
|
|
|
699
679
|
}
|
|
700
680
|
//#endregion
|
|
701
681
|
//#region src/runtime/spawn-vitest.ts
|
|
702
|
-
const require$
|
|
682
|
+
const require$1 = createRequire(import.meta.url);
|
|
703
683
|
function resolveVitestBin() {
|
|
704
|
-
const pkgPath = require$
|
|
705
|
-
const pkg = require$
|
|
684
|
+
const pkgPath = require$1.resolve("vitest/package.json");
|
|
685
|
+
const pkg = require$1(pkgPath);
|
|
706
686
|
const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.vitest;
|
|
707
687
|
if (!binRel) throw new Error(`vitest package.json has no bin entry (resolved at ${pkgPath})`);
|
|
708
688
|
return resolve(dirname(pkgPath), binRel);
|
|
@@ -1891,7 +1871,7 @@ ${formatBlockList(blocks)}
|
|
|
1891
1871
|
|
|
1892
1872
|
1. Read the codebase under cwd to find concrete strings: routes, button labels, aria-labels, page titles, placeholders. Use those exact strings in \`expected\`.
|
|
1893
1873
|
2. If you use \`include:\` steps, verify each \`params\` key matches a declared param of the block (see the Available blocks list above).
|
|
1894
|
-
3. Populate \`relatedPaths\` with
|
|
1874
|
+
3. Populate \`relatedPaths\` with glob patterns pointing at the source files this spec depends on — this is the spec's final, authoritative list (nothing else updates it later, so get it right here): the route/page file for each URL the spec visits, plus the component files (or their parent feature directory) that render the aria-labels, placeholders, or visible texts the spec asserts on. Prefer directory globs (e.g. \`src/features/tasks/**\`) when several files in one area are involved. Confirm each path actually exists via \`Glob\`/\`Read\` before listing it — never guess a path by convention. Be conservative — when unsure whether a real path is relevant, include it rather than omit it (a missing path means a code change silently escapes \`drift --changed\`).
|
|
1895
1875
|
4. Validate the (current or proposed) spec on four axes — emit one issue per finding:
|
|
1896
1876
|
- **assertable**: each \`expected\` can be verified against a string/URL/state that exists in code.
|
|
1897
1877
|
- **blocks**: every \`include\` resolves to a real block; every \`params\` key is declared on that block; every required param is provided.
|
|
@@ -3951,104 +3931,6 @@ async function warnStaleBlockArtifacts() {
|
|
|
3951
3931
|
for (const p of stale) hint(`stale block artifact detected: ${p} — v0.4 no longer uses these; delete it manually.`);
|
|
3952
3932
|
}
|
|
3953
3933
|
//#endregion
|
|
3954
|
-
//#region src/runtime/agent-browser-bin.ts
|
|
3955
|
-
const require$1 = createRequire(import.meta.url);
|
|
3956
|
-
function hasAgentBrowserShim(dir) {
|
|
3957
|
-
try {
|
|
3958
|
-
statSync(join(dir, "agent-browser"));
|
|
3959
|
-
return true;
|
|
3960
|
-
} catch {
|
|
3961
|
-
return false;
|
|
3962
|
-
}
|
|
3963
|
-
}
|
|
3964
|
-
/**
|
|
3965
|
-
* Walks up from `start` looking for a `node_modules/.bin/agent-browser` shim.
|
|
3966
|
-
* Returns the .bin directory containing the shim, or null if none is found.
|
|
3967
|
-
*/
|
|
3968
|
-
function findNodeModulesBin(start) {
|
|
3969
|
-
let cur = start;
|
|
3970
|
-
while (true) {
|
|
3971
|
-
const candidate = join(cur, "node_modules", ".bin");
|
|
3972
|
-
if (hasAgentBrowserShim(candidate)) return candidate;
|
|
3973
|
-
const parent = dirname(cur);
|
|
3974
|
-
if (parent === cur) return null;
|
|
3975
|
-
cur = parent;
|
|
3976
|
-
}
|
|
3977
|
-
}
|
|
3978
|
-
/**
|
|
3979
|
-
* Resolves the directory containing the `agent-browser` shim that npm/pnpm
|
|
3980
|
-
* exposes on PATH for the peer-installed package. Used by `ccqa trace` to
|
|
3981
|
-
* prepend this directory to PATH so the Claude subprocess can invoke
|
|
3982
|
-
* `agent-browser ...` without requiring a global install.
|
|
3983
|
-
*
|
|
3984
|
-
* Returns null if agent-browser cannot be located.
|
|
3985
|
-
*/
|
|
3986
|
-
function resolveAgentBrowserBinDir() {
|
|
3987
|
-
const fromCwd = findNodeModulesBin(process.cwd());
|
|
3988
|
-
if (fromCwd) return fromCwd;
|
|
3989
|
-
const fromSelf = findNodeModulesBin(dirname(require$1.resolve("agent-browser/package.json")));
|
|
3990
|
-
if (fromSelf) return fromSelf;
|
|
3991
|
-
try {
|
|
3992
|
-
const candidate = join(dirname(require$1.resolve("agent-browser/package.json")), "node_modules", ".bin");
|
|
3993
|
-
if (hasAgentBrowserShim(candidate)) return candidate;
|
|
3994
|
-
} catch {}
|
|
3995
|
-
return null;
|
|
3996
|
-
}
|
|
3997
|
-
/**
|
|
3998
|
-
* Returns a PATH string with the agent-browser shim directory prepended,
|
|
3999
|
-
* so `agent-browser ...` resolves without a global install. Falls back to
|
|
4000
|
-
* the original PATH when the package can't be resolved.
|
|
4001
|
-
*/
|
|
4002
|
-
function pathWithAgentBrowserShim(currentPath) {
|
|
4003
|
-
const path = currentPath ?? "";
|
|
4004
|
-
const dir = resolveAgentBrowserBinDir();
|
|
4005
|
-
if (!dir) return path;
|
|
4006
|
-
if (path.split(delimiter).includes(dir)) return path;
|
|
4007
|
-
return dir + delimiter + path;
|
|
4008
|
-
}
|
|
4009
|
-
/**
|
|
4010
|
-
* Confirms before launching Claude that an `agent-browser` shim is reachable
|
|
4011
|
-
* via PATH. We do this up front so a missing peer dependency fails fast with
|
|
4012
|
-
* a clear message, instead of Claude burning tokens probing the system with
|
|
4013
|
-
* `which`, `find`, `npm install`, etc.
|
|
4014
|
-
*
|
|
4015
|
-
* The `resolver` argument is for tests; production calls take no args.
|
|
4016
|
-
*/
|
|
4017
|
-
function assertAgentBrowserAvailable(resolver = resolveAgentBrowserBinDir) {
|
|
4018
|
-
const dir = resolver();
|
|
4019
|
-
if (!dir) throw new AgentBrowserUnavailableError();
|
|
4020
|
-
const shim = join(dir, "agent-browser");
|
|
4021
|
-
try {
|
|
4022
|
-
const s = statSync(shim);
|
|
4023
|
-
if (!s.isFile() && !s.isSymbolicLink()) throw new AgentBrowserUnavailableError();
|
|
4024
|
-
} catch {
|
|
4025
|
-
throw new AgentBrowserUnavailableError();
|
|
4026
|
-
}
|
|
4027
|
-
return dir;
|
|
4028
|
-
}
|
|
4029
|
-
var AgentBrowserUnavailableError = class extends Error {
|
|
4030
|
-
constructor() {
|
|
4031
|
-
super("agent-browser binary not found on PATH");
|
|
4032
|
-
this.name = "AgentBrowserUnavailableError";
|
|
4033
|
-
}
|
|
4034
|
-
};
|
|
4035
|
-
/** Human-readable explanation shown to the user when the guard fires. */
|
|
4036
|
-
function formatAgentBrowserUnavailableMessage() {
|
|
4037
|
-
return [
|
|
4038
|
-
"agent-browser is not installed or not on PATH.",
|
|
4039
|
-
"",
|
|
4040
|
-
"ccqa drives the browser via the peer-installed `agent-browser` package.",
|
|
4041
|
-
"Install it in this project:",
|
|
4042
|
-
"",
|
|
4043
|
-
" pnpm add -D agent-browser",
|
|
4044
|
-
" # or",
|
|
4045
|
-
" npm install -D agent-browser",
|
|
4046
|
-
"",
|
|
4047
|
-
"If it is already installed, make sure you are running ccqa from the",
|
|
4048
|
-
"project root (or via your package runner, e.g. `pnpm exec ccqa ...`)."
|
|
4049
|
-
].join("\n");
|
|
4050
|
-
}
|
|
4051
|
-
//#endregion
|
|
4052
3934
|
//#region src/cli/preflight.ts
|
|
4053
3935
|
/**
|
|
4054
3936
|
* Shared startup steps for every command that drives a real `agent-browser`
|
|
@@ -4327,6 +4209,14 @@ function readOctal(buf, offset, fieldLen) {
|
|
|
4327
4209
|
/** Default per-profile sessions root, relative to the project (`--cwd`). */
|
|
4328
4210
|
const SESSIONS_SUBDIR = ".ccqa/sessions";
|
|
4329
4211
|
/**
|
|
4212
|
+
* Key under which `bootstrap` embeds the verify URL inside the uploaded
|
|
4213
|
+
* storage-state JSON. The hub roundtrips it opaquely; `mergeStorageStates`
|
|
4214
|
+
* rebuilds a `{cookies, origins}` object, so the key never reaches
|
|
4215
|
+
* agent-browser (which would reject an unknown top-level field). `ccqa run`
|
|
4216
|
+
* reads it to health-check the restore before executing steps.
|
|
4217
|
+
*/
|
|
4218
|
+
const SESSION_VERIFY_URL_KEY = "__ccqaVerifyUrl";
|
|
4219
|
+
/**
|
|
4330
4220
|
* Resolve a session name to its state file path:
|
|
4331
4221
|
* `<cwd>/.ccqa/sessions/<profile>/<name>.json`. The name is a slug (validated
|
|
4332
4222
|
* by SessionNameSchema), so it can't escape the sessions directory. Used by
|
|
@@ -4448,20 +4338,54 @@ function loadStateIntoSession(sessionName, statePath) {
|
|
|
4448
4338
|
};
|
|
4449
4339
|
return { ok: true };
|
|
4450
4340
|
}
|
|
4341
|
+
/** Drop every trailing slash so `/a` and `/a/` compare equal. */
|
|
4342
|
+
function normalizePath(pathname) {
|
|
4343
|
+
return pathname.replace(/\/+$/, "");
|
|
4344
|
+
}
|
|
4451
4345
|
/**
|
|
4452
|
-
*
|
|
4453
|
-
*
|
|
4454
|
-
*
|
|
4455
|
-
*
|
|
4456
|
-
*
|
|
4457
|
-
*
|
|
4346
|
+
* Behaviour-based "signed in?" check with no product knowledge: after
|
|
4347
|
+
* restoring a session, opening `requestedUrl` must settle on the SAME origin
|
|
4348
|
+
* and WITHIN the requested path. An expired/absent auth redirects elsewhere —
|
|
4349
|
+
* a sign-in route on the same origin, or a different origin entirely — so a
|
|
4350
|
+
* final URL that left the requested origin+path means the restore failed.
|
|
4351
|
+
*
|
|
4352
|
+
* This replaces the old "is a password input visible?" heuristic, which had
|
|
4353
|
+
* false positives (multi-step / SSO / workspace-picker sign-in screens show no
|
|
4354
|
+
* password field) and false negatives (a signed-in page can host a
|
|
4355
|
+
* change-password form). Trailing slashes are insensitive; a malformed URL on
|
|
4356
|
+
* either side returns false.
|
|
4458
4357
|
*
|
|
4459
|
-
*
|
|
4460
|
-
*
|
|
4461
|
-
*
|
|
4462
|
-
*
|
|
4463
|
-
*
|
|
4464
|
-
*
|
|
4358
|
+
* Limitation: if `requestedUrl` is just an origin root, any same-origin page
|
|
4359
|
+
* counts as "stayed" (`startsWith("")` is always true) — so callers should
|
|
4360
|
+
* pass a deep, already-signed-in page URL. And an app that renders a login
|
|
4361
|
+
* form IN PLACE at the same URL (no redirect) can't be caught this way; the
|
|
4362
|
+
* escape hatch for that is a future explicit positive signal (e.g. a
|
|
4363
|
+
* `--verify-text` the caller expects to see). See issue #109.
|
|
4364
|
+
*/
|
|
4365
|
+
function urlStayedAtTarget(requestedUrl, finalUrl) {
|
|
4366
|
+
let requested;
|
|
4367
|
+
let final;
|
|
4368
|
+
try {
|
|
4369
|
+
requested = new URL(requestedUrl);
|
|
4370
|
+
final = new URL(finalUrl);
|
|
4371
|
+
} catch {
|
|
4372
|
+
return false;
|
|
4373
|
+
}
|
|
4374
|
+
if (requested.origin !== final.origin) return false;
|
|
4375
|
+
return normalizePath(final.pathname).startsWith(normalizePath(requested.pathname));
|
|
4376
|
+
}
|
|
4377
|
+
/**
|
|
4378
|
+
* Prove a saved session actually restores to a signed-in page, in a fresh
|
|
4379
|
+
* throwaway agent-browser session. Loads `statePath` into a clean session,
|
|
4380
|
+
* opens `verifyUrl`, and checks the browser STAYED at that URL's origin+path
|
|
4381
|
+
* (see {@link urlStayedAtTarget}) — an expired/absent session redirects to a
|
|
4382
|
+
* sign-in route or another origin instead. Used both as a save-time gate in
|
|
4383
|
+
* `bootstrap` and as a pre-run health check in `ccqa run`.
|
|
4384
|
+
*
|
|
4385
|
+
* Best-effort: on infrastructure errors (daemon won't start, navigation
|
|
4386
|
+
* fails) it returns `restored: false` with the error so the caller can
|
|
4387
|
+
* surface it rather than silently trusting the session. Always closes the
|
|
4388
|
+
* throwaway session.
|
|
4465
4389
|
*/
|
|
4466
4390
|
function verifySessionRestores(statePath, verifyUrl) {
|
|
4467
4391
|
const verifySession = `ccqa-session-verify-${process.pid}-${trackedTempDirs.size}`;
|
|
@@ -4497,17 +4421,17 @@ function verifySessionRestores(statePath, verifyUrl) {
|
|
|
4497
4421
|
const probe = spawnAB([
|
|
4498
4422
|
"--session",
|
|
4499
4423
|
verifySession,
|
|
4500
|
-
"
|
|
4501
|
-
"
|
|
4502
|
-
"input[type=password]"
|
|
4424
|
+
"eval",
|
|
4425
|
+
"location.href"
|
|
4503
4426
|
]);
|
|
4504
4427
|
if (probe.status !== 0) return {
|
|
4505
4428
|
restored: false,
|
|
4506
4429
|
reason: (probe.stderr || probe.stdout || `probe exited ${probe.status}`).trim()
|
|
4507
4430
|
};
|
|
4508
|
-
|
|
4431
|
+
const finalHref = unwrapEvalString(probe.stdout);
|
|
4432
|
+
if (!urlStayedAtTarget(verifyUrl, finalHref)) return {
|
|
4509
4433
|
restored: false,
|
|
4510
|
-
reason:
|
|
4434
|
+
reason: `restore left the verify URL — landed on ${safeOriginPath(finalHref)}`
|
|
4511
4435
|
};
|
|
4512
4436
|
return { restored: true };
|
|
4513
4437
|
} finally {
|
|
@@ -4518,6 +4442,25 @@ function verifySessionRestores(statePath, verifyUrl) {
|
|
|
4518
4442
|
]);
|
|
4519
4443
|
}
|
|
4520
4444
|
}
|
|
4445
|
+
/** Take the last non-empty line of `agent-browser eval` stdout and JSON-unquote it. */
|
|
4446
|
+
function unwrapEvalString(stdout) {
|
|
4447
|
+
const lines = stdout.split("\n").map((l) => l.trim()).filter((l) => l.length > 0);
|
|
4448
|
+
const last = lines[lines.length - 1] ?? "";
|
|
4449
|
+
try {
|
|
4450
|
+
const parsed = JSON.parse(last);
|
|
4451
|
+
if (typeof parsed === "string") return parsed;
|
|
4452
|
+
} catch {}
|
|
4453
|
+
return last.replace(/^"|"$/g, "");
|
|
4454
|
+
}
|
|
4455
|
+
/** `origin + pathname` of a URL, or the raw string if it doesn't parse. */
|
|
4456
|
+
function safeOriginPath(href) {
|
|
4457
|
+
try {
|
|
4458
|
+
const u = new URL(href);
|
|
4459
|
+
return u.origin + u.pathname;
|
|
4460
|
+
} catch {
|
|
4461
|
+
return href;
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
4521
4464
|
//#endregion
|
|
4522
4465
|
//#region src/prompts/prompt-names.ts
|
|
4523
4466
|
/**
|
|
@@ -5652,21 +5595,33 @@ async function runDriftAuditOne(r, opts, cwd) {
|
|
|
5652
5595
|
return result.issues.length > 0 ? result.issues : null;
|
|
5653
5596
|
}
|
|
5654
5597
|
/**
|
|
5598
|
+
* Sessions whose restore has already been health-checked this process, keyed
|
|
5599
|
+
* `<profile>/<name>`. A run of many specs restores the same session repeatedly;
|
|
5600
|
+
* we only open a throwaway verify browser the first time.
|
|
5601
|
+
*/
|
|
5602
|
+
const verifiedSessions = /* @__PURE__ */ new Set();
|
|
5603
|
+
/**
|
|
5655
5604
|
* Resolve `spec.session` names to a single state file to restore, fetching
|
|
5656
5605
|
* each named session from the hub (`.ccqa/sessions/*.json` is no longer
|
|
5657
5606
|
* read here). Every name must load as a valid agent-browser state (the spec
|
|
5658
5607
|
* assumes it starts signed-in); a missing/malformed session fails with a
|
|
5659
|
-
* `ccqa session bootstrap` hint instead of running unauthenticated.
|
|
5660
|
-
*
|
|
5661
|
-
*
|
|
5662
|
-
|
|
5663
|
-
|
|
5608
|
+
* `ccqa session bootstrap` hint instead of running unauthenticated.
|
|
5609
|
+
*
|
|
5610
|
+
* If a session carries an embedded verify URL (bootstrap saved it), the
|
|
5611
|
+
* restore is health-checked before the run starts, so an expired/unusable
|
|
5612
|
+
* session fails fast with a re-bootstrap hint instead of every step failing
|
|
5613
|
+
* generically. Loaded states are always merged (even a single one) and written
|
|
5614
|
+
* to a fresh temp file — callers must invoke the returned `cleanup()` once the
|
|
5615
|
+
* run is done. `verify` is injectable for tests.
|
|
5616
|
+
*/
|
|
5617
|
+
async function resolveSessionState(names, hubCtx, profile, verify = verifySessionRestores) {
|
|
5664
5618
|
if (names.length === 0 || hubCtx === null) return {
|
|
5665
5619
|
ok: false,
|
|
5666
5620
|
error: `session '${names.join(", ")}' requires a hub connection`,
|
|
5667
5621
|
hint: "set --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN) to restore sessions from the hub"
|
|
5668
5622
|
};
|
|
5669
5623
|
const resolvedProfile = profile ?? "default";
|
|
5624
|
+
const profileFlag = profile ? ` --profile ${profile}` : "";
|
|
5670
5625
|
const loaded = [];
|
|
5671
5626
|
const broken = [];
|
|
5672
5627
|
for (const name of names) {
|
|
@@ -5681,16 +5636,28 @@ async function resolveSessionState(names, hubCtx, profile) {
|
|
|
5681
5636
|
broken.push(name);
|
|
5682
5637
|
continue;
|
|
5683
5638
|
}
|
|
5639
|
+
const embedded = state[SESSION_VERIFY_URL_KEY];
|
|
5640
|
+
if (typeof embedded === "string") {
|
|
5641
|
+
const memoKey = `${resolvedProfile}/${name}`;
|
|
5642
|
+
if (!verifiedSessions.has(memoKey)) {
|
|
5643
|
+
const tmp = await writeMergedTempState(mergeStorageStates([state]));
|
|
5644
|
+
const check = verify(tmp, embedded);
|
|
5645
|
+
await removeTempStateDir(tmp);
|
|
5646
|
+
if (!check.restored) return {
|
|
5647
|
+
ok: false,
|
|
5648
|
+
error: `session '${name}' did not restore to a signed-in page — ${check.reason}`,
|
|
5649
|
+
hint: `re-bootstrap it: ccqa session bootstrap ${name}${profileFlag}`
|
|
5650
|
+
};
|
|
5651
|
+
verifiedSessions.add(memoKey);
|
|
5652
|
+
}
|
|
5653
|
+
} else warn(`session '${name}' has no embedded verify URL (saved by an older ccqa) — skipping the pre-run restore check`);
|
|
5684
5654
|
loaded.push(state);
|
|
5685
5655
|
}
|
|
5686
|
-
if (broken.length > 0) {
|
|
5687
|
-
|
|
5688
|
-
|
|
5689
|
-
|
|
5690
|
-
|
|
5691
|
-
hint: `create it with: ${broken.map((name) => `ccqa session bootstrap ${name}${profileFlag}`).join(" · ")}`
|
|
5692
|
-
};
|
|
5693
|
-
}
|
|
5656
|
+
if (broken.length > 0) return {
|
|
5657
|
+
ok: false,
|
|
5658
|
+
error: `session not usable on the hub: ${broken.join(", ")}`,
|
|
5659
|
+
hint: `create it with: ${broken.map((name) => `ccqa session bootstrap ${name}${profileFlag}`).join(" · ")}`
|
|
5660
|
+
};
|
|
5694
5661
|
const statePath = await writeMergedTempState(mergeStorageStates(loaded));
|
|
5695
5662
|
return {
|
|
5696
5663
|
ok: true,
|
|
@@ -10002,7 +9969,6 @@ function buildTraceSystemPrompt(input) {
|
|
|
10002
9969
|
const stepsText = input.steps.map((step) => `### ${step.id} [${step.source}]
|
|
10003
9970
|
- **Instruction**: ${step.instruction}
|
|
10004
9971
|
- **Expected**: ${step.expected}`).join("\n\n");
|
|
10005
|
-
const relatedPathsBlock = buildRelatedPathsInstruction();
|
|
10006
9972
|
return `You are an expert QA engineer executing a browser E2E test. Execute each step precisely and record every browser action as a structured log line.
|
|
10007
9973
|
|
|
10008
9974
|
## Session
|
|
@@ -10272,6 +10238,9 @@ CCQA_STEP=<step-id> CCQA_ASSERT=url_contains:/dashboard agent-browser --session
|
|
|
10272
10238
|
- A marked command that exits non-zero records nothing — fix the check and re-run it.
|
|
10273
10239
|
- A marker that doesn't match its command (e.g. \`CCQA_ASSERT=1\` on a \`click\`) is ignored with a warning — never do that.
|
|
10274
10240
|
- \`get count\` and \`get url\` exit 0 regardless of what they print. **Read the output**: if the printed count / URL contradicts the marker, the signal did NOT verify — treat it as a failed verification (emit \`ASSERTION_FAILED\` if the step cannot be confirmed another way).
|
|
10241
|
+
- **\`url_contains\` is opt-in, not a habit.** Emit it ONLY when a step's own \`expected\` explicitly asks about the URL or path. Do NOT add a \`url_contains\` to "prove" a login succeeded, a page loaded, or a navigation happened — confirm those with \`text_visible\` / \`element_visible\` on something the destination page renders. An unrequested URL assertion adds no coverage the visible-content assert doesn't already give, and is the single most common way an environment gets baked into a test.
|
|
10242
|
+
- **When you do assert a URL, the substring may come from ONE place only:** a \`\${VAR}\` URL that *this step's own instruction* opened, written as that \`\${VAR}\` followed by the literal tail after it. If the step opens \`\${APP_URL}/policies\`, assert \`\${APP_URL}/policies\` (or the tail \`/policies\`); the recorder resolves \`\${APP_URL}\` per environment. Never assert on a URL you merely *observed* — login redirects, identity-provider pages, and OAuth callbacks all live on a **different, environment-named origin** than the app, so any substring of them (host, origin, OR path) names the environment.
|
|
10243
|
+
- **A leading slash does NOT make a substring safe.** \`/auth-staging\`, \`/env-qa\`, \`/tenant-acme\` look like paths but are environment labels — the first segment of an identity-provider or tenant URL, not an application route. If a substring contains an environment name, a stage token (\`dev\`, \`stg\`, \`prod\`), a tenant/org name, or any fragment of a hostname, it is forbidden even with a leading slash. The only safe path substrings are stable *application* routes off the app's own origin (\`/dashboard\`, \`/policies/new\`), taken from a \`\${VAR}\` you opened — not from a redirect you watched.
|
|
10275
10244
|
|
|
10276
10245
|
**Fallback text protocol** — ONLY for assert types the markers cannot express
|
|
10277
10246
|
(\`element_enabled\`, \`element_disabled\`, \`element_checked\`,
|
|
@@ -10382,7 +10351,7 @@ RUN_COMPLETED|passed|<summary>
|
|
|
10382
10351
|
RUN_COMPLETED|failed|<summary>
|
|
10383
10352
|
\`\`\`
|
|
10384
10353
|
|
|
10385
|
-
|
|
10354
|
+
## Start
|
|
10386
10355
|
|
|
10387
10356
|
Begin by clearing cookies, then proceed straight to the first step's instruction.
|
|
10388
10357
|
|
|
@@ -10401,130 +10370,12 @@ AB_ACTION|cookies_clear
|
|
|
10401
10370
|
Then emit \`STEP_START|step-01|...\` and execute the first step, prefixing
|
|
10402
10371
|
every one of its agent-browser commands with \`CCQA_STEP=step-01\`. The first
|
|
10403
10372
|
step is responsible for opening the initial URL.
|
|
10404
|
-
`;
|
|
10405
|
-
}
|
|
10406
|
-
function buildRelatedPathsInstruction() {
|
|
10407
|
-
return `## Post-run: emit \`relatedPaths\` block
|
|
10408
|
-
|
|
10409
|
-
After all steps are complete (regardless of pass/fail) and **before** \`RUN_COMPLETED\`, you MUST emit a single \`RELATED_PATHS\` block. The host (not you) writes these paths into the spec — your only job is to emit the block.
|
|
10410
|
-
|
|
10411
|
-
\`relatedPaths\` is a list of glob patterns identifying the source files this spec depends on. CI uses them to decide whether a code change should trigger a drift check for this spec.
|
|
10412
|
-
|
|
10413
|
-
**Do NOT modify any source files.** You have only \`Read\`, \`Grep\`, and \`Glob\` for source inspection. The block you emit is the only output the host uses to update the spec.
|
|
10414
|
-
|
|
10415
|
-
**Inputs to consider:**
|
|
10416
|
-
- The URLs you opened (\`AB_ACTION|open|...\`)
|
|
10417
|
-
- The aria-labels, placeholders, and visible texts you clicked / filled / waited on
|
|
10418
|
-
- The component / page / route files that render those strings (find them with \`Grep\`/\`Read\`/\`Glob\`)
|
|
10419
|
-
|
|
10420
|
-
**How to choose paths:**
|
|
10421
|
-
1. For each URL the test navigates to, locate the route/page file and include it (e.g. \`src/app/tasks/page.tsx\`, \`src/pages/tasks/index.tsx\`).
|
|
10422
|
-
2. For each unique aria-label / placeholder / visible text you interacted with, \`Grep\` the codebase, find the defining component, and include either the file or its parent feature directory.
|
|
10423
|
-
3. Prefer **directory globs** (e.g. \`src/features/tasks/**\`) over individual files when several related components live in the same area. Otherwise list specific files.
|
|
10424
|
-
4. Skip third-party files (\`node_modules/\`), build output (\`dist/\`, \`.next/\`), and generated code.
|
|
10425
|
-
5. Be conservative — false positives (extra paths) are fine; false negatives (missing paths) cause drift to be missed in CI. When unsure whether a path is relevant, include it.
|
|
10426
|
-
6. **Path base:** a path inside this working directory MUST be relative to it — the same base your \`Glob\`/\`Grep\` tools use (\`src/features/tasks/**\`, NOT the repo-root form \`apps/foo/src/features/tasks/**\`). For a file in a monorepo sibling package this spec genuinely depends on, use its **repo-root-relative** path (e.g. \`packages/ui-kit/src/FilePicker.tsx\`); never emit \`../\` forms.
|
|
10427
|
-
7. **Shared / design-system components:** include one only when the spec's checks depend on that component's specific behaviour (e.g. the flow exercises its file picker). Do NOT list generic primitives every screen uses (buttons, tags, dialogs, layout) — they would scope this spec into almost every UI change.
|
|
10428
|
-
|
|
10429
|
-
**Output format (STRICT — one line per path, no leading dashes, no commentary inside the block):**
|
|
10430
|
-
|
|
10431
|
-
\`\`\`
|
|
10432
|
-
RELATED_PATHS_BEGIN
|
|
10433
|
-
src/features/tasks/**
|
|
10434
|
-
src/app/tasks/page.tsx
|
|
10435
|
-
RELATED_PATHS_END
|
|
10436
|
-
\`\`\`
|
|
10437
|
-
|
|
10438
|
-
Emit the block outside any other code block, on its own lines. If the test could not exercise the feature at all (e.g. blocked early), emit the block anyway with whatever paths you can identify; emit \`RELATED_PATHS_BEGIN\` immediately followed by \`RELATED_PATHS_END\` only if you genuinely could not identify any related file.
|
|
10439
|
-
|
|
10440
10373
|
`;
|
|
10441
10374
|
}
|
|
10442
10375
|
function buildTracePrompt(title) {
|
|
10443
10376
|
return `Execute the test for "${title}". Each step's instruction includes the URL or selector context it needs.`;
|
|
10444
10377
|
}
|
|
10445
10378
|
//#endregion
|
|
10446
|
-
//#region src/drift/parse-related-paths.ts
|
|
10447
|
-
/**
|
|
10448
|
-
* Pull a `RELATED_PATHS_BEGIN ... RELATED_PATHS_END` block out of the trace
|
|
10449
|
-
* agent's combined text output. Lines inside the block become entries; blank
|
|
10450
|
-
* lines, bullet markers, and code fences are tolerated. Returns null when the
|
|
10451
|
-
* agent did not emit a block at all so the caller can warn instead of silently
|
|
10452
|
-
* clearing the spec's existing relatedPaths.
|
|
10453
|
-
*/
|
|
10454
|
-
function parseRelatedPathsBlock(text) {
|
|
10455
|
-
const match = text.match(/RELATED_PATHS_BEGIN\s*\n?([\s\S]*?)\n?RELATED_PATHS_END/);
|
|
10456
|
-
if (!match || match[1] === void 0) return null;
|
|
10457
|
-
const seen = /* @__PURE__ */ new Set();
|
|
10458
|
-
const out = [];
|
|
10459
|
-
for (const raw of match[1].split("\n")) {
|
|
10460
|
-
const line = raw.replace(/^```.*$/, "").trim();
|
|
10461
|
-
if (!line) continue;
|
|
10462
|
-
const cleaned = line.replace(/^[-*]\s+/, "").trim();
|
|
10463
|
-
if (!cleaned || seen.has(cleaned)) continue;
|
|
10464
|
-
seen.add(cleaned);
|
|
10465
|
-
out.push(cleaned);
|
|
10466
|
-
}
|
|
10467
|
-
return out;
|
|
10468
|
-
}
|
|
10469
|
-
/**
|
|
10470
|
-
* Normalize parsed entries to the canonical bases the drift matcher expects:
|
|
10471
|
-
* app-relative for files inside the working directory, repo-root-relative
|
|
10472
|
-
* for files outside it. Models sometimes emit an in-app path in repo-root
|
|
10473
|
-
* form (`apps/foo/src/...`) or an outside-package path in `../` form — both
|
|
10474
|
-
* would silently never match.
|
|
10475
|
-
*
|
|
10476
|
-
* `cwdPrefix` is the working directory's path relative to the repo root
|
|
10477
|
-
* ("" when they coincide, unknown → pass `null` to skip prefix handling).
|
|
10478
|
-
*/
|
|
10479
|
-
function normalizeRelatedPaths(paths, cwdPrefix) {
|
|
10480
|
-
const out = [];
|
|
10481
|
-
const warnings = [];
|
|
10482
|
-
const seen = /* @__PURE__ */ new Set();
|
|
10483
|
-
const push = (p) => {
|
|
10484
|
-
if (!seen.has(p)) {
|
|
10485
|
-
seen.add(p);
|
|
10486
|
-
out.push(p);
|
|
10487
|
-
}
|
|
10488
|
-
};
|
|
10489
|
-
for (const raw of paths) {
|
|
10490
|
-
const p = raw.replace(/^\.\//, "");
|
|
10491
|
-
if (cwdPrefix !== null && cwdPrefix !== "" && p.startsWith(`${cwdPrefix}/`)) {
|
|
10492
|
-
push(p.slice(cwdPrefix.length + 1));
|
|
10493
|
-
continue;
|
|
10494
|
-
}
|
|
10495
|
-
if (p.startsWith("../")) {
|
|
10496
|
-
if (cwdPrefix === null || cwdPrefix === "") {
|
|
10497
|
-
warnings.push(`dropped relatedPaths entry "${raw}" — it points outside the repository`);
|
|
10498
|
-
continue;
|
|
10499
|
-
}
|
|
10500
|
-
const segments = [...cwdPrefix.split("/"), ...p.split("/")];
|
|
10501
|
-
const resolved = [];
|
|
10502
|
-
let escaped = false;
|
|
10503
|
-
for (const seg of segments) {
|
|
10504
|
-
if (seg === "" || seg === ".") continue;
|
|
10505
|
-
if (seg === "..") {
|
|
10506
|
-
if (resolved.length === 0) {
|
|
10507
|
-
escaped = true;
|
|
10508
|
-
break;
|
|
10509
|
-
}
|
|
10510
|
-
resolved.pop();
|
|
10511
|
-
} else resolved.push(seg);
|
|
10512
|
-
}
|
|
10513
|
-
if (escaped || resolved.length === 0) {
|
|
10514
|
-
warnings.push(`dropped relatedPaths entry "${raw}" — it points outside the repository`);
|
|
10515
|
-
continue;
|
|
10516
|
-
}
|
|
10517
|
-
push(resolved.join("/"));
|
|
10518
|
-
continue;
|
|
10519
|
-
}
|
|
10520
|
-
push(p);
|
|
10521
|
-
}
|
|
10522
|
-
return {
|
|
10523
|
-
paths: out,
|
|
10524
|
-
warnings
|
|
10525
|
-
};
|
|
10526
|
-
}
|
|
10527
|
-
//#endregion
|
|
10528
10379
|
//#region src/runtime/replay-validate.ts
|
|
10529
10380
|
function isPollCheck(x) {
|
|
10530
10381
|
return x !== null && !Array.isArray(x) && x.kind === "poll-present";
|
|
@@ -11029,7 +10880,11 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
|
|
|
11029
10880
|
const expanded = expandSpec(spec, { blocks: await loadAllBlocks(opts.cwd) });
|
|
11030
10881
|
const envScrub = buildSpecEnvScrub(spec, expanded);
|
|
11031
10882
|
const envScrubMap = envScrub.map;
|
|
11032
|
-
if (envScrub.unresolved.length > 0)
|
|
10883
|
+
if (envScrub.unresolved.length > 0) {
|
|
10884
|
+
warn(`spec references env var(s) that are unset at record time: ${envScrub.unresolved.join(", ")}`);
|
|
10885
|
+
warn("their concrete trace-time values (e.g. navigate URLs) will be baked into ir.json instead of kept as ${VAR}, so the recording won't switch across environments.");
|
|
10886
|
+
hint("load these vars before recording — pass --profile <name> (hub) or define them in .env — then re-record.");
|
|
10887
|
+
}
|
|
11033
10888
|
meta("spec", spec.title);
|
|
11034
10889
|
meta("steps", expanded.length);
|
|
11035
10890
|
const includes = collectIncludedBlockNames(spec);
|
|
@@ -11051,7 +10906,6 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
|
|
|
11051
10906
|
let overallStatus = "passed";
|
|
11052
10907
|
const traceActions = [];
|
|
11053
10908
|
const stepTracker = createStepTracker();
|
|
11054
|
-
let relatedPathsBuffer = null;
|
|
11055
10909
|
const withStepId = (action, stepId) => {
|
|
11056
10910
|
if (!action) return null;
|
|
11057
10911
|
return stepId ? {
|
|
@@ -11098,13 +10952,7 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
|
|
|
11098
10952
|
if (msg.type !== "assistant") return;
|
|
11099
10953
|
for (const block of msg.message.content ?? []) {
|
|
11100
10954
|
if (block.type !== "text" || !block.text) continue;
|
|
11101
|
-
const
|
|
11102
|
-
if (relatedPathsBuffer !== null) relatedPathsBuffer += text + "\n";
|
|
11103
|
-
else {
|
|
11104
|
-
const idx = text.indexOf("RELATED_PATHS_BEGIN");
|
|
11105
|
-
if (idx !== -1) relatedPathsBuffer = text.slice(idx) + "\n";
|
|
11106
|
-
}
|
|
11107
|
-
for (const line of text.split("\n")) {
|
|
10955
|
+
for (const line of block.text.split("\n")) {
|
|
11108
10956
|
const trimmed = line.trim();
|
|
11109
10957
|
const status = parseStatusLine(line);
|
|
11110
10958
|
if (status) {
|
|
@@ -11129,13 +10977,6 @@ async function runTrace(featureName, specName, model, validationMode = "lenient"
|
|
|
11129
10977
|
meta("saved", recordingPath);
|
|
11130
10978
|
meta("actions", validatedActions.length);
|
|
11131
10979
|
meta("status", overallStatus.toUpperCase());
|
|
11132
|
-
const parsedRelatedPaths = relatedPathsBuffer !== null ? parseRelatedPathsBlock(relatedPathsBuffer) : null;
|
|
11133
|
-
if (parsedRelatedPaths !== null) {
|
|
11134
|
-
const { paths: relatedPaths, warnings } = normalizeRelatedPaths(parsedRelatedPaths, await resolveCwdPrefix(opts.cwd));
|
|
11135
|
-
for (const w of warnings) warn(w);
|
|
11136
|
-
const written = await updateSpecRelatedPaths(featureName, specName, relatedPaths, opts.cwd);
|
|
11137
|
-
if (written) meta("relatedPaths", `${relatedPaths.length} path(s) written to ${written}`);
|
|
11138
|
-
} else warn("trace did not emit a RELATED_PATHS block; drift --changed cannot scope this spec");
|
|
11139
10980
|
hint(`run 'ccqa generate ${featureName}/${specName}' to generate a test script`);
|
|
11140
10981
|
return {
|
|
11141
10982
|
status: overallStatus,
|
|
@@ -11429,23 +11270,6 @@ function parseStatusLine(text) {
|
|
|
11429
11270
|
}
|
|
11430
11271
|
return null;
|
|
11431
11272
|
}
|
|
11432
|
-
/**
|
|
11433
|
-
* The working directory's path relative to the git repo root ("" when they
|
|
11434
|
-
* coincide), used to pin relatedPaths bases. Null when git is unavailable —
|
|
11435
|
-
* normalization then skips prefix handling rather than guessing.
|
|
11436
|
-
*/
|
|
11437
|
-
async function resolveCwdPrefix(cwd) {
|
|
11438
|
-
const { execFile } = await import("node:child_process");
|
|
11439
|
-
const { promisify } = await import("node:util");
|
|
11440
|
-
const { relative, resolve } = await import("node:path");
|
|
11441
|
-
const dir = resolve(cwd ?? process.cwd());
|
|
11442
|
-
try {
|
|
11443
|
-
const { stdout } = await promisify(execFile)("git", ["rev-parse", "--show-toplevel"], { cwd: dir });
|
|
11444
|
-
return relative(stdout.trim(), dir);
|
|
11445
|
-
} catch {
|
|
11446
|
-
return null;
|
|
11447
|
-
}
|
|
11448
|
-
}
|
|
11449
11273
|
//#endregion
|
|
11450
11274
|
//#region src/prompts/perspectives.ts
|
|
11451
11275
|
/**
|
|
@@ -13246,7 +13070,7 @@ const initCommand = new Command("init").description("Create the .ccqa/ spec skel
|
|
|
13246
13070
|
});
|
|
13247
13071
|
//#endregion
|
|
13248
13072
|
//#region src/cli/session.ts
|
|
13249
|
-
const AB =
|
|
13073
|
+
const AB = resolveAgentBrowserBin$1();
|
|
13250
13074
|
/**
|
|
13251
13075
|
* Run agent-browser attached to the user's terminal (no timeout, inherited
|
|
13252
13076
|
* stdio) so a human can complete an interactive login during `bootstrap`.
|
|
@@ -13313,6 +13137,7 @@ const bootstrapCommand = new Command("bootstrap").description("Open a headed bro
|
|
|
13313
13137
|
process.exit(1);
|
|
13314
13138
|
}
|
|
13315
13139
|
const state = await loadStorageState(tmpPath);
|
|
13140
|
+
let payload = state;
|
|
13316
13141
|
if (opts.url) {
|
|
13317
13142
|
info("verifying the saved session restores to a signed-in page…");
|
|
13318
13143
|
const check = verifySessionRestores(tmpPath, opts.url);
|
|
@@ -13322,8 +13147,12 @@ const bootstrapCommand = new Command("bootstrap").description("Open a headed bro
|
|
|
13322
13147
|
process.exit(1);
|
|
13323
13148
|
}
|
|
13324
13149
|
info("restore verified — the session starts signed in.");
|
|
13325
|
-
|
|
13326
|
-
|
|
13150
|
+
payload = {
|
|
13151
|
+
...state,
|
|
13152
|
+
[SESSION_VERIFY_URL_KEY]: opts.url
|
|
13153
|
+
};
|
|
13154
|
+
} else warn("no --url given — the session can't be verified now, and runs can't health-check it before executing steps; strongly consider re-running with --url <a signed-in page URL>.");
|
|
13155
|
+
await hub.putSession(project, opts.profile ?? "default", name, payload);
|
|
13327
13156
|
} finally {
|
|
13328
13157
|
await rm(tmpDir, {
|
|
13329
13158
|
recursive: true,
|
package/dist/package.json
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { c as FAILURE_SOURCE, l as FAILURE_STEP_ID, n as spawnAB, t as sleepSync } from "../spawn-ab-B9Il2TMd.mjs";
|
|
2
2
|
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { dirname, isAbsolute, join, resolve } from "node:path";
|
|
4
4
|
//#region src/runtime/test-helpers.ts
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { statSync } from "node:fs";
|
|
3
|
+
import { delimiter, dirname, join } from "node:path";
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
|
+
//#region src/runtime/evidence-constants.ts
|
|
6
|
+
/**
|
|
7
|
+
* Shared constants for step-boundary evidence captured by abStepEvidence() /
|
|
8
|
+
* captureFailureEvidence() and consumed by the run report. Kept under
|
|
9
|
+
* `runtime/` so the test-helpers module — which generated test scripts import
|
|
10
|
+
* via `ccqa/test-helpers` — can stay free of CLI-side imports while still
|
|
11
|
+
* sharing the literal with run.ts.
|
|
12
|
+
*/
|
|
13
|
+
/** stepId reserved for the screenshot captured by fail() at the moment of an assertion failure. */
|
|
14
|
+
const FAILURE_STEP_ID = "failure";
|
|
15
|
+
/** source value paired with FAILURE_STEP_ID so the report can tell failure captures apart from step captures. */
|
|
16
|
+
const FAILURE_SOURCE = "failed";
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/runtime/agent-browser-bin.ts
|
|
19
|
+
const require = createRequire(import.meta.url);
|
|
20
|
+
function hasAgentBrowserShim(dir) {
|
|
21
|
+
try {
|
|
22
|
+
statSync(join(dir, "agent-browser"));
|
|
23
|
+
return true;
|
|
24
|
+
} catch {
|
|
25
|
+
return false;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* Walks up from `start` looking for a `node_modules/.bin/agent-browser` shim.
|
|
30
|
+
* Returns the .bin directory containing the shim, or null if none is found.
|
|
31
|
+
*/
|
|
32
|
+
function findNodeModulesBin(start) {
|
|
33
|
+
let cur = start;
|
|
34
|
+
while (true) {
|
|
35
|
+
const candidate = join(cur, "node_modules", ".bin");
|
|
36
|
+
if (hasAgentBrowserShim(candidate)) return candidate;
|
|
37
|
+
const parent = dirname(cur);
|
|
38
|
+
if (parent === cur) return null;
|
|
39
|
+
cur = parent;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
/** The shim-directory walk shared by every resolution below (no env override). */
|
|
43
|
+
function resolveShimDir() {
|
|
44
|
+
const fromCwd = findNodeModulesBin(process.cwd());
|
|
45
|
+
if (fromCwd) return fromCwd;
|
|
46
|
+
const fromSelf = findNodeModulesBin(dirname(require.resolve("agent-browser/package.json")));
|
|
47
|
+
if (fromSelf) return fromSelf;
|
|
48
|
+
try {
|
|
49
|
+
const candidate = join(dirname(require.resolve("agent-browser/package.json")), "node_modules", ".bin");
|
|
50
|
+
if (hasAgentBrowserShim(candidate)) return candidate;
|
|
51
|
+
} catch {}
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* INVARIANT: every agent-browser invocation in one ccqa process — the host
|
|
56
|
+
* side (`spawnAB`: state load, replay probes, …) and the Claude subprocess
|
|
57
|
+
* (via the PATH prepended by `pathWithAgentBrowserShim`) — must resolve to
|
|
58
|
+
* the SAME binary. agent-browser runs one daemon per binary, and a state
|
|
59
|
+
* loaded into one daemon's session is invisible to a same-named session on
|
|
60
|
+
* another daemon, so a split resolution silently loses session restores.
|
|
61
|
+
* Both entry points below therefore share one resolution order:
|
|
62
|
+
* `CCQA_AB_BIN` (explicit override, e.g. the e2e harness or a dev build
|
|
63
|
+
* driving a consumer project's agent-browser) → the peer-installed shim
|
|
64
|
+
* (consumer project first, then ccqa's own tree).
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* Resolves the executable `spawnAB` should invoke. Falls back to the package
|
|
68
|
+
* JS entry (resolvable whenever the peer dependency is installed) when no
|
|
69
|
+
* shim directory exists; throws only if agent-browser is missing entirely.
|
|
70
|
+
*/
|
|
71
|
+
function resolveAgentBrowserBin() {
|
|
72
|
+
const override = process.env["CCQA_AB_BIN"];
|
|
73
|
+
if (override) return override;
|
|
74
|
+
const shimDir = resolveShimDir();
|
|
75
|
+
if (shimDir) return join(shimDir, "agent-browser");
|
|
76
|
+
return require.resolve("agent-browser/bin/agent-browser.js");
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Resolves the directory containing the `agent-browser` shim that npm/pnpm
|
|
80
|
+
* exposes on PATH for the peer-installed package. Used by `ccqa trace` /
|
|
81
|
+
* `ccqa run` to prepend this directory to PATH so the Claude subprocess can
|
|
82
|
+
* invoke `agent-browser ...` without requiring a global install.
|
|
83
|
+
*
|
|
84
|
+
* Returns null if agent-browser cannot be located.
|
|
85
|
+
*/
|
|
86
|
+
function resolveAgentBrowserBinDir() {
|
|
87
|
+
const override = process.env["CCQA_AB_BIN"];
|
|
88
|
+
if (override) return dirname(override);
|
|
89
|
+
return resolveShimDir();
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Returns a PATH string with the agent-browser shim directory prepended,
|
|
93
|
+
* so `agent-browser ...` resolves without a global install. Falls back to
|
|
94
|
+
* the original PATH when the package can't be resolved.
|
|
95
|
+
*/
|
|
96
|
+
function pathWithAgentBrowserShim(currentPath) {
|
|
97
|
+
const path = currentPath ?? "";
|
|
98
|
+
const dir = resolveAgentBrowserBinDir();
|
|
99
|
+
if (!dir) return path;
|
|
100
|
+
if (path.split(delimiter).includes(dir)) return path;
|
|
101
|
+
return dir + delimiter + path;
|
|
102
|
+
}
|
|
103
|
+
/**
|
|
104
|
+
* Confirms before launching Claude that an `agent-browser` shim is reachable
|
|
105
|
+
* via PATH. We do this up front so a missing peer dependency fails fast with
|
|
106
|
+
* a clear message, instead of Claude burning tokens probing the system with
|
|
107
|
+
* `which`, `find`, `npm install`, etc.
|
|
108
|
+
*
|
|
109
|
+
* The `resolver` argument is for tests; production calls take no args.
|
|
110
|
+
*/
|
|
111
|
+
function assertAgentBrowserAvailable(resolver = resolveAgentBrowserBinDir) {
|
|
112
|
+
const probe = process.env["CCQA_AB_BIN"] ?? (() => {
|
|
113
|
+
const dir = resolver();
|
|
114
|
+
return dir === null ? null : join(dir, "agent-browser");
|
|
115
|
+
})();
|
|
116
|
+
if (!probe) throw new AgentBrowserUnavailableError();
|
|
117
|
+
try {
|
|
118
|
+
const s = statSync(probe);
|
|
119
|
+
if (!s.isFile() && !s.isSymbolicLink()) throw new AgentBrowserUnavailableError();
|
|
120
|
+
} catch {
|
|
121
|
+
throw new AgentBrowserUnavailableError();
|
|
122
|
+
}
|
|
123
|
+
return dirname(probe);
|
|
124
|
+
}
|
|
125
|
+
var AgentBrowserUnavailableError = class extends Error {
|
|
126
|
+
constructor() {
|
|
127
|
+
super("agent-browser binary not found on PATH");
|
|
128
|
+
this.name = "AgentBrowserUnavailableError";
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
/** Human-readable explanation shown to the user when the guard fires. */
|
|
132
|
+
function formatAgentBrowserUnavailableMessage() {
|
|
133
|
+
return [
|
|
134
|
+
"agent-browser is not installed or not on PATH.",
|
|
135
|
+
"",
|
|
136
|
+
"ccqa drives the browser via the peer-installed `agent-browser` package.",
|
|
137
|
+
"Install it in this project:",
|
|
138
|
+
"",
|
|
139
|
+
" pnpm add -D agent-browser",
|
|
140
|
+
" # or",
|
|
141
|
+
" npm install -D agent-browser",
|
|
142
|
+
"",
|
|
143
|
+
"If it is already installed, make sure you are running ccqa from the",
|
|
144
|
+
"project root (or via your package runner, e.g. `pnpm exec ccqa ...`)."
|
|
145
|
+
].join("\n");
|
|
146
|
+
}
|
|
147
|
+
//#endregion
|
|
148
|
+
//#region src/runtime/spawn-ab.ts
|
|
149
|
+
const AB = resolveAgentBrowserBin();
|
|
150
|
+
const EAGAIN_PATTERN = /Resource temporarily unavailable|os error 35/i;
|
|
151
|
+
const EAGAIN_TOTAL_BUDGET_MS = 3e4;
|
|
152
|
+
const EAGAIN_BACKOFF_MS = [
|
|
153
|
+
100,
|
|
154
|
+
200,
|
|
155
|
+
300,
|
|
156
|
+
500,
|
|
157
|
+
700,
|
|
158
|
+
1e3,
|
|
159
|
+
1500,
|
|
160
|
+
2e3,
|
|
161
|
+
2500,
|
|
162
|
+
3e3,
|
|
163
|
+
3e3,
|
|
164
|
+
3e3,
|
|
165
|
+
3e3,
|
|
166
|
+
3e3,
|
|
167
|
+
3e3
|
|
168
|
+
];
|
|
169
|
+
const PROCESS_HARD_TIMEOUT_MS = 35e3;
|
|
170
|
+
function sleepSync(ms) {
|
|
171
|
+
const buf = new SharedArrayBuffer(4);
|
|
172
|
+
Atomics.wait(new Int32Array(buf), 0, 0, ms);
|
|
173
|
+
}
|
|
174
|
+
function spawnABOnce(args) {
|
|
175
|
+
const result = spawnSync(AB, args, {
|
|
176
|
+
stdio: "pipe",
|
|
177
|
+
timeout: PROCESS_HARD_TIMEOUT_MS
|
|
178
|
+
});
|
|
179
|
+
return {
|
|
180
|
+
status: result.status,
|
|
181
|
+
stdout: result.stdout?.toString() ?? "",
|
|
182
|
+
stderr: (result.stderr?.toString() ?? "") + (result.signal === "SIGTERM" ? "\n[ccqa] agent-browser killed after hard timeout" : "")
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
/**
|
|
186
|
+
* Invoke `agent-browser` once and return its exit status/stdout/stderr,
|
|
187
|
+
* retrying internally up to ~30s while the daemon's state file is in the
|
|
188
|
+
* "Resource temporarily unavailable" race window. Used by both the test
|
|
189
|
+
* runtime (`test-helpers.ts`) and the post-trace replay validation
|
|
190
|
+
* (`replay-validate.ts`). Kept out of `test-helpers.ts` because that
|
|
191
|
+
* module is also the public surface for generated test scripts — exposing
|
|
192
|
+
* the raw spawner there would widen the contract for end users.
|
|
193
|
+
*/
|
|
194
|
+
function spawnAB(args) {
|
|
195
|
+
let result = spawnABOnce(args);
|
|
196
|
+
let elapsed = 0;
|
|
197
|
+
let attempt = 0;
|
|
198
|
+
while (result.status !== 0 && elapsed < EAGAIN_TOTAL_BUDGET_MS) {
|
|
199
|
+
const combined = `${result.stdout}\n${result.stderr}`;
|
|
200
|
+
if (!EAGAIN_PATTERN.test(combined)) return result;
|
|
201
|
+
const wait = EAGAIN_BACKOFF_MS[attempt] ?? 3e3;
|
|
202
|
+
sleepSync(wait);
|
|
203
|
+
elapsed += wait;
|
|
204
|
+
attempt++;
|
|
205
|
+
result = spawnABOnce(args);
|
|
206
|
+
}
|
|
207
|
+
return result;
|
|
208
|
+
}
|
|
209
|
+
//#endregion
|
|
210
|
+
export { formatAgentBrowserUnavailableMessage as a, FAILURE_SOURCE as c, assertAgentBrowserAvailable as i, FAILURE_STEP_ID as l, spawnAB as n, pathWithAgentBrowserShim as o, AgentBrowserUnavailableError as r, resolveAgentBrowserBin as s, sleepSync as t };
|
package/package.json
CHANGED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
import { createRequire } from "node:module";
|
|
2
|
-
import { spawnSync } from "node:child_process";
|
|
3
|
-
//#region src/runtime/evidence-constants.ts
|
|
4
|
-
/**
|
|
5
|
-
* Shared constants for step-boundary evidence captured by abStepEvidence() /
|
|
6
|
-
* captureFailureEvidence() and consumed by the run report. Kept under
|
|
7
|
-
* `runtime/` so the test-helpers module — which generated test scripts import
|
|
8
|
-
* via `ccqa/test-helpers` — can stay free of CLI-side imports while still
|
|
9
|
-
* sharing the literal with run.ts.
|
|
10
|
-
*/
|
|
11
|
-
/** stepId reserved for the screenshot captured by fail() at the moment of an assertion failure. */
|
|
12
|
-
const FAILURE_STEP_ID = "failure";
|
|
13
|
-
/** source value paired with FAILURE_STEP_ID so the report can tell failure captures apart from step captures. */
|
|
14
|
-
const FAILURE_SOURCE = "failed";
|
|
15
|
-
//#endregion
|
|
16
|
-
//#region src/runtime/spawn-ab.ts
|
|
17
|
-
const require = createRequire(import.meta.url);
|
|
18
|
-
const AB = process.env["CCQA_AB_BIN"] ?? require.resolve("agent-browser/bin/agent-browser.js");
|
|
19
|
-
const EAGAIN_PATTERN = /Resource temporarily unavailable|os error 35/i;
|
|
20
|
-
const EAGAIN_TOTAL_BUDGET_MS = 3e4;
|
|
21
|
-
const EAGAIN_BACKOFF_MS = [
|
|
22
|
-
100,
|
|
23
|
-
200,
|
|
24
|
-
300,
|
|
25
|
-
500,
|
|
26
|
-
700,
|
|
27
|
-
1e3,
|
|
28
|
-
1500,
|
|
29
|
-
2e3,
|
|
30
|
-
2500,
|
|
31
|
-
3e3,
|
|
32
|
-
3e3,
|
|
33
|
-
3e3,
|
|
34
|
-
3e3,
|
|
35
|
-
3e3,
|
|
36
|
-
3e3
|
|
37
|
-
];
|
|
38
|
-
const PROCESS_HARD_TIMEOUT_MS = 35e3;
|
|
39
|
-
function sleepSync(ms) {
|
|
40
|
-
const buf = new SharedArrayBuffer(4);
|
|
41
|
-
Atomics.wait(new Int32Array(buf), 0, 0, ms);
|
|
42
|
-
}
|
|
43
|
-
function spawnABOnce(args) {
|
|
44
|
-
const result = spawnSync(AB, args, {
|
|
45
|
-
stdio: "pipe",
|
|
46
|
-
timeout: PROCESS_HARD_TIMEOUT_MS
|
|
47
|
-
});
|
|
48
|
-
return {
|
|
49
|
-
status: result.status,
|
|
50
|
-
stdout: result.stdout?.toString() ?? "",
|
|
51
|
-
stderr: (result.stderr?.toString() ?? "") + (result.signal === "SIGTERM" ? "\n[ccqa] agent-browser killed after hard timeout" : "")
|
|
52
|
-
};
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Invoke `agent-browser` once and return its exit status/stdout/stderr,
|
|
56
|
-
* retrying internally up to ~30s while the daemon's state file is in the
|
|
57
|
-
* "Resource temporarily unavailable" race window. Used by both the test
|
|
58
|
-
* runtime (`test-helpers.ts`) and the post-trace replay validation
|
|
59
|
-
* (`replay-validate.ts`). Kept out of `test-helpers.ts` because that
|
|
60
|
-
* module is also the public surface for generated test scripts — exposing
|
|
61
|
-
* the raw spawner there would widen the contract for end users.
|
|
62
|
-
*/
|
|
63
|
-
function spawnAB(args) {
|
|
64
|
-
let result = spawnABOnce(args);
|
|
65
|
-
let elapsed = 0;
|
|
66
|
-
let attempt = 0;
|
|
67
|
-
while (result.status !== 0 && elapsed < EAGAIN_TOTAL_BUDGET_MS) {
|
|
68
|
-
const combined = `${result.stdout}\n${result.stderr}`;
|
|
69
|
-
if (!EAGAIN_PATTERN.test(combined)) return result;
|
|
70
|
-
const wait = EAGAIN_BACKOFF_MS[attempt] ?? 3e3;
|
|
71
|
-
sleepSync(wait);
|
|
72
|
-
elapsed += wait;
|
|
73
|
-
attempt++;
|
|
74
|
-
result = spawnABOnce(args);
|
|
75
|
-
}
|
|
76
|
-
return result;
|
|
77
|
-
}
|
|
78
|
-
//#endregion
|
|
79
|
-
export { FAILURE_STEP_ID as i, spawnAB as n, FAILURE_SOURCE as r, sleepSync as t };
|