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.
- package/README.md +11 -4
- package/dist/bin/ccqa.mjs +22449 -13372
- package/dist/evidence-constants-BufWx8Bt.cjs +59 -0
- package/dist/hub-client/index.cjs +434 -0
- package/dist/hub-client/index.d.cts +1376 -0
- package/dist/hub-client/index.d.mts +28 -4
- package/dist/package.json +49 -9
- package/dist/runtime/judge.cjs +1307 -0
- package/dist/runtime/judge.d.cts +68 -0
- package/dist/runtime/judge.d.mts +24 -2
- package/dist/runtime/judge.mjs +1229 -3
- package/dist/runtime/step-evidence.cjs +106 -0
- package/dist/runtime/step-evidence.d.cts +53 -0
- package/dist/runtime/step-evidence.mjs +1 -1
- package/dist/runtime/test-helpers.cjs +461 -0
- package/dist/runtime/test-helpers.d.cts +39 -0
- package/dist/runtime/test-helpers.mjs +134 -3
- package/package.json +49 -9
- package/dist/diagnose-CZms9Cer.mjs +0 -2432
- package/dist/spawn-ab-CR_Sr7wh.mjs +0 -199
- /package/dist/{evidence-constants-Cm_S_5od.mjs → evidence-constants-C425F7ZG.mjs} +0 -0
|
@@ -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-
|
|
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 };
|