ccqa 0.10.1 → 0.10.2
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/README.md +36 -40
- package/dist/bin/ccqa.mjs +206 -12
- package/dist/package.json +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -69,7 +69,7 @@ ccqa run tasks/create-and-complete # vitest replays test.spec.ts; no LLM
|
|
|
69
69
|
ccqa run tasks/create-and-complete # Claude drives the browser every time
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
Live specs can start already-signed-in by
|
|
72
|
+
Live specs can start already-signed-in by naming a saved session with `session:`. Create it once with `ccqa session bootstrap <name>` (log in by hand, ccqa saves the cookies + localStorage), then specs restore it — see [Saved sessions](#saved-sessions-session) below for the bootstrap and the CI restore pattern.
|
|
73
73
|
|
|
74
74
|
By default deterministic runs write step-boundary screenshots and metadata to `ccqa-report/evidence/<feature>/<spec>/` so a reviewer can confirm a passing spec actually reached the states its `expected` clauses describe. Disable with `--no-evidence`.
|
|
75
75
|
|
|
@@ -207,71 +207,67 @@ ccqa run --retry 2 tasks/create-and-complete
|
|
|
207
207
|
|
|
208
208
|
Constraints on selectors / `agent-browser` subcommands that apply during `ccqa record` (no `eval`, no `@ref`, no bare-tag positional `find`, no chained agent-browser calls) are **relaxed** for live specs — Claude can use any subcommand and any selector style because there is no replay contract to honour.
|
|
209
209
|
|
|
210
|
-
###
|
|
210
|
+
### Saved sessions (`session:`)
|
|
211
211
|
|
|
212
|
-
By default each `ccqa run` of a live spec
|
|
212
|
+
By default each `ccqa run` of a live spec starts signed-out and logs in through its own steps. That's fine for plain form logins, but some providers gate every fresh browser with a device-trust check (an "unrecognized device" e-mail code, an MFA prompt) that a human has to clear by hand — impractical to repeat on every run, impossible in CI.
|
|
213
213
|
|
|
214
|
-
|
|
214
|
+
For those, save the signed-in browser state once and let the spec **restore** it. ccqa does not manage authentication — `session` is purely an optional restore of cookies + localStorage. Specs that can just log in normally don't use it.
|
|
215
215
|
|
|
216
216
|
```yaml
|
|
217
|
-
title:
|
|
217
|
+
title: Admin can open the settings page
|
|
218
218
|
mode: live
|
|
219
|
-
|
|
219
|
+
session: admin # restore the saved "admin" session before step 1
|
|
220
220
|
steps:
|
|
221
|
-
- ...
|
|
221
|
+
- ... # no login steps — the spec starts signed-in
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
-
|
|
224
|
+
A spec can also restore several sessions at once (e.g. one provider in each), and ccqa merges them:
|
|
225
225
|
|
|
226
|
-
|
|
226
|
+
```yaml
|
|
227
|
+
session:
|
|
228
|
+
- admin # one provider, signed in as admin
|
|
229
|
+
- admin-chat # another provider, same person
|
|
230
|
+
```
|
|
227
231
|
|
|
228
|
-
|
|
229
|
-
# 1. Log in interactively in a headed browser.
|
|
230
|
-
agent-browser --headed open https://app.slack.com
|
|
231
|
-
# …complete login + device-trust prompts by hand…
|
|
232
|
+
#### Create a session — `ccqa session bootstrap`
|
|
232
233
|
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
234
|
+
```bash
|
|
235
|
+
# Opens a headed browser. Log in by hand (clear any device-trust gate),
|
|
236
|
+
# then press Enter and ccqa saves the session.
|
|
237
|
+
ccqa session bootstrap admin --url https://app.example.com/login
|
|
237
238
|
|
|
238
|
-
#
|
|
239
|
-
ccqa
|
|
239
|
+
# List saved sessions (names + save times; no secret values shown).
|
|
240
|
+
ccqa session ls
|
|
240
241
|
```
|
|
241
242
|
|
|
242
|
-
|
|
243
|
+
Sessions are saved to `.ccqa/sessions/<profile>/<name>.json`. The `<profile>` is the same `--profile` that selects the `.ccqa/profiles/<profile>.env` file, so one flag picks both the environment and its sessions bucket; with no `--profile` the bucket is `default`. `ccqa init` drops a self-ignoring `.ccqa/sessions/.gitignore` so saved sessions stay out of git — **they contain live auth cookies and must never be committed.**
|
|
243
244
|
|
|
244
|
-
|
|
245
|
+
When a spec names a session that hasn't been created yet, the run stops and tells you which `ccqa session bootstrap` to run, rather than starting unauthenticated.
|
|
245
246
|
|
|
246
|
-
|
|
247
|
+
#### CI: restore the session
|
|
248
|
+
|
|
249
|
+
Saved sessions live entirely inside `.ccqa/` and never touch `~/`. In CI, write each session file back to the path the run expects (read it from your secret store):
|
|
247
250
|
|
|
248
251
|
```bash
|
|
249
|
-
# Locally, after the
|
|
250
|
-
base64 -i .ccqa/sessions/
|
|
251
|
-
# paste into your CI secret store as CCQA_SLACK_STG_STATE_B64
|
|
252
|
+
# Locally, after bootstrapping, copy the file into your CI secret store:
|
|
253
|
+
base64 -i .ccqa/sessions/default/admin.json | pbcopy # paste as CCQA_SESSION_ADMIN_B64
|
|
252
254
|
```
|
|
253
255
|
|
|
254
256
|
```yaml
|
|
255
|
-
#
|
|
256
|
-
- name: Restore
|
|
257
|
+
# CI step (sketch) — restore before `ccqa run`
|
|
258
|
+
- name: Restore session
|
|
257
259
|
env:
|
|
258
|
-
|
|
260
|
+
CCQA_SESSION_ADMIN_B64: ${{ secrets.CCQA_SESSION_ADMIN_B64 }}
|
|
259
261
|
run: |
|
|
260
|
-
mkdir -p .ccqa/sessions
|
|
261
|
-
printf '%s' "$
|
|
262
|
-
> .ccqa/sessions/slack-stg.json
|
|
263
|
-
|
|
264
|
-
- name: Run live specs
|
|
265
|
-
env:
|
|
266
|
-
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
|
267
|
-
run: pnpm ccqa run --report
|
|
262
|
+
mkdir -p .ccqa/sessions/default
|
|
263
|
+
printf '%s' "$CCQA_SESSION_ADMIN_B64" | base64 -d > .ccqa/sessions/default/admin.json
|
|
268
264
|
```
|
|
269
265
|
|
|
270
|
-
|
|
266
|
+
Notes:
|
|
271
267
|
|
|
272
|
-
- **Expiry.**
|
|
273
|
-
- **Treat
|
|
274
|
-
- **Deterministic specs ignore `
|
|
268
|
+
- **Expiry.** The provider's "remember this device" window eventually lapses and the saved cookies stop working. Re-run `ccqa session bootstrap` locally and rotate the secret.
|
|
269
|
+
- **Treat session files as credentials.** They hold live auth cookies. Keep them in a secret manager; never commit them.
|
|
270
|
+
- **Deterministic specs ignore `session:`.** It only affects `mode: live`; vitest-replayed specs always run isolated.
|
|
275
271
|
|
|
276
272
|
### Per-project guidance (`.ccqa/prompts/live.user.md` + `live.agent.md`)
|
|
277
273
|
|
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { accessSync, existsSync, readFileSync, statSync } from "node:fs";
|
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
7
|
import { access, mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
|
|
8
8
|
import { homedir, tmpdir } from "node:os";
|
|
9
|
-
import { delimiter, dirname,
|
|
9
|
+
import { delimiter, dirname, join, posix, relative, resolve } from "node:path";
|
|
10
10
|
import { parse, stringify } from "yaml";
|
|
11
11
|
import { ZodError, z } from "zod";
|
|
12
12
|
import { execFile, spawn, spawnSync } from "node:child_process";
|
|
@@ -136,12 +136,27 @@ const StepSchema = z.union([ActionStepSchema, IncludeStepSchema]);
|
|
|
136
136
|
* codegen is impractical). Cost ~$0.5 per spec.
|
|
137
137
|
*/
|
|
138
138
|
const SpecModeSchema = z.enum(["deterministic", "live"]);
|
|
139
|
+
/**
|
|
140
|
+
* A session name: the identifier of a saved browser session (cookies +
|
|
141
|
+
* localStorage) to restore before the spec runs. Resolved to
|
|
142
|
+
* `.ccqa/sessions/<profile>/<name>.json` at run time. Restricted to a safe
|
|
143
|
+
* slug so the name can't escape the sessions directory.
|
|
144
|
+
*/
|
|
145
|
+
const SessionNameSchema = z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, "session name must be a slug (letters, digits, '.', '_', '-'; no path separators)");
|
|
146
|
+
/**
|
|
147
|
+
* Sessions to restore before a `mode: live` spec runs: a single name or a
|
|
148
|
+
* list. Always normalized to an array. Each name maps to a saved
|
|
149
|
+
* agent-browser state file; multiple names are merged (their cookies +
|
|
150
|
+
* localStorage are unioned) and restored together, so a spec can start
|
|
151
|
+
* signed-in to several providers at once.
|
|
152
|
+
*/
|
|
153
|
+
const SessionFieldSchema = z.union([SessionNameSchema, z.array(SessionNameSchema).min(1)]).transform((v) => Array.isArray(v) ? v : [v]);
|
|
139
154
|
/** Top-level spec schema. `.strict()` rejects any unknown key. */
|
|
140
155
|
const TestSpecSchema = z.object({
|
|
141
156
|
title: z.string().min(1),
|
|
142
157
|
relatedPaths: z.array(z.string().min(1)).optional(),
|
|
143
158
|
mode: SpecModeSchema.optional(),
|
|
144
|
-
|
|
159
|
+
session: SessionFieldSchema.optional(),
|
|
145
160
|
steps: z.array(StepSchema).min(1)
|
|
146
161
|
}).strict();
|
|
147
162
|
/** Default mode when `mode:` is absent. */
|
|
@@ -4584,6 +4599,55 @@ function oneLine$1(s) {
|
|
|
4584
4599
|
return s.replace(/\s+/g, " ").trim();
|
|
4585
4600
|
}
|
|
4586
4601
|
//#endregion
|
|
4602
|
+
//#region src/runtime/session-state.ts
|
|
4603
|
+
/** Default per-profile sessions root, relative to the project (`--cwd`). */
|
|
4604
|
+
const SESSIONS_SUBDIR = ".ccqa/sessions";
|
|
4605
|
+
/** The per-profile sessions directory: `<cwd>/.ccqa/sessions/<profile>/`. */
|
|
4606
|
+
function sessionsDir(profile, cwd) {
|
|
4607
|
+
return join(cwd, SESSIONS_SUBDIR, profile ?? "default");
|
|
4608
|
+
}
|
|
4609
|
+
/**
|
|
4610
|
+
* Resolve a session name to its state file path:
|
|
4611
|
+
* `<cwd>/.ccqa/sessions/<profile>/<name>.json`. The name is a slug (validated
|
|
4612
|
+
* by SessionNameSchema), so it can't escape the sessions directory.
|
|
4613
|
+
*/
|
|
4614
|
+
function sessionFilePath(name, profile, cwd) {
|
|
4615
|
+
return join(sessionsDir(profile, cwd), `${name}.json`);
|
|
4616
|
+
}
|
|
4617
|
+
/** Read and parse a saved session file. Throws if missing or malformed. */
|
|
4618
|
+
async function loadStorageState(path) {
|
|
4619
|
+
const raw = await readFile(path, "utf8");
|
|
4620
|
+
const parsed = JSON.parse(raw);
|
|
4621
|
+
if (!Array.isArray(parsed?.cookies) || !Array.isArray(parsed?.origins)) throw new Error(`not a valid agent-browser state file (expected { cookies, origins }): ${path}`);
|
|
4622
|
+
return parsed;
|
|
4623
|
+
}
|
|
4624
|
+
/**
|
|
4625
|
+
* Merge several saved sessions into one. Cookies are unioned by
|
|
4626
|
+
* (name, domain, path); origins by `origin`. Later states win on collision.
|
|
4627
|
+
* Distinct providers don't collide (different domains / origins), so merging
|
|
4628
|
+
* two single-provider sessions yields a combined signed-in state.
|
|
4629
|
+
*/
|
|
4630
|
+
function mergeStorageStates(states) {
|
|
4631
|
+
const cookies = /* @__PURE__ */ new Map();
|
|
4632
|
+
for (const s of states) for (const c of s.cookies) cookies.set(`${c.name}\t${c.domain}\t${c.path}`, c);
|
|
4633
|
+
const origins = /* @__PURE__ */ new Map();
|
|
4634
|
+
for (const s of states) for (const o of s.origins) origins.set(o.origin, o);
|
|
4635
|
+
return {
|
|
4636
|
+
cookies: [...cookies.values()],
|
|
4637
|
+
origins: [...origins.values()]
|
|
4638
|
+
};
|
|
4639
|
+
}
|
|
4640
|
+
/**
|
|
4641
|
+
* Write a merged state to a fresh temp file and return its path. Source
|
|
4642
|
+
* session files are never modified; the temp file is what gets restored via
|
|
4643
|
+
* `--state`, so re-runs (local or CI) leave the source-of-truth untouched.
|
|
4644
|
+
*/
|
|
4645
|
+
async function writeMergedTempState(state) {
|
|
4646
|
+
const file = join(await mkdtemp(join(tmpdir(), "ccqa-session-")), "merged-state.json");
|
|
4647
|
+
await writeFile(file, JSON.stringify(state), "utf8");
|
|
4648
|
+
return file;
|
|
4649
|
+
}
|
|
4650
|
+
//#endregion
|
|
4587
4651
|
//#region src/claude/agent-browser-invoke.ts
|
|
4588
4652
|
function agentBrowserInvokeBase(input) {
|
|
4589
4653
|
const env = {
|
|
@@ -4641,7 +4705,7 @@ function buildLiveSystemPromptPrefix(input) {
|
|
|
4641
4705
|
const stepsText = input.allSteps.map((s) => `### ${s.id} [${s.source}]
|
|
4642
4706
|
- **Instruction**: ${s.instruction}
|
|
4643
4707
|
- **Expected**: ${s.expected}`).join("\n\n");
|
|
4644
|
-
const stateLine = input.statePath ? `\n\nA pre-recorded auth-state file is provided at \`${input.statePath}\` (also in the env var \`CCQA_AB_STATE\`). **Always also pass \`--state "$CCQA_AB_STATE"\`** to every \`agent-browser\` command — this restores cookies and localStorage from a prior interactive login, so the user is already signed in to the application under test from step 1. The file is loaded read-only; do not run \`agent-browser state save\`.` : "";
|
|
4708
|
+
const stateLine = input.statePath ? `\n\nA pre-recorded auth-state file is provided at \`${input.statePath}\` (also in the env var \`CCQA_AB_STATE\`). **Always also pass \`--state "$CCQA_AB_STATE"\`** to every \`agent-browser\` command — this restores cookies and localStorage saved from a prior interactive login (one or more providers), so the user is already signed in to the application under test from step 1. The file is loaded read-only; do not run \`agent-browser state save\`.` : "";
|
|
4645
4709
|
return `You are a QA execution agent. You are executing ONE step of a browser-based end-to-end test and judging whether the step's expected outcome was achieved. You are NOT recording a replayable test script — be flexible, explore the DOM as needed, and make a clear pass / fail call at the end.
|
|
4646
4710
|
|
|
4647
4711
|
## Session
|
|
@@ -5174,6 +5238,44 @@ async function runDriftAudit(runs, opts, cwd) {
|
|
|
5174
5238
|
}
|
|
5175
5239
|
return out;
|
|
5176
5240
|
}
|
|
5241
|
+
/**
|
|
5242
|
+
* Resolve `spec.session` names to a single state file to restore. Each name
|
|
5243
|
+
* maps to `.ccqa/sessions/<profile>/<name>.json` and must load as a valid
|
|
5244
|
+
* agent-browser state (the spec assumes it starts signed-in). One name is
|
|
5245
|
+
* restored from its file directly; several are merged into a temp file. A
|
|
5246
|
+
* missing or malformed session fails with a `ccqa session bootstrap` hint
|
|
5247
|
+
* instead of running unauthenticated.
|
|
5248
|
+
*/
|
|
5249
|
+
async function resolveSessionState(names, profile, cwd) {
|
|
5250
|
+
const paths = names.map((name) => ({
|
|
5251
|
+
name,
|
|
5252
|
+
path: sessionFilePath(name, profile, cwd)
|
|
5253
|
+
}));
|
|
5254
|
+
const loaded = [];
|
|
5255
|
+
const broken = [];
|
|
5256
|
+
for (const p of paths) try {
|
|
5257
|
+
loaded.push(await loadStorageState(p.path));
|
|
5258
|
+
} catch {
|
|
5259
|
+
broken.push(p);
|
|
5260
|
+
}
|
|
5261
|
+
if (broken.length > 0) {
|
|
5262
|
+
const list = broken.map((b) => b.name).join(", ");
|
|
5263
|
+
const profileFlag = profile ? ` --profile ${profile}` : "";
|
|
5264
|
+
return {
|
|
5265
|
+
ok: false,
|
|
5266
|
+
error: `session not usable: ${list} (looked under ${dirname(broken[0].path)})`,
|
|
5267
|
+
hint: `create it with: ${broken.map((b) => `ccqa session bootstrap ${b.name}${profileFlag}`).join(" · ")}`
|
|
5268
|
+
};
|
|
5269
|
+
}
|
|
5270
|
+
if (paths.length === 1) return {
|
|
5271
|
+
ok: true,
|
|
5272
|
+
statePath: paths[0].path
|
|
5273
|
+
};
|
|
5274
|
+
return {
|
|
5275
|
+
ok: true,
|
|
5276
|
+
statePath: await writeMergedTempState(mergeStorageStates(loaded))
|
|
5277
|
+
};
|
|
5278
|
+
}
|
|
5177
5279
|
async function runOneSpec(args) {
|
|
5178
5280
|
const { featureName, specName, opts, userPromptSuffix, cwd } = args;
|
|
5179
5281
|
const specDir = getSpecDir(featureName, specName, cwd);
|
|
@@ -5198,21 +5300,20 @@ async function runOneSpec(args) {
|
|
|
5198
5300
|
const sessionName = generateLiveSessionName();
|
|
5199
5301
|
meta("session", sessionName);
|
|
5200
5302
|
let statePath = null;
|
|
5201
|
-
if (spec.
|
|
5202
|
-
|
|
5203
|
-
|
|
5204
|
-
|
|
5205
|
-
|
|
5206
|
-
const msg = `spec.statePath points to a missing file: ${statePath}`;
|
|
5207
|
-
error(msg);
|
|
5303
|
+
if (spec.session && spec.session.length > 0) {
|
|
5304
|
+
const resolution = await resolveSessionState(spec.session, opts.profile, cwd);
|
|
5305
|
+
if (!resolution.ok) {
|
|
5306
|
+
error(resolution.error);
|
|
5307
|
+
hint(resolution.hint);
|
|
5208
5308
|
return {
|
|
5209
5309
|
kind: "error",
|
|
5210
5310
|
featureName,
|
|
5211
5311
|
specName,
|
|
5212
|
-
error:
|
|
5312
|
+
error: resolution.error
|
|
5213
5313
|
};
|
|
5214
5314
|
}
|
|
5215
|
-
|
|
5315
|
+
statePath = resolution.statePath;
|
|
5316
|
+
meta("state", spec.session.join(", "));
|
|
5216
5317
|
}
|
|
5217
5318
|
const runId = buildRunId();
|
|
5218
5319
|
const runDir = opts.out ?? join(specDir, "runs", runId);
|
|
@@ -5576,6 +5677,7 @@ async function runDispatcher(targets, opts) {
|
|
|
5576
5677
|
...reportDir ? { reportDir } : {},
|
|
5577
5678
|
...typeof opts.retry === "number" ? { retry: opts.retry } : {},
|
|
5578
5679
|
concurrency: opts.concurrency ?? 1,
|
|
5680
|
+
...opts.profile ? { profile: opts.profile } : {},
|
|
5579
5681
|
...reportDir && opts.driftAudit !== false ? { driftAudit: true } : {},
|
|
5580
5682
|
...reportDir && opts.failureAnalysis === false ? { failureAnalysis: false } : {}
|
|
5581
5683
|
});
|
|
@@ -9669,6 +9771,13 @@ Write stable, hand-maintained context here for the trace phase of 'ccqa record'.
|
|
|
9669
9771
|
content: `# Agent learnings for ccqa record
|
|
9670
9772
|
|
|
9671
9773
|
This file is updated by 'ccqa record --update-agent-prompt'. Same convention as live.agent.md — stable rules go in record.user.md.
|
|
9774
|
+
`
|
|
9775
|
+
},
|
|
9776
|
+
{
|
|
9777
|
+
relPath: ".ccqa/sessions/.gitignore",
|
|
9778
|
+
content: `# Saved browser sessions contain live auth cookies. Never commit them.
|
|
9779
|
+
*
|
|
9780
|
+
!.gitignore
|
|
9672
9781
|
`
|
|
9673
9782
|
}
|
|
9674
9783
|
];
|
|
@@ -9676,6 +9785,7 @@ const initCommand = new Command("init").description("Create .ccqa/prompts/{live,
|
|
|
9676
9785
|
const cwd = resolveCwd(opts.cwd);
|
|
9677
9786
|
header("init", cwd);
|
|
9678
9787
|
await mkdir(join(cwd, ".ccqa", "prompts"), { recursive: true });
|
|
9788
|
+
await mkdir(join(cwd, ".ccqa", "sessions"), { recursive: true });
|
|
9679
9789
|
const created = [];
|
|
9680
9790
|
const skipped = [];
|
|
9681
9791
|
for (const t of TEMPLATES) if (await writeTemplate(join(cwd, t.relPath), t.content, opts.force ?? false)) created.push(t.relPath);
|
|
@@ -10183,6 +10293,89 @@ function mdCell(value) {
|
|
|
10183
10293
|
return value.replace(/\|/g, "\\|").replace(/\n/g, " ");
|
|
10184
10294
|
}
|
|
10185
10295
|
//#endregion
|
|
10296
|
+
//#region src/cli/session.ts
|
|
10297
|
+
const AB = createRequire(import.meta.url).resolve("agent-browser/bin/agent-browser.js");
|
|
10298
|
+
/**
|
|
10299
|
+
* Run agent-browser attached to the user's terminal (no timeout, inherited
|
|
10300
|
+
* stdio) so a human can complete an interactive login during `bootstrap`.
|
|
10301
|
+
* Distinct from runtime/spawn-ab.ts, which pipes stdio and hard-times-out for
|
|
10302
|
+
* non-interactive automation.
|
|
10303
|
+
*/
|
|
10304
|
+
function runAbInteractive(args) {
|
|
10305
|
+
return spawnSync(AB, args, { stdio: "inherit" }).status ?? 1;
|
|
10306
|
+
}
|
|
10307
|
+
function validateName(name) {
|
|
10308
|
+
const parsed = SessionNameSchema.safeParse(name);
|
|
10309
|
+
if (!parsed.success) {
|
|
10310
|
+
error(`invalid session name "${name}": ${parsed.error.issues[0]?.message ?? "bad name"}`);
|
|
10311
|
+
process.exit(2);
|
|
10312
|
+
}
|
|
10313
|
+
return parsed.data;
|
|
10314
|
+
}
|
|
10315
|
+
const profileOption = ["--profile <name>", "Sessions bucket to read/write (.ccqa/sessions/<profile>/). Defaults to 'default'."];
|
|
10316
|
+
const bootstrapCommand = new Command("bootstrap").description("Open a headed browser so you can log in by hand, then save the resulting session (cookies + localStorage) for `session:` specs to restore. The saved file holds live auth cookies — keep .ccqa/sessions/ gitignored.").argument("<name>", "Session name to save (a slug; resolves to .ccqa/sessions/<profile>/<name>.json)").option("--url <url>", "URL to open first (e.g. the login page). Omit to start with a blank tab.").option(...profileOption).option("--cwd <path>", "Project root containing .ccqa/ (defaults to the current directory).").action(async (rawName, opts) => {
|
|
10317
|
+
const name = validateName(rawName);
|
|
10318
|
+
const cwd = resolveCwd(opts.cwd);
|
|
10319
|
+
const dest = sessionFilePath(name, opts.profile, cwd);
|
|
10320
|
+
header("session bootstrap", name);
|
|
10321
|
+
meta("profile", opts.profile ?? "default");
|
|
10322
|
+
meta("save to", dest);
|
|
10323
|
+
blank();
|
|
10324
|
+
const openArgs = [
|
|
10325
|
+
"--headed",
|
|
10326
|
+
"open",
|
|
10327
|
+
...opts.url ? [opts.url] : ["about:blank"]
|
|
10328
|
+
];
|
|
10329
|
+
info("opening a browser — log in by hand, then return here.");
|
|
10330
|
+
const openStatus = runAbInteractive(openArgs);
|
|
10331
|
+
if (openStatus !== 0) {
|
|
10332
|
+
error(`agent-browser open exited ${openStatus}`);
|
|
10333
|
+
process.exit(1);
|
|
10334
|
+
}
|
|
10335
|
+
const rl = createInterface$1({
|
|
10336
|
+
input: process.stdin,
|
|
10337
|
+
output: process.stdout
|
|
10338
|
+
});
|
|
10339
|
+
await rl.question("\nPress Enter once you are fully logged in to save the session… ");
|
|
10340
|
+
rl.close();
|
|
10341
|
+
await mkdir(dirname(dest), { recursive: true });
|
|
10342
|
+
const saveStatus = runAbInteractive([
|
|
10343
|
+
"state",
|
|
10344
|
+
"save",
|
|
10345
|
+
dest
|
|
10346
|
+
]);
|
|
10347
|
+
runAbInteractive(["close"]);
|
|
10348
|
+
if (saveStatus !== 0) {
|
|
10349
|
+
error(`agent-browser state save exited ${saveStatus}`);
|
|
10350
|
+
process.exit(1);
|
|
10351
|
+
}
|
|
10352
|
+
blank();
|
|
10353
|
+
info(`saved session "${name}" → ${dest}`);
|
|
10354
|
+
hint("reference it from a spec with: session: " + name);
|
|
10355
|
+
});
|
|
10356
|
+
const lsCommand = new Command("ls").description("List saved sessions for a profile (names + last-saved times). No secret values are shown.").option(...profileOption).option("--cwd <path>", "Project root containing .ccqa/ (defaults to the current directory).").action(async (opts) => {
|
|
10357
|
+
const cwd = resolveCwd(opts.cwd);
|
|
10358
|
+
const profile = opts.profile ?? "default";
|
|
10359
|
+
const dir = sessionsDir(profile, cwd);
|
|
10360
|
+
header("sessions", profile);
|
|
10361
|
+
let entries;
|
|
10362
|
+
try {
|
|
10363
|
+
entries = (await readdir(dir)).filter((f) => f.endsWith(".json"));
|
|
10364
|
+
} catch {
|
|
10365
|
+
entries = [];
|
|
10366
|
+
}
|
|
10367
|
+
if (entries.length === 0) {
|
|
10368
|
+
info(`no saved sessions in ${dir}`);
|
|
10369
|
+
hint("create one with: ccqa session bootstrap <name>");
|
|
10370
|
+
return;
|
|
10371
|
+
}
|
|
10372
|
+
for (const file of entries.sort()) {
|
|
10373
|
+
const info = await stat(join(dir, file));
|
|
10374
|
+
meta(file.replace(/\.json$/, ""), `saved ${info.mtime.toISOString()}`);
|
|
10375
|
+
}
|
|
10376
|
+
});
|
|
10377
|
+
const sessionCommand = new Command("session").description("Manage saved browser sessions (cookies + localStorage) for `session:` specs.").addCommand(bootstrapCommand).addCommand(lsCommand);
|
|
10378
|
+
//#endregion
|
|
10186
10379
|
//#region src/cli/index.ts
|
|
10187
10380
|
function resolvePackageJson() {
|
|
10188
10381
|
const distCandidate = fileURLToPath(new URL("../package.json", import.meta.url));
|
|
@@ -10203,6 +10396,7 @@ program.addCommand(perspectivesCommand);
|
|
|
10203
10396
|
program.addCommand(recordCommand);
|
|
10204
10397
|
program.addCommand(runCommand);
|
|
10205
10398
|
program.addCommand(driftCommand);
|
|
10399
|
+
program.addCommand(sessionCommand);
|
|
10206
10400
|
program.parse();
|
|
10207
10401
|
//#endregion
|
|
10208
10402
|
export {};
|
package/dist/package.json
CHANGED