ccqa 1.51.0 → 1.52.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.
@@ -0,0 +1,106 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_evidence_constants = require("../evidence-constants-BufWx8Bt.cjs");
3
+ let node_fs = require("node:fs");
4
+ let node_path = require("node:path");
5
+ //#region src/runtime/step-evidence.ts
6
+ /**
7
+ * Caption recorded on the entry shot's metadata and cleared by
8
+ * `ccqaStepAfter`. If the test dies inside the step, this is what survives —
9
+ * so the report shows the failing step with the screen it started from,
10
+ * marked failed, instead of dropping the step entirely.
11
+ */
12
+ const INCOMPLETE_STEP_SUMMARY = "the test stopped inside this step (no closing screenshot)";
13
+ /**
14
+ * Capture the screen as the step is entered. Pair with `ccqaStepAfter` at the
15
+ * end of the same step.
16
+ */
17
+ async function ccqaStepBefore(page, stepId, source) {
18
+ const dir = process.env[require_evidence_constants.EVIDENCE_DIR_ENV];
19
+ if (!dir) return;
20
+ const id = require_evidence_constants.sanitizeStepId(stepId);
21
+ const beforeFile = `${id}.before.png`;
22
+ if (!await capture(page, dir, beforeFile)) return;
23
+ await writeMeta(page, dir, id, {
24
+ stepId,
25
+ source,
26
+ pngFile: beforeFile,
27
+ failureSummary: INCOMPLETE_STEP_SUMMARY
28
+ });
29
+ }
30
+ /**
31
+ * Capture the screen as the step closes and finalise the step's metadata:
32
+ * the closing shot becomes the step's primary screenshot, the entry shot (if
33
+ * one was taken) rides along as `beforePngFile`, and the "did not complete"
34
+ * caption written by `ccqaStepBefore` is cleared.
35
+ *
36
+ * If the closing shot itself fails, the step still COMPLETED — it just lost its
37
+ * final frame. Rewrite the meta without the failure caption (keeping the entry
38
+ * shot as the frame) so a passing step doesn't render red; the capture failure
39
+ * is surfaced via the stderr warn in `capture()`.
40
+ */
41
+ async function ccqaStepAfter(page, stepId, source) {
42
+ const dir = process.env[require_evidence_constants.EVIDENCE_DIR_ENV];
43
+ if (!dir) return;
44
+ const id = require_evidence_constants.sanitizeStepId(stepId);
45
+ const afterFile = `${id}.png`;
46
+ const beforeFile = `${id}.before.png`;
47
+ const hasBefore = (0, node_fs.existsSync)((0, node_path.join)(dir, beforeFile));
48
+ if (!await capture(page, dir, afterFile)) {
49
+ if (hasBefore) await writeMeta(page, dir, id, {
50
+ stepId,
51
+ source,
52
+ pngFile: beforeFile
53
+ });
54
+ return;
55
+ }
56
+ await writeMeta(page, dir, id, {
57
+ stepId,
58
+ source,
59
+ pngFile: afterFile,
60
+ ...hasBefore ? { beforePngFile: beforeFile } : {}
61
+ });
62
+ }
63
+ /** Screenshot into `<dir>/<file>`; false when the shot could not be taken. */
64
+ async function capture(page, dir, file) {
65
+ try {
66
+ (0, node_fs.mkdirSync)(dir, { recursive: true });
67
+ await page.screenshot({ path: (0, node_path.join)(dir, file) });
68
+ return true;
69
+ } catch (e) {
70
+ warn(`screenshot failed for ${file} (${message(e)})`);
71
+ return false;
72
+ }
73
+ }
74
+ /**
75
+ * Write the step's meta sidecar. `url`/`title` are read here rather than by
76
+ * the caller so a page that cannot answer them still yields a usable record
77
+ * (the screenshot alone is worth keeping).
78
+ */
79
+ async function writeMeta(page, dir, id, fields) {
80
+ let url = null;
81
+ let title = null;
82
+ try {
83
+ url = page.url();
84
+ title = await page.title();
85
+ } catch {}
86
+ const meta = {
87
+ ...fields,
88
+ url,
89
+ title,
90
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString()
91
+ };
92
+ try {
93
+ (0, node_fs.writeFileSync)((0, node_path.join)(dir, `${id}.json`), `${JSON.stringify(meta, null, 2)}\n`, "utf8");
94
+ } catch (e) {
95
+ warn(`meta write failed for ${id} (${message(e)})`);
96
+ }
97
+ }
98
+ function message(e) {
99
+ return e instanceof Error ? e.message : String(e);
100
+ }
101
+ function warn(text) {
102
+ process.stderr.write(`[ccqa] step-evidence: ${text}\n`);
103
+ }
104
+ //#endregion
105
+ exports.ccqaStepAfter = ccqaStepAfter;
106
+ exports.ccqaStepBefore = ccqaStepBefore;
@@ -0,0 +1,53 @@
1
+ //#region src/runtime/step-evidence.d.ts
2
+ /**
3
+ * Step-boundary screenshot capture for tests ccqa generates for external
4
+ * targets (Playwright today). Generated tests import this through the
5
+ * `ccqa/step-evidence` subpath and call it at each spec-step boundary; the
6
+ * `<id>.png` + `<id>.json` pairs it writes are exactly what `ccqa run`'s
7
+ * report loader consumes, so an external target's rows carry the same
8
+ * per-step evidence the built-in agent-browser path produces.
9
+ *
10
+ * Two constraints shape the whole module:
11
+ *
12
+ * - **No test-framework dependency.** The page handle is typed
13
+ * structurally, so ccqa never imports `@playwright/test` and a consumer
14
+ * installs nothing beyond ccqa itself. Any object exposing these three
15
+ * members works — including a Playwright `Page`.
16
+ * - **Never fail the user's test.** Capture is best-effort: every error is
17
+ * swallowed with a stderr note. A missing screenshot costs a frame in the
18
+ * report; it must never flip a passing spec to red.
19
+ *
20
+ * Capture is opt-in at runtime via `CCQA_EVIDENCE_DIR`, which only `ccqa run`
21
+ * sets. Running the generated test directly (or through the generation-time
22
+ * verify/fix loop) writes nothing.
23
+ */
24
+ /**
25
+ * The subset of a browser page this module needs. Structural by design — see
26
+ * the module comment.
27
+ */
28
+ interface CcqaEvidencePage {
29
+ screenshot(options: {
30
+ path: string;
31
+ }): Promise<unknown>;
32
+ url(): string;
33
+ title(): Promise<string>;
34
+ }
35
+ /**
36
+ * Capture the screen as the step is entered. Pair with `ccqaStepAfter` at the
37
+ * end of the same step.
38
+ */
39
+ declare function ccqaStepBefore(page: CcqaEvidencePage, stepId: string, source: string): Promise<void>;
40
+ /**
41
+ * Capture the screen as the step closes and finalise the step's metadata:
42
+ * the closing shot becomes the step's primary screenshot, the entry shot (if
43
+ * one was taken) rides along as `beforePngFile`, and the "did not complete"
44
+ * caption written by `ccqaStepBefore` is cleared.
45
+ *
46
+ * If the closing shot itself fails, the step still COMPLETED — it just lost its
47
+ * final frame. Rewrite the meta without the failure caption (keeping the entry
48
+ * shot as the frame) so a passing step doesn't render red; the capture failure
49
+ * is surfaced via the stderr warn in `capture()`.
50
+ */
51
+ declare function ccqaStepAfter(page: CcqaEvidencePage, stepId: string, source: string): Promise<void>;
52
+ //#endregion
53
+ export { CcqaEvidencePage, ccqaStepAfter, ccqaStepBefore };
@@ -1,4 +1,4 @@
1
- import { i as sanitizeStepId, t as EVIDENCE_DIR_ENV } from "../evidence-constants-Cm_S_5od.mjs";
1
+ import { i as sanitizeStepId, t as EVIDENCE_DIR_ENV } from "../evidence-constants-C425F7ZG.mjs";
2
2
  import { existsSync, mkdirSync, writeFileSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  //#region src/runtime/step-evidence.ts
@@ -0,0 +1,461 @@
1
+ Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
+ const require_evidence_constants = require("../evidence-constants-BufWx8Bt.cjs");
3
+ let node_fs = require("node:fs");
4
+ let node_path = require("node:path");
5
+ let node_child_process = require("node:child_process");
6
+ //#region src/runtime/agent-browser-bin.ts
7
+ const require$1 = (0, require("node:module").createRequire)(require("url").pathToFileURL(__filename).href);
8
+ function hasAgentBrowserShim(dir) {
9
+ try {
10
+ (0, node_fs.statSync)((0, node_path.join)(dir, "agent-browser"));
11
+ return true;
12
+ } catch {
13
+ return false;
14
+ }
15
+ }
16
+ /**
17
+ * Walks up from `start` looking for a `node_modules/.bin/agent-browser` shim.
18
+ * Returns the .bin directory containing the shim, or null if none is found.
19
+ */
20
+ function findNodeModulesBin(start) {
21
+ let cur = start;
22
+ while (true) {
23
+ const candidate = (0, node_path.join)(cur, "node_modules", ".bin");
24
+ if (hasAgentBrowserShim(candidate)) return candidate;
25
+ const parent = (0, node_path.dirname)(cur);
26
+ if (parent === cur) return null;
27
+ cur = parent;
28
+ }
29
+ }
30
+ /** The shim-directory walk shared by every resolution below (no env override). */
31
+ function resolveShimDir() {
32
+ const fromCwd = findNodeModulesBin(process.cwd());
33
+ if (fromCwd) return fromCwd;
34
+ const fromSelf = findNodeModulesBin((0, node_path.dirname)(require$1.resolve("agent-browser/package.json")));
35
+ if (fromSelf) return fromSelf;
36
+ try {
37
+ const candidate = (0, node_path.join)((0, node_path.dirname)(require$1.resolve("agent-browser/package.json")), "node_modules", ".bin");
38
+ if (hasAgentBrowserShim(candidate)) return candidate;
39
+ } catch {}
40
+ return null;
41
+ }
42
+ /**
43
+ * INVARIANT: every agent-browser invocation in one ccqa process — the host
44
+ * side (`spawnAB`: state load, replay probes, …) and the Claude subprocess
45
+ * (via the PATH prepended by `pathWithAgentBrowserShim`) — must resolve to
46
+ * the SAME binary. agent-browser runs one daemon per binary, and a state
47
+ * loaded into one daemon's session is invisible to a same-named session on
48
+ * another daemon, so a split resolution silently loses session restores.
49
+ * Both entry points below therefore share one resolution order:
50
+ * `CCQA_AB_BIN` (explicit override, e.g. the e2e harness or a dev build
51
+ * driving a consumer project's agent-browser) → the peer-installed shim
52
+ * (consumer project first, then ccqa's own tree).
53
+ */
54
+ /**
55
+ * Resolves the executable `spawnAB` should invoke. Falls back to the package
56
+ * JS entry (resolvable whenever the peer dependency is installed) when no
57
+ * shim directory exists; throws only if agent-browser is missing entirely.
58
+ */
59
+ function resolveAgentBrowserBin() {
60
+ const override = process.env["CCQA_AB_BIN"];
61
+ if (override) return override;
62
+ const shimDir = resolveShimDir();
63
+ if (shimDir) return (0, node_path.join)(shimDir, "agent-browser");
64
+ return require$1.resolve("agent-browser/bin/agent-browser.js");
65
+ }
66
+ //#endregion
67
+ //#region src/runtime/spawn-ab.ts
68
+ const AB = resolveAgentBrowserBin();
69
+ const EAGAIN_PATTERN = /Resource temporarily unavailable|os error 35/i;
70
+ const EAGAIN_TOTAL_BUDGET_MS = 3e4;
71
+ const EAGAIN_BACKOFF_MS = [
72
+ 100,
73
+ 200,
74
+ 300,
75
+ 500,
76
+ 700,
77
+ 1e3,
78
+ 1500,
79
+ 2e3,
80
+ 2500,
81
+ 3e3,
82
+ 3e3,
83
+ 3e3,
84
+ 3e3,
85
+ 3e3,
86
+ 3e3
87
+ ];
88
+ const PROCESS_HARD_TIMEOUT_MS = 35e3;
89
+ function sleepSync(ms) {
90
+ const buf = new SharedArrayBuffer(4);
91
+ Atomics.wait(new Int32Array(buf), 0, 0, ms);
92
+ }
93
+ function spawnABOnce(args, timeoutMs) {
94
+ const result = (0, node_child_process.spawnSync)(AB, args, {
95
+ stdio: "pipe",
96
+ timeout: timeoutMs
97
+ });
98
+ const wedged = result.error?.code === "ETIMEDOUT";
99
+ return {
100
+ status: result.status,
101
+ stdout: result.stdout?.toString() ?? "",
102
+ stderr: (result.stderr?.toString() ?? "") + (wedged ? `\n[ccqa] agent-browser ${subcommand(args)} did not answer in ${timeoutMs}ms — killed after hard timeout` : ""),
103
+ wedged
104
+ };
105
+ }
106
+ /** The verb a reader recognises, past the one prefix every caller writes. */
107
+ function subcommand(args) {
108
+ return (args[0] === "--session" ? args[2] : args[0]) ?? "(no command)";
109
+ }
110
+ /**
111
+ * Invoke `agent-browser` once and return its exit status/stdout/stderr,
112
+ * retrying internally up to ~30s while the daemon's state file is in the
113
+ * "Resource temporarily unavailable" race window. Used by both the test
114
+ * runtime (`test-helpers.ts`) and the post-trace replay validation
115
+ * (`replay-validate.ts`). Kept out of `test-helpers.ts` because that
116
+ * module is also the public surface for generated test scripts — exposing
117
+ * the raw spawner there would widen the contract for end users.
118
+ */
119
+ function spawnAB(args, opts) {
120
+ const timeoutMs = opts?.timeoutMs ?? PROCESS_HARD_TIMEOUT_MS;
121
+ let result = spawnABOnce(args, timeoutMs);
122
+ let elapsed = 0;
123
+ let attempt = 0;
124
+ while (result.status !== 0 && elapsed < EAGAIN_TOTAL_BUDGET_MS) {
125
+ const combined = `${result.stdout}\n${result.stderr}`;
126
+ if (!EAGAIN_PATTERN.test(combined)) return result;
127
+ const wait = EAGAIN_BACKOFF_MS[attempt] ?? 3e3;
128
+ sleepSync(wait);
129
+ elapsed += wait;
130
+ attempt++;
131
+ result = spawnABOnce(args, timeoutMs);
132
+ }
133
+ return result;
134
+ }
135
+ //#endregion
136
+ //#region src/runtime/test-helpers.ts
137
+ const POST_OPEN_SETTLE_MS = 600;
138
+ function logStep(action, args) {
139
+ const pretty = args.map((a) => typeof a === "string" ? a : JSON.stringify(a)).join(" ");
140
+ process.stdout.write(` ▶ ${action.padEnd(14)} ${pretty}\n`);
141
+ }
142
+ function fail(summary, result) {
143
+ process.stdout.write(` ✗ ${summary}\n`);
144
+ const details = [result.stdout, result.stderr].map((s) => s.trim()).filter(Boolean).join("\n");
145
+ if (details) for (const line of details.split("\n")) process.stdout.write(` ${line}\n`);
146
+ captureFailureEvidence(summary);
147
+ throw new Error(summary);
148
+ }
149
+ /**
150
+ * Tracks the step the test is currently inside. The codegen emits one of these
151
+ * calls right after every `// step: ...` marker so when fail() fires we know
152
+ * which step to attribute the failure to. Older generated scripts that don't
153
+ * emit this still work — captureFailureEvidence() falls back to a generic
154
+ * `failure.png` when currentStep is null.
155
+ */
156
+ let currentStep = null;
157
+ function __setCurrentStep(stepId, source) {
158
+ currentStep = {
159
+ stepId,
160
+ source
161
+ };
162
+ }
163
+ function captureFailureEvidence(summary) {
164
+ if (currentStep) {
165
+ const safe = require_evidence_constants.sanitizeStepId(currentStep.stepId);
166
+ captureEvidence({
167
+ stepId: currentStep.stepId,
168
+ source: currentStep.source,
169
+ pngFile: `${safe}.png`,
170
+ failureSummary: summary,
171
+ silent: true
172
+ });
173
+ return;
174
+ }
175
+ captureEvidence({
176
+ stepId: require_evidence_constants.FAILURE_STEP_ID,
177
+ source: require_evidence_constants.FAILURE_SOURCE,
178
+ pngFile: "failure.png",
179
+ failureSummary: summary,
180
+ silent: true
181
+ });
182
+ }
183
+ function ab(...args) {
184
+ const [command = "", ...rest] = args;
185
+ logStep(command, rest);
186
+ const result = spawnAB(args);
187
+ if (result.status !== 0) fail(`agent-browser ${command} failed (exit ${result.status})`, result);
188
+ if (command === "open") sleepSync(POST_OPEN_SETTLE_MS);
189
+ }
190
+ const SELECTOR_POLL_INTERVAL_MS = 500;
191
+ function selectorCount(selector) {
192
+ const r = spawnAB([
193
+ "get",
194
+ "count",
195
+ selector
196
+ ]);
197
+ if (r.status !== 0) return 0;
198
+ const n = Number.parseInt(r.stdout.trim(), 10);
199
+ return Number.isNaN(n) ? 0 : n;
200
+ }
201
+ /**
202
+ * Poll until `get count <selector>` reaches the desired presence state or the
203
+ * timeout elapses. `want: "present"` waits for >=1 match; `"absent"` waits for
204
+ * 0 matches. Returns true on success, false on timeout.
205
+ */
206
+ function pollSelector(selector, want, timeoutMs) {
207
+ const deadline = Date.now() + timeoutMs;
208
+ for (;;) {
209
+ const count = selectorCount(selector);
210
+ if (want === "present" ? count > 0 : count === 0) return true;
211
+ if (Date.now() >= deadline) return false;
212
+ sleepSync(SELECTOR_POLL_INTERVAL_MS);
213
+ }
214
+ }
215
+ /** Wait for element/text with an explicit timeout so long-running async ops don't hang. */
216
+ function abWait(selector, timeoutMs = 3e4) {
217
+ logStep("wait", [selector]);
218
+ if (selector.startsWith("text=")) {
219
+ const result = spawnAB([
220
+ "wait",
221
+ "--text",
222
+ selector.slice(5),
223
+ "--timeout",
224
+ String(timeoutMs)
225
+ ]);
226
+ if (result.status !== 0) fail(`wait failed: ${selector}`, result);
227
+ return;
228
+ }
229
+ if (!pollSelector(selector, "present", timeoutMs)) fail(`wait failed: ${selector} not present within ${timeoutMs}ms`, {
230
+ status: 1,
231
+ stdout: "",
232
+ stderr: ""
233
+ });
234
+ }
235
+ /**
236
+ * Upload one or more files to a file input via `agent-browser upload`.
237
+ * Relative paths resolve against the test's cwd. We pre-check existence
238
+ * locally so a typo in a fixture path surfaces as a clear error before
239
+ * agent-browser exits with an opaque non-zero status.
240
+ */
241
+ function abUpload(selector, ...files) {
242
+ logStep("upload", [selector, ...files]);
243
+ const resolved = [];
244
+ for (const file of files) {
245
+ const abs = (0, node_path.isAbsolute)(file) ? file : (0, node_path.resolve)(process.cwd(), file);
246
+ if (!(0, node_fs.existsSync)(abs)) fail(`abUpload: file not found (${file} → ${abs})`, {
247
+ status: 1,
248
+ stdout: "",
249
+ stderr: ""
250
+ });
251
+ resolved.push(abs);
252
+ }
253
+ const result = spawnAB([
254
+ "upload",
255
+ selector,
256
+ ...resolved
257
+ ]);
258
+ if (result.status !== 0) fail(`agent-browser upload failed (exit ${result.status})`, result);
259
+ }
260
+ /** Assert stable text is visible on page (via wait --text). */
261
+ function abAssertTextVisible(text, timeoutMs = 3e4) {
262
+ logStep("assert.text", [text]);
263
+ const result = spawnAB([
264
+ "wait",
265
+ "--text",
266
+ text,
267
+ "--timeout",
268
+ String(timeoutMs)
269
+ ]);
270
+ if (result.status !== 0) fail(`Assertion failed: text ${JSON.stringify(text)} not found within ${timeoutMs}ms`, result);
271
+ }
272
+ /** Assert element is visible (polls `get count`; never uses the blocking `wait <selector>`). */
273
+ function abAssertVisible(selector, timeoutMs = 3e4) {
274
+ logStep("assert.visible", [selector]);
275
+ if (selector.startsWith("text=")) {
276
+ const result = spawnAB([
277
+ "wait",
278
+ "--text",
279
+ selector.slice(5),
280
+ "--timeout",
281
+ String(timeoutMs)
282
+ ]);
283
+ if (result.status !== 0) fail(`Assertion failed: ${JSON.stringify(selector)} not visible within ${timeoutMs}ms`, result);
284
+ return;
285
+ }
286
+ if (!pollSelector(selector, "present", timeoutMs)) fail(`Assertion failed: ${JSON.stringify(selector)} not visible within ${timeoutMs}ms`, {
287
+ status: 1,
288
+ stdout: "",
289
+ stderr: ""
290
+ });
291
+ }
292
+ /** Assert element is NOT visible (polls `get count` for absence; --fn for text). */
293
+ function abAssertNotVisible(selector, timeoutMs = 3e4) {
294
+ logStep("assert.hidden", [selector]);
295
+ if (selector.startsWith("text=")) {
296
+ const result = spawnAB([
297
+ "wait",
298
+ "--fn",
299
+ `!document.body.innerText.includes(${JSON.stringify(selector.slice(5))})`,
300
+ "--timeout",
301
+ String(timeoutMs)
302
+ ]);
303
+ if (result.status !== 0) fail(`Assertion failed: ${JSON.stringify(selector)} still visible after ${timeoutMs}ms`, result);
304
+ return;
305
+ }
306
+ if (!pollSelector(selector, "absent", timeoutMs)) fail(`Assertion failed: ${JSON.stringify(selector)} still visible after ${timeoutMs}ms`, {
307
+ status: 1,
308
+ stdout: "",
309
+ stderr: ""
310
+ });
311
+ }
312
+ /** Assert URL contains a pattern (via get url). */
313
+ function abAssertUrl(pattern) {
314
+ logStep("assert.url", [pattern]);
315
+ const result = spawnAB(["get", "url"]);
316
+ const url = result.stdout.trim();
317
+ if (!url.includes(pattern)) fail(`Assertion failed: URL ${JSON.stringify(url)} does not contain ${JSON.stringify(pattern)}`, result);
318
+ }
319
+ /** Assert element is enabled (via is enabled). */
320
+ function abAssertEnabled(selector) {
321
+ logStep("assert.enabled", [selector]);
322
+ const result = spawnAB([
323
+ "is",
324
+ "enabled",
325
+ selector
326
+ ]);
327
+ if (result.status !== 0) fail(`Assertion failed: element ${JSON.stringify(selector)} not found`, result);
328
+ const value = result.stdout.trim();
329
+ if (value !== "true") fail(`Assertion failed: ${JSON.stringify(selector)} is not enabled (got: ${value})`, result);
330
+ }
331
+ /** Assert element is disabled (via is enabled). */
332
+ function abAssertDisabled(selector) {
333
+ logStep("assert.disabled", [selector]);
334
+ const result = spawnAB([
335
+ "is",
336
+ "enabled",
337
+ selector
338
+ ]);
339
+ if (result.status !== 0) fail(`Assertion failed: element ${JSON.stringify(selector)} not found`, result);
340
+ const value = result.stdout.trim();
341
+ if (value !== "false") fail(`Assertion failed: ${JSON.stringify(selector)} is not disabled (got: ${value})`, result);
342
+ }
343
+ /** Assert checkbox is checked (via is checked). */
344
+ function abAssertChecked(selector) {
345
+ logStep("assert.checked", [selector]);
346
+ const result = spawnAB([
347
+ "is",
348
+ "checked",
349
+ selector
350
+ ]);
351
+ if (result.status !== 0) fail(`Assertion failed: element ${JSON.stringify(selector)} not found`, result);
352
+ const value = result.stdout.trim();
353
+ if (value !== "true") fail(`Assertion failed: ${JSON.stringify(selector)} is not checked (got: ${value})`, result);
354
+ }
355
+ /** Assert checkbox is unchecked (via is checked). */
356
+ function abAssertUnchecked(selector) {
357
+ logStep("assert.unchecked", [selector]);
358
+ const result = spawnAB([
359
+ "is",
360
+ "checked",
361
+ selector
362
+ ]);
363
+ if (result.status !== 0) fail(`Assertion failed: element ${JSON.stringify(selector)} not found`, result);
364
+ const value = result.stdout.trim();
365
+ if (value !== "false") fail(`Assertion failed: ${JSON.stringify(selector)} is not unchecked (got: ${value})`, result);
366
+ }
367
+ /**
368
+ * Capture a step-boundary evidence pair (PNG + JSON metadata) so a reviewer
369
+ * can confirm at a glance that a passing spec actually drove the app to the
370
+ * state its `expected` describes. Opt-in at runtime via `CCQA_EVIDENCE_DIR` so
371
+ * generated scripts hand-run outside `ccqa run` don't write stray files. All
372
+ * errors are swallowed with a stderr warning — evidence capture must never
373
+ * flip a passing spec to red.
374
+ */
375
+ function abStepEvidence(stepId, source) {
376
+ captureEvidence({
377
+ stepId,
378
+ source,
379
+ pngFile: `${require_evidence_constants.sanitizeStepId(stepId)}.png`
380
+ });
381
+ if (currentStep && currentStep.stepId === stepId) currentStep = null;
382
+ }
383
+ /**
384
+ * Shared screenshot+meta pipeline behind both abStepEvidence (step boundary)
385
+ * and captureFailureEvidence (called from fail()). The url/title eval is one
386
+ * round-trip; agent-browser wraps eval output in JSON.stringify, so the JS
387
+ * expression must itself stringify the payload — hence the double JSON.parse.
388
+ */
389
+ function captureEvidence(opts) {
390
+ const dir = process.env[require_evidence_constants.EVIDENCE_DIR_ENV];
391
+ if (!dir) return;
392
+ const { stepId, source, pngFile, failureSummary, silent } = opts;
393
+ const pngPath = (0, node_path.join)(dir, pngFile);
394
+ const metaPath = (0, node_path.join)(dir, pngFile.replace(/\.png$/, ".json"));
395
+ try {
396
+ (0, node_fs.mkdirSync)((0, node_path.dirname)(pngPath), { recursive: true });
397
+ } catch (e) {
398
+ if (!silent) warnEvidence(`mkdir failed (${e.message})`);
399
+ return;
400
+ }
401
+ if (!silent) logStep("evidence", [stepId]);
402
+ const shot = spawnAB(["screenshot", pngPath]);
403
+ if (shot.status !== 0) {
404
+ if (!silent) warnEvidence(`screenshot failed for ${stepId} (${shot.stderr.trim() || shot.stdout.trim()})`);
405
+ return;
406
+ }
407
+ const { url, title } = readPageContext();
408
+ const meta = {
409
+ stepId,
410
+ source,
411
+ url,
412
+ title,
413
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
414
+ pngFile
415
+ };
416
+ if (failureSummary !== void 0) meta["failureSummary"] = failureSummary;
417
+ try {
418
+ (0, node_fs.writeFileSync)(metaPath, `${JSON.stringify(meta, null, 2)}\n`, "utf8");
419
+ } catch (e) {
420
+ if (!silent) warnEvidence(`meta write failed (${e.message})`);
421
+ }
422
+ }
423
+ function readPageContext() {
424
+ const ctx = spawnAB(["eval", "JSON.stringify({url: location.href, title: document.title})"]);
425
+ if (ctx.status !== 0) return {
426
+ url: null,
427
+ title: null
428
+ };
429
+ try {
430
+ const outer = JSON.parse(ctx.stdout.trim());
431
+ const inner = typeof outer === "string" ? JSON.parse(outer) : outer;
432
+ if (inner && typeof inner === "object") {
433
+ const obj = inner;
434
+ return {
435
+ url: typeof obj.url === "string" ? obj.url : null,
436
+ title: typeof obj.title === "string" ? obj.title : null
437
+ };
438
+ }
439
+ } catch {}
440
+ return {
441
+ url: null,
442
+ title: null
443
+ };
444
+ }
445
+ function warnEvidence(msg) {
446
+ process.stderr.write(`[ccqa] evidence: ${msg}\n`);
447
+ }
448
+ //#endregion
449
+ exports.__setCurrentStep = __setCurrentStep;
450
+ exports.ab = ab;
451
+ exports.abAssertChecked = abAssertChecked;
452
+ exports.abAssertDisabled = abAssertDisabled;
453
+ exports.abAssertEnabled = abAssertEnabled;
454
+ exports.abAssertNotVisible = abAssertNotVisible;
455
+ exports.abAssertTextVisible = abAssertTextVisible;
456
+ exports.abAssertUnchecked = abAssertUnchecked;
457
+ exports.abAssertUrl = abAssertUrl;
458
+ exports.abAssertVisible = abAssertVisible;
459
+ exports.abStepEvidence = abStepEvidence;
460
+ exports.abUpload = abUpload;
461
+ exports.abWait = abWait;
@@ -0,0 +1,39 @@
1
+ //#region src/runtime/test-helpers.d.ts
2
+ declare function __setCurrentStep(stepId: string, source: string): void;
3
+ declare function ab(...args: string[]): void;
4
+ /** Wait for element/text with an explicit timeout so long-running async ops don't hang. */
5
+ declare function abWait(selector: string, timeoutMs?: number): void;
6
+ /**
7
+ * Upload one or more files to a file input via `agent-browser upload`.
8
+ * Relative paths resolve against the test's cwd. We pre-check existence
9
+ * locally so a typo in a fixture path surfaces as a clear error before
10
+ * agent-browser exits with an opaque non-zero status.
11
+ */
12
+ declare function abUpload(selector: string, ...files: string[]): void;
13
+ /** Assert stable text is visible on page (via wait --text). */
14
+ declare function abAssertTextVisible(text: string, timeoutMs?: number): void;
15
+ /** Assert element is visible (polls `get count`; never uses the blocking `wait <selector>`). */
16
+ declare function abAssertVisible(selector: string, timeoutMs?: number): void;
17
+ /** Assert element is NOT visible (polls `get count` for absence; --fn for text). */
18
+ declare function abAssertNotVisible(selector: string, timeoutMs?: number): void;
19
+ /** Assert URL contains a pattern (via get url). */
20
+ declare function abAssertUrl(pattern: string): void;
21
+ /** Assert element is enabled (via is enabled). */
22
+ declare function abAssertEnabled(selector: string): void;
23
+ /** Assert element is disabled (via is enabled). */
24
+ declare function abAssertDisabled(selector: string): void;
25
+ /** Assert checkbox is checked (via is checked). */
26
+ declare function abAssertChecked(selector: string): void;
27
+ /** Assert checkbox is unchecked (via is checked). */
28
+ declare function abAssertUnchecked(selector: string): void;
29
+ /**
30
+ * Capture a step-boundary evidence pair (PNG + JSON metadata) so a reviewer
31
+ * can confirm at a glance that a passing spec actually drove the app to the
32
+ * state its `expected` describes. Opt-in at runtime via `CCQA_EVIDENCE_DIR` so
33
+ * generated scripts hand-run outside `ccqa run` don't write stray files. All
34
+ * errors are swallowed with a stderr warning — evidence capture must never
35
+ * flip a passing spec to red.
36
+ */
37
+ declare function abStepEvidence(stepId: string, source: string): void;
38
+ //#endregion
39
+ export { __setCurrentStep, ab, abAssertChecked, abAssertDisabled, abAssertEnabled, abAssertNotVisible, abAssertTextVisible, abAssertUnchecked, abAssertUrl, abAssertVisible, abStepEvidence, abUpload, abWait };