ccqa 1.2.0 → 1.3.0
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 +129 -137
- package/dist/package.json +3 -2
- package/dist/runtime/test-helpers.mjs +1 -1
- package/dist/spawn-ab-B9Il2TMd.mjs +210 -0
- package/package.json +3 -2
- 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";
|
|
@@ -679,10 +679,10 @@ function bundledVitestConfigPath() {
|
|
|
679
679
|
}
|
|
680
680
|
//#endregion
|
|
681
681
|
//#region src/runtime/spawn-vitest.ts
|
|
682
|
-
const require$
|
|
682
|
+
const require$1 = createRequire(import.meta.url);
|
|
683
683
|
function resolveVitestBin() {
|
|
684
|
-
const pkgPath = require$
|
|
685
|
-
const pkg = require$
|
|
684
|
+
const pkgPath = require$1.resolve("vitest/package.json");
|
|
685
|
+
const pkg = require$1(pkgPath);
|
|
686
686
|
const binRel = typeof pkg.bin === "string" ? pkg.bin : pkg.bin?.vitest;
|
|
687
687
|
if (!binRel) throw new Error(`vitest package.json has no bin entry (resolved at ${pkgPath})`);
|
|
688
688
|
return resolve(dirname(pkgPath), binRel);
|
|
@@ -3931,104 +3931,6 @@ async function warnStaleBlockArtifacts() {
|
|
|
3931
3931
|
for (const p of stale) hint(`stale block artifact detected: ${p} — v0.4 no longer uses these; delete it manually.`);
|
|
3932
3932
|
}
|
|
3933
3933
|
//#endregion
|
|
3934
|
-
//#region src/runtime/agent-browser-bin.ts
|
|
3935
|
-
const require$1 = createRequire(import.meta.url);
|
|
3936
|
-
function hasAgentBrowserShim(dir) {
|
|
3937
|
-
try {
|
|
3938
|
-
statSync(join(dir, "agent-browser"));
|
|
3939
|
-
return true;
|
|
3940
|
-
} catch {
|
|
3941
|
-
return false;
|
|
3942
|
-
}
|
|
3943
|
-
}
|
|
3944
|
-
/**
|
|
3945
|
-
* Walks up from `start` looking for a `node_modules/.bin/agent-browser` shim.
|
|
3946
|
-
* Returns the .bin directory containing the shim, or null if none is found.
|
|
3947
|
-
*/
|
|
3948
|
-
function findNodeModulesBin(start) {
|
|
3949
|
-
let cur = start;
|
|
3950
|
-
while (true) {
|
|
3951
|
-
const candidate = join(cur, "node_modules", ".bin");
|
|
3952
|
-
if (hasAgentBrowserShim(candidate)) return candidate;
|
|
3953
|
-
const parent = dirname(cur);
|
|
3954
|
-
if (parent === cur) return null;
|
|
3955
|
-
cur = parent;
|
|
3956
|
-
}
|
|
3957
|
-
}
|
|
3958
|
-
/**
|
|
3959
|
-
* Resolves the directory containing the `agent-browser` shim that npm/pnpm
|
|
3960
|
-
* exposes on PATH for the peer-installed package. Used by `ccqa trace` to
|
|
3961
|
-
* prepend this directory to PATH so the Claude subprocess can invoke
|
|
3962
|
-
* `agent-browser ...` without requiring a global install.
|
|
3963
|
-
*
|
|
3964
|
-
* Returns null if agent-browser cannot be located.
|
|
3965
|
-
*/
|
|
3966
|
-
function resolveAgentBrowserBinDir() {
|
|
3967
|
-
const fromCwd = findNodeModulesBin(process.cwd());
|
|
3968
|
-
if (fromCwd) return fromCwd;
|
|
3969
|
-
const fromSelf = findNodeModulesBin(dirname(require$1.resolve("agent-browser/package.json")));
|
|
3970
|
-
if (fromSelf) return fromSelf;
|
|
3971
|
-
try {
|
|
3972
|
-
const candidate = join(dirname(require$1.resolve("agent-browser/package.json")), "node_modules", ".bin");
|
|
3973
|
-
if (hasAgentBrowserShim(candidate)) return candidate;
|
|
3974
|
-
} catch {}
|
|
3975
|
-
return null;
|
|
3976
|
-
}
|
|
3977
|
-
/**
|
|
3978
|
-
* Returns a PATH string with the agent-browser shim directory prepended,
|
|
3979
|
-
* so `agent-browser ...` resolves without a global install. Falls back to
|
|
3980
|
-
* the original PATH when the package can't be resolved.
|
|
3981
|
-
*/
|
|
3982
|
-
function pathWithAgentBrowserShim(currentPath) {
|
|
3983
|
-
const path = currentPath ?? "";
|
|
3984
|
-
const dir = resolveAgentBrowserBinDir();
|
|
3985
|
-
if (!dir) return path;
|
|
3986
|
-
if (path.split(delimiter).includes(dir)) return path;
|
|
3987
|
-
return dir + delimiter + path;
|
|
3988
|
-
}
|
|
3989
|
-
/**
|
|
3990
|
-
* Confirms before launching Claude that an `agent-browser` shim is reachable
|
|
3991
|
-
* via PATH. We do this up front so a missing peer dependency fails fast with
|
|
3992
|
-
* a clear message, instead of Claude burning tokens probing the system with
|
|
3993
|
-
* `which`, `find`, `npm install`, etc.
|
|
3994
|
-
*
|
|
3995
|
-
* The `resolver` argument is for tests; production calls take no args.
|
|
3996
|
-
*/
|
|
3997
|
-
function assertAgentBrowserAvailable(resolver = resolveAgentBrowserBinDir) {
|
|
3998
|
-
const dir = resolver();
|
|
3999
|
-
if (!dir) throw new AgentBrowserUnavailableError();
|
|
4000
|
-
const shim = join(dir, "agent-browser");
|
|
4001
|
-
try {
|
|
4002
|
-
const s = statSync(shim);
|
|
4003
|
-
if (!s.isFile() && !s.isSymbolicLink()) throw new AgentBrowserUnavailableError();
|
|
4004
|
-
} catch {
|
|
4005
|
-
throw new AgentBrowserUnavailableError();
|
|
4006
|
-
}
|
|
4007
|
-
return dir;
|
|
4008
|
-
}
|
|
4009
|
-
var AgentBrowserUnavailableError = class extends Error {
|
|
4010
|
-
constructor() {
|
|
4011
|
-
super("agent-browser binary not found on PATH");
|
|
4012
|
-
this.name = "AgentBrowserUnavailableError";
|
|
4013
|
-
}
|
|
4014
|
-
};
|
|
4015
|
-
/** Human-readable explanation shown to the user when the guard fires. */
|
|
4016
|
-
function formatAgentBrowserUnavailableMessage() {
|
|
4017
|
-
return [
|
|
4018
|
-
"agent-browser is not installed or not on PATH.",
|
|
4019
|
-
"",
|
|
4020
|
-
"ccqa drives the browser via the peer-installed `agent-browser` package.",
|
|
4021
|
-
"Install it in this project:",
|
|
4022
|
-
"",
|
|
4023
|
-
" pnpm add -D agent-browser",
|
|
4024
|
-
" # or",
|
|
4025
|
-
" npm install -D agent-browser",
|
|
4026
|
-
"",
|
|
4027
|
-
"If it is already installed, make sure you are running ccqa from the",
|
|
4028
|
-
"project root (or via your package runner, e.g. `pnpm exec ccqa ...`)."
|
|
4029
|
-
].join("\n");
|
|
4030
|
-
}
|
|
4031
|
-
//#endregion
|
|
4032
3934
|
//#region src/cli/preflight.ts
|
|
4033
3935
|
/**
|
|
4034
3936
|
* Shared startup steps for every command that drives a real `agent-browser`
|
|
@@ -4307,6 +4209,14 @@ function readOctal(buf, offset, fieldLen) {
|
|
|
4307
4209
|
/** Default per-profile sessions root, relative to the project (`--cwd`). */
|
|
4308
4210
|
const SESSIONS_SUBDIR = ".ccqa/sessions";
|
|
4309
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
|
+
/**
|
|
4310
4220
|
* Resolve a session name to its state file path:
|
|
4311
4221
|
* `<cwd>/.ccqa/sessions/<profile>/<name>.json`. The name is a slug (validated
|
|
4312
4222
|
* by SessionNameSchema), so it can't escape the sessions directory. Used by
|
|
@@ -4428,20 +4338,54 @@ function loadStateIntoSession(sessionName, statePath) {
|
|
|
4428
4338
|
};
|
|
4429
4339
|
return { ok: true };
|
|
4430
4340
|
}
|
|
4341
|
+
/** Drop every trailing slash so `/a` and `/a/` compare equal. */
|
|
4342
|
+
function normalizePath(pathname) {
|
|
4343
|
+
return pathname.replace(/\/+$/, "");
|
|
4344
|
+
}
|
|
4345
|
+
/**
|
|
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.
|
|
4357
|
+
*
|
|
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
|
+
}
|
|
4431
4377
|
/**
|
|
4432
|
-
* Prove a
|
|
4433
|
-
* throwaway agent-browser session
|
|
4434
|
-
*
|
|
4435
|
-
*
|
|
4436
|
-
*
|
|
4437
|
-
*
|
|
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`.
|
|
4438
4384
|
*
|
|
4439
|
-
*
|
|
4440
|
-
*
|
|
4441
|
-
*
|
|
4442
|
-
*
|
|
4443
|
-
* error so the caller can surface it rather than silently trusting the save.
|
|
4444
|
-
* Always closes the throwaway session.
|
|
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.
|
|
4445
4389
|
*/
|
|
4446
4390
|
function verifySessionRestores(statePath, verifyUrl) {
|
|
4447
4391
|
const verifySession = `ccqa-session-verify-${process.pid}-${trackedTempDirs.size}`;
|
|
@@ -4477,17 +4421,17 @@ function verifySessionRestores(statePath, verifyUrl) {
|
|
|
4477
4421
|
const probe = spawnAB([
|
|
4478
4422
|
"--session",
|
|
4479
4423
|
verifySession,
|
|
4480
|
-
"
|
|
4481
|
-
"
|
|
4482
|
-
"input[type=password]"
|
|
4424
|
+
"eval",
|
|
4425
|
+
"location.href"
|
|
4483
4426
|
]);
|
|
4484
4427
|
if (probe.status !== 0) return {
|
|
4485
4428
|
restored: false,
|
|
4486
4429
|
reason: (probe.stderr || probe.stdout || `probe exited ${probe.status}`).trim()
|
|
4487
4430
|
};
|
|
4488
|
-
|
|
4431
|
+
const finalHref = unwrapEvalString(probe.stdout);
|
|
4432
|
+
if (!urlStayedAtTarget(verifyUrl, finalHref)) return {
|
|
4489
4433
|
restored: false,
|
|
4490
|
-
reason:
|
|
4434
|
+
reason: `restore left the verify URL — landed on ${safeOriginPath(finalHref)}`
|
|
4491
4435
|
};
|
|
4492
4436
|
return { restored: true };
|
|
4493
4437
|
} finally {
|
|
@@ -4498,6 +4442,25 @@ function verifySessionRestores(statePath, verifyUrl) {
|
|
|
4498
4442
|
]);
|
|
4499
4443
|
}
|
|
4500
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
|
+
}
|
|
4501
4464
|
//#endregion
|
|
4502
4465
|
//#region src/prompts/prompt-names.ts
|
|
4503
4466
|
/**
|
|
@@ -5632,21 +5595,33 @@ async function runDriftAuditOne(r, opts, cwd) {
|
|
|
5632
5595
|
return result.issues.length > 0 ? result.issues : null;
|
|
5633
5596
|
}
|
|
5634
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
|
+
/**
|
|
5635
5604
|
* Resolve `spec.session` names to a single state file to restore, fetching
|
|
5636
5605
|
* each named session from the hub (`.ccqa/sessions/*.json` is no longer
|
|
5637
5606
|
* read here). Every name must load as a valid agent-browser state (the spec
|
|
5638
5607
|
* assumes it starts signed-in); a missing/malformed session fails with a
|
|
5639
|
-
* `ccqa session bootstrap` hint instead of running unauthenticated.
|
|
5640
|
-
*
|
|
5641
|
-
*
|
|
5642
|
-
|
|
5643
|
-
|
|
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) {
|
|
5644
5618
|
if (names.length === 0 || hubCtx === null) return {
|
|
5645
5619
|
ok: false,
|
|
5646
5620
|
error: `session '${names.join(", ")}' requires a hub connection`,
|
|
5647
5621
|
hint: "set --hub-url/--hub-token (or CCQA_HUB_URL/CCQA_HUB_TOKEN) to restore sessions from the hub"
|
|
5648
5622
|
};
|
|
5649
5623
|
const resolvedProfile = profile ?? "default";
|
|
5624
|
+
const profileFlag = profile ? ` --profile ${profile}` : "";
|
|
5650
5625
|
const loaded = [];
|
|
5651
5626
|
const broken = [];
|
|
5652
5627
|
for (const name of names) {
|
|
@@ -5661,16 +5636,28 @@ async function resolveSessionState(names, hubCtx, profile) {
|
|
|
5661
5636
|
broken.push(name);
|
|
5662
5637
|
continue;
|
|
5663
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`);
|
|
5664
5654
|
loaded.push(state);
|
|
5665
5655
|
}
|
|
5666
|
-
if (broken.length > 0) {
|
|
5667
|
-
|
|
5668
|
-
|
|
5669
|
-
|
|
5670
|
-
|
|
5671
|
-
hint: `create it with: ${broken.map((name) => `ccqa session bootstrap ${name}${profileFlag}`).join(" · ")}`
|
|
5672
|
-
};
|
|
5673
|
-
}
|
|
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
|
+
};
|
|
5674
5661
|
const statePath = await writeMergedTempState(mergeStorageStates(loaded));
|
|
5675
5662
|
return {
|
|
5676
5663
|
ok: true,
|
|
@@ -13083,7 +13070,7 @@ const initCommand = new Command("init").description("Create the .ccqa/ spec skel
|
|
|
13083
13070
|
});
|
|
13084
13071
|
//#endregion
|
|
13085
13072
|
//#region src/cli/session.ts
|
|
13086
|
-
const AB =
|
|
13073
|
+
const AB = resolveAgentBrowserBin$1();
|
|
13087
13074
|
/**
|
|
13088
13075
|
* Run agent-browser attached to the user's terminal (no timeout, inherited
|
|
13089
13076
|
* stdio) so a human can complete an interactive login during `bootstrap`.
|
|
@@ -13150,6 +13137,7 @@ const bootstrapCommand = new Command("bootstrap").description("Open a headed bro
|
|
|
13150
13137
|
process.exit(1);
|
|
13151
13138
|
}
|
|
13152
13139
|
const state = await loadStorageState(tmpPath);
|
|
13140
|
+
let payload = state;
|
|
13153
13141
|
if (opts.url) {
|
|
13154
13142
|
info("verifying the saved session restores to a signed-in page…");
|
|
13155
13143
|
const check = verifySessionRestores(tmpPath, opts.url);
|
|
@@ -13159,8 +13147,12 @@ const bootstrapCommand = new Command("bootstrap").description("Open a headed bro
|
|
|
13159
13147
|
process.exit(1);
|
|
13160
13148
|
}
|
|
13161
13149
|
info("restore verified — the session starts signed in.");
|
|
13162
|
-
|
|
13163
|
-
|
|
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);
|
|
13164
13156
|
} finally {
|
|
13165
13157
|
await rm(tmpDir, {
|
|
13166
13158
|
recursive: true,
|
package/dist/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccqa",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Browser test recorder powered by Claude Code and agent-browser",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"dist/"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@anthropic-ai/claude-agent-sdk": "^0.
|
|
31
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.215",
|
|
32
|
+
"@anthropic-ai/sdk": "^0.112.3",
|
|
32
33
|
"commander": "^14.0.3",
|
|
33
34
|
"yaml": "^2.9.0",
|
|
34
35
|
"zod": "^4.3.6"
|
|
@@ -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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ccqa",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.3.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Browser test recorder powered by Claude Code and agent-browser",
|
|
6
6
|
"repository": {
|
|
@@ -28,7 +28,8 @@
|
|
|
28
28
|
"dist/"
|
|
29
29
|
],
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@anthropic-ai/claude-agent-sdk": "^0.
|
|
31
|
+
"@anthropic-ai/claude-agent-sdk": "^0.3.215",
|
|
32
|
+
"@anthropic-ai/sdk": "^0.112.3",
|
|
32
33
|
"commander": "^14.0.3",
|
|
33
34
|
"yaml": "^2.9.0",
|
|
34
35
|
"zod": "^4.3.6"
|
|
@@ -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 };
|