ccqa 1.46.1 → 1.47.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/ccqa.mjs +336 -2281
- package/dist/diagnose-CSQzwS5f.mjs +2222 -0
- package/dist/package.json +5 -1
- package/dist/runtime/judge.d.mts +46 -0
- package/dist/runtime/judge.mjs +78 -0
- package/dist/runtime/step-evidence.mjs +1 -1
- package/dist/runtime/test-helpers.mjs +2 -2
- package/package.json +5 -1
- /package/dist/{evidence-constants-C425F7ZG.mjs → evidence-constants-Cm_S_5od.mjs} +0 -0
- /package/dist/{spawn-ab-Bm34WBui.mjs → spawn-ab-CR_Sr7wh.mjs} +0 -0
|
@@ -0,0 +1,2222 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { readFile } from "node:fs/promises";
|
|
3
|
+
import { parse } from "yaml";
|
|
4
|
+
import { ZodError, z } from "zod";
|
|
5
|
+
import { query } from "@anthropic-ai/claude-agent-sdk";
|
|
6
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
7
|
+
//#region src/runtime/env-vars.ts
|
|
8
|
+
const ENV_VAR_RE = /\$\{([A-Z_][A-Z0-9_]*)\}|\$([A-Z_][A-Z0-9_]*)/g;
|
|
9
|
+
const ANY_VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}|\$([A-Za-z_][A-Za-z0-9_]*)/g;
|
|
10
|
+
const BRACED_VAR_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g;
|
|
11
|
+
/**
|
|
12
|
+
* Replace every `$NAME` / `${NAME}` reference in `value` using `lookup`. When
|
|
13
|
+
* `lookup` returns `undefined`, the original reference text is preserved
|
|
14
|
+
* (callers that want empty-string substitution should wrap with `?? ""`).
|
|
15
|
+
*/
|
|
16
|
+
function substituteVars(value, lookup) {
|
|
17
|
+
ANY_VAR_RE.lastIndex = 0;
|
|
18
|
+
return value.replace(ANY_VAR_RE, (match, braced, plain) => {
|
|
19
|
+
const replacement = lookup(braced ?? plain ?? "");
|
|
20
|
+
return replacement === void 0 ? match : replacement;
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Iterate every `${NAME}` / `$NAME` reference name (case-insensitive form)
|
|
25
|
+
* appearing in `value`. Used by callers that want to enumerate refs without
|
|
26
|
+
* also substituting, e.g. the env-scrub map builder. The reference name
|
|
27
|
+
* grammar is the canonical one shared with `substituteVars`.
|
|
28
|
+
*/
|
|
29
|
+
function* iterEnvRefNames(value) {
|
|
30
|
+
ANY_VAR_RE.lastIndex = 0;
|
|
31
|
+
let m;
|
|
32
|
+
while ((m = ANY_VAR_RE.exec(value)) !== null) {
|
|
33
|
+
const name = m[1] ?? m[2];
|
|
34
|
+
if (name) yield name;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Resolve every `$VAR` / `${VAR}` reference against `overrides`, then the
|
|
39
|
+
* current process env. `overrides` carries values an invoker injects into a
|
|
40
|
+
* child process (e.g. CCQA_RUN_ID), which beat the parent env there.
|
|
41
|
+
*
|
|
42
|
+
* Missing variables expand to the empty string, mirroring `sh` behaviour.
|
|
43
|
+
* Throwing would force ccqa to be invoked with every var set even for
|
|
44
|
+
* unused blocks, which is more user-hostile than letting the test fail
|
|
45
|
+
* downstream with a clearer message ("login form rejected: empty password").
|
|
46
|
+
*/
|
|
47
|
+
function resolveEnvRefs(value, overrides = {}) {
|
|
48
|
+
return value.replace(ENV_VAR_RE, (_, braced, plain) => {
|
|
49
|
+
const name = braced ?? plain ?? "";
|
|
50
|
+
return overrides[name] ?? process.env[name] ?? "";
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Embed `$VAR` / `${VAR}` as a JS template-literal expression that reads
|
|
55
|
+
* `process.env.VAR ?? ""` at runtime. Used by `ccqa generate` so the test
|
|
56
|
+
* script never bakes in the secret value.
|
|
57
|
+
*
|
|
58
|
+
* Returns a JavaScript string-literal expression (template literal when env
|
|
59
|
+
* refs are present, plain string literal otherwise).
|
|
60
|
+
*
|
|
61
|
+
* Examples:
|
|
62
|
+
* "${PASSWORD}" -> '`${process.env.PASSWORD ?? ""}`'
|
|
63
|
+
* "user-${SUFFIX}@x.com" -> '`user-${process.env.SUFFIX ?? ""}@x.com`'
|
|
64
|
+
* "literal value" -> '"literal value"'
|
|
65
|
+
*/
|
|
66
|
+
function envRefsToJsExpression(value) {
|
|
67
|
+
return refsToJsExpression(value, () => null);
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Generalised version of `envRefsToJsExpression`. Each `$NAME` / `${NAME}`
|
|
71
|
+
* reference in `value` is passed to `nameToExpr(name)` first:
|
|
72
|
+
*
|
|
73
|
+
* - If it returns a string, that string is interpolated as a JS expression
|
|
74
|
+
* (no quoting / no `?? ""` wrap — the caller decides the shape).
|
|
75
|
+
* - If it returns `null`, the reference is treated as a missing env var
|
|
76
|
+
* and expands to `process.env.<NAME> ?? ""` (the legacy behaviour).
|
|
77
|
+
*
|
|
78
|
+
* Used by the block codegen path: param names map to `params.<name>`,
|
|
79
|
+
* everything else falls through to `process.env.X ?? ""`.
|
|
80
|
+
*/
|
|
81
|
+
function refsToJsExpression(value, nameToExpr, refRe = ANY_VAR_RE) {
|
|
82
|
+
refRe.lastIndex = 0;
|
|
83
|
+
if (!refRe.test(value)) return JSON.stringify(value);
|
|
84
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$\{/g, (_match, offset, source) => {
|
|
85
|
+
const probe = new RegExp(refRe.source, "g");
|
|
86
|
+
let m;
|
|
87
|
+
while ((m = probe.exec(source)) !== null) if (m.index === offset) return "${";
|
|
88
|
+
return "\\${";
|
|
89
|
+
});
|
|
90
|
+
refRe.lastIndex = 0;
|
|
91
|
+
return `\`${escaped.replace(refRe, (_match, braced, plain) => {
|
|
92
|
+
const name = braced ?? plain ?? "";
|
|
93
|
+
const expr = nameToExpr(name);
|
|
94
|
+
return expr !== null ? `\${${expr}}` : `\${process.env.${name} ?? ""}`;
|
|
95
|
+
})}\``;
|
|
96
|
+
}
|
|
97
|
+
/** `${VAR}` only. For prose, where a bare `$WORD` is a word and not a reference. */
|
|
98
|
+
function bracedRefsToJsExpression(value) {
|
|
99
|
+
return refsToJsExpression(value, () => null, BRACED_VAR_RE);
|
|
100
|
+
}
|
|
101
|
+
//#endregion
|
|
102
|
+
//#region src/spec/yaml-schema.ts
|
|
103
|
+
/**
|
|
104
|
+
* An action step: one user-facing browser interaction. `instruction` and
|
|
105
|
+
* `expected` are the natural-language description handed to Claude during
|
|
106
|
+
* `ccqa trace`. URLs live inside `instruction`, either verbatim or via
|
|
107
|
+
* `${ENV_VAR}` references (resolved at runtime).
|
|
108
|
+
*/
|
|
109
|
+
const ActionStepSchema = z.object({
|
|
110
|
+
instruction: z.string().min(1),
|
|
111
|
+
expected: z.string().min(1)
|
|
112
|
+
}).strict();
|
|
113
|
+
/**
|
|
114
|
+
* An include step: invokes a reusable block (`.ccqa/blocks/<name>/spec.yaml`).
|
|
115
|
+
* `params` values are plain strings; env refs (`${VAR}`) inside them are
|
|
116
|
+
* resolved at expand time the same way step instructions are.
|
|
117
|
+
*/
|
|
118
|
+
const IncludeStepSchema = z.object({
|
|
119
|
+
include: z.string().min(1),
|
|
120
|
+
params: z.record(z.string(), z.string()).optional()
|
|
121
|
+
}).strict();
|
|
122
|
+
/**
|
|
123
|
+
* A claim about the page decided by a model rather than by a selector match,
|
|
124
|
+
* for output a run cannot predict. `from` narrows what it reads to one
|
|
125
|
+
* element; omitted, the page's visible text. See docs/spec.md.
|
|
126
|
+
*/
|
|
127
|
+
const JudgeByLlmStepSchema = z.object({
|
|
128
|
+
judgeByLlm: z.string().min(1),
|
|
129
|
+
from: z.string().min(1).optional()
|
|
130
|
+
}).strict();
|
|
131
|
+
/**
|
|
132
|
+
* A spec step is an action, an include, or a judge-by-LLM — discriminated by the
|
|
133
|
+
* presence of the `include` / `judgeByLlm` key (see the predicates below).
|
|
134
|
+
*/
|
|
135
|
+
const StepSchema = z.union([
|
|
136
|
+
ActionStepSchema,
|
|
137
|
+
IncludeStepSchema,
|
|
138
|
+
JudgeByLlmStepSchema
|
|
139
|
+
]);
|
|
140
|
+
/**
|
|
141
|
+
* Execution mode for `ccqa run`:
|
|
142
|
+
* - `deterministic` (default): vitest replays the recorded `test.spec.ts`.
|
|
143
|
+
* - `live`: Claude drives agent-browser per step (for fragile UIs where
|
|
144
|
+
* codegen is impractical). Cost ~$0.5 per spec.
|
|
145
|
+
*/
|
|
146
|
+
const SpecModeSchema = z.enum(["deterministic", "live"]);
|
|
147
|
+
/**
|
|
148
|
+
* A name a spec chooses that ccqa resolves to a path or looks up in a
|
|
149
|
+
* registry. Restricted to a slug so it cannot escape a directory.
|
|
150
|
+
*/
|
|
151
|
+
function slug(what) {
|
|
152
|
+
return z.string().min(1).regex(/^[a-z0-9][a-z0-9._-]*$/i, `${what} must be a slug (letters, digits, '.', '_', '-'; no path separators)`);
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* A saved browser session (cookies + localStorage) to restore before the spec
|
|
156
|
+
* runs, resolved to `.ccqa/sessions/<profile>/<name>.json` at run time.
|
|
157
|
+
*/
|
|
158
|
+
const SessionNameSchema = slug("session name");
|
|
159
|
+
/**
|
|
160
|
+
* Sessions to restore before a `mode: live` spec runs: one name or a list,
|
|
161
|
+
* always read back as a list. Multiple names are merged (their cookies +
|
|
162
|
+
* localStorage are unioned) and restored together, so a spec can start
|
|
163
|
+
* signed-in to several providers at once.
|
|
164
|
+
*/
|
|
165
|
+
const SessionFieldSchema = z.union([SessionNameSchema, z.array(SessionNameSchema).min(1)]).transform((v) => Array.isArray(v) ? v : [v]);
|
|
166
|
+
/**
|
|
167
|
+
* A generation-target id: which plugin turns this spec into runnable tests
|
|
168
|
+
* (e.g. "agent-browser", "playwright", "runn"). Whether the id names a
|
|
169
|
+
* registered target is the registry's responsibility, so new targets don't
|
|
170
|
+
* require a schema change.
|
|
171
|
+
*/
|
|
172
|
+
const TargetIdSchema = slug("target");
|
|
173
|
+
/** The built-in recorder-backed target. `mode:` / `session:` only apply to it. */
|
|
174
|
+
const AGENT_BROWSER_TARGET = "agent-browser";
|
|
175
|
+
/**
|
|
176
|
+
* Top-level spec schema. `.strict()` rejects any unknown key.
|
|
177
|
+
*
|
|
178
|
+
* `mode:` and `session:` are agent-browser-only fields, enforced here when
|
|
179
|
+
* `target:` names another target. When `target:` is omitted the effective
|
|
180
|
+
* target comes from config (`defaultTarget`, falling back to agent-browser),
|
|
181
|
+
* which this schema can't see — so mode/session pass parsing and the
|
|
182
|
+
* post-resolution check is the target resolver's responsibility.
|
|
183
|
+
*/
|
|
184
|
+
const TestSpecSchema = z.object({
|
|
185
|
+
title: z.string().min(1),
|
|
186
|
+
disabled: z.boolean().optional(),
|
|
187
|
+
target: TargetIdSchema.optional(),
|
|
188
|
+
mode: SpecModeSchema.optional(),
|
|
189
|
+
session: SessionFieldSchema.optional(),
|
|
190
|
+
steps: z.array(StepSchema).min(1)
|
|
191
|
+
}).strict().superRefine((spec, ctx) => {
|
|
192
|
+
if (spec.target === void 0 || spec.target === "agent-browser") return;
|
|
193
|
+
for (const key of ["mode", "session"]) if (spec[key] !== void 0) ctx.addIssue({
|
|
194
|
+
code: "custom",
|
|
195
|
+
path: [key],
|
|
196
|
+
message: `\`${key}\` only applies to the agent-browser target — remove it or drop \`target: ${spec.target}\``
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
/** Default mode when `mode:` is absent. */
|
|
200
|
+
const DEFAULT_SPEC_MODE = "deterministic";
|
|
201
|
+
/**
|
|
202
|
+
* A block param declaration. `required` defaults to true; only explicit
|
|
203
|
+
* `required: false` makes it optional. `secret: true` flags the value as
|
|
204
|
+
* sensitive — codegen renders such values as `process.env.<NAME> ?? ""`
|
|
205
|
+
* template literals so the secret never ends up baked into test.spec.ts.
|
|
206
|
+
*/
|
|
207
|
+
const BlockParamSchema = z.object({
|
|
208
|
+
name: z.string().min(1),
|
|
209
|
+
required: z.boolean().optional(),
|
|
210
|
+
secret: z.boolean().optional()
|
|
211
|
+
}).strict();
|
|
212
|
+
/**
|
|
213
|
+
* Block schema. A block step is an action or a judge-by-LLM — nested blocks are
|
|
214
|
+
* forbidden, so including a block from inside another block fails parsing here
|
|
215
|
+
* (the parser maps the union's cryptic failure into a nested-block message).
|
|
216
|
+
*/
|
|
217
|
+
const BlockSpecSchema = z.object({
|
|
218
|
+
title: z.string().min(1),
|
|
219
|
+
params: z.array(BlockParamSchema).optional(),
|
|
220
|
+
steps: z.array(z.union([ActionStepSchema, JudgeByLlmStepSchema])).min(1)
|
|
221
|
+
}).strict();
|
|
222
|
+
/** Runtime predicates for the StepSchema union. */
|
|
223
|
+
function isIncludeStep(step) {
|
|
224
|
+
return "include" in step;
|
|
225
|
+
}
|
|
226
|
+
function isJudgeByLlmStep(step) {
|
|
227
|
+
return "judgeByLlm" in step;
|
|
228
|
+
}
|
|
229
|
+
/** Returns true if a block param is required (default: true). */
|
|
230
|
+
function isParamRequired(param) {
|
|
231
|
+
return param.required !== false;
|
|
232
|
+
}
|
|
233
|
+
//#endregion
|
|
234
|
+
//#region src/spec/parser.ts
|
|
235
|
+
/** The spec/block root (an `unrecognized_keys` issue there has an empty path). */
|
|
236
|
+
const atRoot = (path) => path.length === 0;
|
|
237
|
+
/** A block param entry — the issue path is `params.<index>`. */
|
|
238
|
+
const atBlockParam = (path) => path.length === 2 && path[0] === "params" && typeof path[1] === "number";
|
|
239
|
+
const UNREAD_PARAM_FIELD = "nothing reads it (a block param reaches the prompts as its name, required and secret only). Delete the line.";
|
|
240
|
+
const REMOVED_FIELDS = {
|
|
241
|
+
relatedPaths: {
|
|
242
|
+
at: atRoot,
|
|
243
|
+
message: "which specs a change affects is now decided by `ccqa select-specs`, which reads the diff instead of a declared path list. Delete the field."
|
|
244
|
+
},
|
|
245
|
+
dummy: {
|
|
246
|
+
at: atBlockParam,
|
|
247
|
+
message: UNREAD_PARAM_FIELD
|
|
248
|
+
},
|
|
249
|
+
description: {
|
|
250
|
+
at: atBlockParam,
|
|
251
|
+
message: UNREAD_PARAM_FIELD
|
|
252
|
+
}
|
|
253
|
+
};
|
|
254
|
+
/** Parse a spec.yaml. Schema rejections are rewritten with actionable messages. */
|
|
255
|
+
function parseTestSpec(content, source = "spec.yaml") {
|
|
256
|
+
const raw = parseYamlOrThrow(content, source);
|
|
257
|
+
try {
|
|
258
|
+
return TestSpecSchema.parse(raw);
|
|
259
|
+
} catch (e) {
|
|
260
|
+
throw enrichZodError(e, source, false, raw);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Throw-suppressed sibling of `parseTestSpec`. Used by report-side helpers
|
|
265
|
+
* that derive cosmetic data (title, step descriptions) from spec.yaml and
|
|
266
|
+
* want a missing or malformed file to degrade silently rather than abort
|
|
267
|
+
* the report.
|
|
268
|
+
*/
|
|
269
|
+
function tryParseTestSpec(yaml) {
|
|
270
|
+
if (!yaml) return null;
|
|
271
|
+
try {
|
|
272
|
+
return parseTestSpec(yaml);
|
|
273
|
+
} catch {
|
|
274
|
+
return null;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
/**
|
|
278
|
+
* Parse a block's spec.yaml. Block-specific errors include the targeted
|
|
279
|
+
* nested-block message (the underlying zod failure on an `include` key
|
|
280
|
+
* inside a block step is hard to read).
|
|
281
|
+
*/
|
|
282
|
+
function parseBlockSpec(content, source = "block spec.yaml") {
|
|
283
|
+
const raw = parseYamlOrThrow(content, source);
|
|
284
|
+
try {
|
|
285
|
+
return BlockSpecSchema.parse(raw);
|
|
286
|
+
} catch (e) {
|
|
287
|
+
throw enrichZodError(e, source, true, raw);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
function parseYamlOrThrow(content, source) {
|
|
291
|
+
try {
|
|
292
|
+
return parse(content);
|
|
293
|
+
} catch (e) {
|
|
294
|
+
throw new Error(`Failed to parse YAML (${source}): ${e.message}`);
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
function enrichZodError(error, source, isBlock, raw) {
|
|
298
|
+
if (!(error instanceof ZodError)) return error;
|
|
299
|
+
const lines = [`Invalid ${source}:`];
|
|
300
|
+
for (const issue of error.issues) for (const [path, message] of explain(issue, isBlock, raw)) lines.push(` - ${path.join(".") || "(root)"}: ${message}`);
|
|
301
|
+
return new Error(lines.join("\n"));
|
|
302
|
+
}
|
|
303
|
+
const NESTED_BLOCK_MESSAGE = "Nested blocks are not supported — flatten by inlining the included block's steps into this block.";
|
|
304
|
+
function explain(issue, isBlock, raw) {
|
|
305
|
+
const asIs = [[issue.path, humanizeIssue(issue, isBlock, raw)]];
|
|
306
|
+
if (issue.code !== "invalid_union" || !issue.errors?.length) return asIs;
|
|
307
|
+
if (isBlock && stepKind(raw, issue.path) === "include") return asIs;
|
|
308
|
+
const best = pickBranch(issue.errors, stepKind(raw, issue.path));
|
|
309
|
+
if (!best?.length) return asIs;
|
|
310
|
+
return best.map((b) => {
|
|
311
|
+
const path = [...issue.path, ...b.path];
|
|
312
|
+
return [path, humanizeIssue({
|
|
313
|
+
...b,
|
|
314
|
+
path
|
|
315
|
+
}, isBlock, raw)];
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
/** Which kind of step the author was writing, read from the key they used. */
|
|
319
|
+
function stepKind(raw, path) {
|
|
320
|
+
const node = nodeAt(raw, path);
|
|
321
|
+
if (!isRecord(node)) return null;
|
|
322
|
+
for (const key of ["include", "judgeByLlm"]) if (key in node) return key;
|
|
323
|
+
return "instruction";
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* The branch the author meant: the one that accepts the key they wrote. Any
|
|
327
|
+
* other branch reports that key as unrecognized, which would tell them the
|
|
328
|
+
* key they just used does not exist. Ties among the rest go to the branch
|
|
329
|
+
* that recognized the most of what they wrote.
|
|
330
|
+
*/
|
|
331
|
+
function pickBranch(branches, kind) {
|
|
332
|
+
const accepts = kind === null ? branches : branches.filter((b) => !rejectsKey(b, kind));
|
|
333
|
+
return (accepts.length > 0 ? accepts : branches).reduce((a, b) => unknownKeyCount(a) <= unknownKeyCount(b) ? a : b);
|
|
334
|
+
}
|
|
335
|
+
function rejectsKey(issues, key) {
|
|
336
|
+
return issues.some((i) => Array.isArray(i.keys) && i.keys.includes(key));
|
|
337
|
+
}
|
|
338
|
+
function unknownKeyCount(issues) {
|
|
339
|
+
return issues.reduce((n, i) => n + (Array.isArray(i.keys) ? i.keys.length : 0), 0);
|
|
340
|
+
}
|
|
341
|
+
function humanizeIssue(issue, isBlock, raw) {
|
|
342
|
+
if (isBlock && issue.code === "invalid_union" && stepKind(raw, issue.path) === "include") return NESTED_BLOCK_MESSAGE;
|
|
343
|
+
if (issue.code === "unrecognized_keys") {
|
|
344
|
+
const keys = Array.isArray(issue.keys) ? issue.keys : [];
|
|
345
|
+
if (isBlock && keys.includes("include")) return NESTED_BLOCK_MESSAGE;
|
|
346
|
+
const removed = keys.filter((k) => REMOVED_FIELDS[k]?.at(issue.path));
|
|
347
|
+
const stillUnknown = keys.filter((k) => !REMOVED_FIELDS[k]?.at(issue.path));
|
|
348
|
+
const parts = removed.map((k) => `\`${k}\` is no longer part of the spec schema — ${REMOVED_FIELDS[k].message}`);
|
|
349
|
+
if (stillUnknown.length > 0) parts.push(`Unknown keys: ${stillUnknown.join(", ")}`);
|
|
350
|
+
return parts.join(" ");
|
|
351
|
+
}
|
|
352
|
+
return issue.message;
|
|
353
|
+
}
|
|
354
|
+
function nodeAt(raw, path) {
|
|
355
|
+
let node = raw;
|
|
356
|
+
for (const segment of path) {
|
|
357
|
+
if (!isRecord(node)) return void 0;
|
|
358
|
+
node = node[segment];
|
|
359
|
+
}
|
|
360
|
+
return node;
|
|
361
|
+
}
|
|
362
|
+
function isRecord(value) {
|
|
363
|
+
return typeof value === "object" && value !== null;
|
|
364
|
+
}
|
|
365
|
+
//#endregion
|
|
366
|
+
//#region src/spec/expand.ts
|
|
367
|
+
/** Runtime predicates for the ExpandedStep union. */
|
|
368
|
+
function isExpandedJudgeByLlmStep(step) {
|
|
369
|
+
return "judgeByLlm" in step;
|
|
370
|
+
}
|
|
371
|
+
function isExpandedActionStep(step) {
|
|
372
|
+
return !("judgeByLlm" in step);
|
|
373
|
+
}
|
|
374
|
+
function isJudgeBody(step) {
|
|
375
|
+
return "judgeByLlm" in step;
|
|
376
|
+
}
|
|
377
|
+
/**
|
|
378
|
+
* Walk the spec's top-level steps, inlining any `- include: <block>` reference
|
|
379
|
+
* as the block's own steps in order. The result is a flat `step-NN`-numbered
|
|
380
|
+
* sequence — block boundaries survive only as the `source` tag, so trace and
|
|
381
|
+
* codegen never need a separate block code path.
|
|
382
|
+
*/
|
|
383
|
+
function expandSpec(spec, options) {
|
|
384
|
+
const out = [];
|
|
385
|
+
let counter = 0;
|
|
386
|
+
const allocId = () => {
|
|
387
|
+
counter += 1;
|
|
388
|
+
return `step-${String(counter).padStart(2, "0")}`;
|
|
389
|
+
};
|
|
390
|
+
for (const step of spec.steps) if (isIncludeStep(step)) {
|
|
391
|
+
const block = resolveBlock(step.include, step.params ?? {}, options.blocks);
|
|
392
|
+
const substitute = (text) => substituteVars(text, block.lookup);
|
|
393
|
+
for (const blockStep of block.steps) out.push(expandStep(blockStep, allocId(), step.include, substitute));
|
|
394
|
+
} else out.push(expandStep(step, allocId(), "spec", (text) => text));
|
|
395
|
+
return out;
|
|
396
|
+
}
|
|
397
|
+
/**
|
|
398
|
+
* `expandSpec` for a target that emits no judge call. A claim is refused by
|
|
399
|
+
* name rather than dropped: one that goes unjudged is a test that passes
|
|
400
|
+
* without testing. Blocks carry steps the spec does not name, which is why
|
|
401
|
+
* this runs after expansion rather than in the schema.
|
|
402
|
+
*/
|
|
403
|
+
function expandActionSteps(spec, options, specKey, target) {
|
|
404
|
+
return expandSpec(spec, options).map((step) => {
|
|
405
|
+
if (isExpandedJudgeByLlmStep(step)) {
|
|
406
|
+
const from = step.source === "spec" ? "" : ` (from block \`${step.source}\`)`;
|
|
407
|
+
throw new Error(`${specKey}: step ${step.id}${from} uses \`judgeByLlm\`, but the "${target.id}" target cannot honour it — ${target.reason}`);
|
|
408
|
+
}
|
|
409
|
+
return step;
|
|
410
|
+
});
|
|
411
|
+
}
|
|
412
|
+
/** `substitute` resolves a block's `$param` refs; a spec's own steps have none, so it passes text through. */
|
|
413
|
+
function expandStep(step, id, source, substitute) {
|
|
414
|
+
if (isJudgeByLlmStep(step)) return {
|
|
415
|
+
id,
|
|
416
|
+
source,
|
|
417
|
+
judgeByLlm: substitute(step.judgeByLlm),
|
|
418
|
+
...step.from !== void 0 ? { from: substitute(step.from) } : {}
|
|
419
|
+
};
|
|
420
|
+
return {
|
|
421
|
+
id,
|
|
422
|
+
source,
|
|
423
|
+
instruction: substitute(step.instruction),
|
|
424
|
+
expected: substitute(step.expected)
|
|
425
|
+
};
|
|
426
|
+
}
|
|
427
|
+
function resolveBlock(blockName, rawParams, blocks) {
|
|
428
|
+
const block = blocks.get(blockName);
|
|
429
|
+
if (!block) throw new Error(`Unknown block: "${blockName}". Define it under .ccqa/blocks/${blockName}/spec.yaml.`);
|
|
430
|
+
const declaredParams = new Map((block.params ?? []).map((p) => [p.name, p]));
|
|
431
|
+
for (const key of Object.keys(rawParams)) if (!declaredParams.has(key)) throw new Error(`Block "${blockName}" received unknown param "${key}". Declared params: ${[...declaredParams.keys()].join(", ") || "(none)"}.`);
|
|
432
|
+
for (const [pname, def] of declaredParams) if (isParamRequired(def) && !(pname in rawParams)) throw new Error(`Block "${blockName}" is missing required param "${pname}".`);
|
|
433
|
+
const lookup = (name) => {
|
|
434
|
+
if (Object.prototype.hasOwnProperty.call(rawParams, name)) return rawParams[name];
|
|
435
|
+
};
|
|
436
|
+
return {
|
|
437
|
+
steps: block.steps,
|
|
438
|
+
lookup
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
/**
|
|
442
|
+
* Collect every block name referenced by a spec (top-level only — blocks
|
|
443
|
+
* cannot nest). Used by the store / drift layers to know which blocks to
|
|
444
|
+
* load or invalidate.
|
|
445
|
+
*/
|
|
446
|
+
function collectIncludedBlockNames(spec) {
|
|
447
|
+
const names = /* @__PURE__ */ new Set();
|
|
448
|
+
for (const step of spec.steps) if (isIncludeStep(step)) names.add(step.include);
|
|
449
|
+
return [...names];
|
|
450
|
+
}
|
|
451
|
+
//#endregion
|
|
452
|
+
//#region src/claude/env-keys.ts
|
|
453
|
+
/**
|
|
454
|
+
* Variables that carry a credential the Claude Code process can use on its
|
|
455
|
+
* own, with no login on the host: an API key, a gateway bearer token, or a
|
|
456
|
+
* subscription token from `claude setup-token`.
|
|
457
|
+
*/
|
|
458
|
+
const CREDENTIAL_ENV_KEYS = [
|
|
459
|
+
"ANTHROPIC_API_KEY",
|
|
460
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
461
|
+
"CLAUDE_CODE_OAUTH_TOKEN"
|
|
462
|
+
];
|
|
463
|
+
/**
|
|
464
|
+
* Standard Claude Code environment variables that select the API endpoint and
|
|
465
|
+
* credentials. ccqa forwards whichever of these are set to the underlying
|
|
466
|
+
* Claude Code process; it does not read or interpret their values.
|
|
467
|
+
*
|
|
468
|
+
* - `ANTHROPIC_BASE_URL` — the API endpoint to send requests to.
|
|
469
|
+
* - `ANTHROPIC_AUTH_TOKEN` — sent as `Authorization: Bearer <token>`.
|
|
470
|
+
* - `ANTHROPIC_API_KEY` — API key, when used instead of a token.
|
|
471
|
+
* - `ANTHROPIC_CUSTOM_HEADERS` — extra request headers.
|
|
472
|
+
* - `CLAUDE_CODE_OAUTH_TOKEN` — long-lived subscription token from
|
|
473
|
+
* `claude setup-token`, the headless-CI counterpart of a login.
|
|
474
|
+
*/
|
|
475
|
+
const ENDPOINT_ENV_KEYS = [
|
|
476
|
+
"ANTHROPIC_BASE_URL",
|
|
477
|
+
"ANTHROPIC_CUSTOM_HEADERS",
|
|
478
|
+
...CREDENTIAL_ENV_KEYS
|
|
479
|
+
];
|
|
480
|
+
//#endregion
|
|
481
|
+
//#region src/cli/logger.ts
|
|
482
|
+
const STEP_ICONS = {
|
|
483
|
+
STEP_START: "▶",
|
|
484
|
+
STEP_DONE: "✓",
|
|
485
|
+
ASSERTION_FAILED: "✗",
|
|
486
|
+
STEP_SKIPPED: "⊘",
|
|
487
|
+
RUN_COMPLETED: "■"
|
|
488
|
+
};
|
|
489
|
+
/**
|
|
490
|
+
* When a `withBuffer` scope is active, every log line (stdout and stderr) is
|
|
491
|
+
* appended to its buffer instead of being written immediately. Parallel spec
|
|
492
|
+
* runs use this so each spec's narration — including logs emitted deep inside
|
|
493
|
+
* the live executor — flushes as one contiguous block, not interleaved.
|
|
494
|
+
*/
|
|
495
|
+
const bufferStore = new AsyncLocalStorage();
|
|
496
|
+
const sinkStore = new AsyncLocalStorage();
|
|
497
|
+
/** True while inside a `withBuffer` scope: progress lines avoid TTY cursor tricks. */
|
|
498
|
+
function isBuffered() {
|
|
499
|
+
return bufferStore.getStore() !== void 0;
|
|
500
|
+
}
|
|
501
|
+
function emit(text, sink = process.stdout) {
|
|
502
|
+
const store = bufferStore.getStore();
|
|
503
|
+
if (store) {
|
|
504
|
+
store.out.push(text);
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
const activeSink = sinkStore.getStore();
|
|
508
|
+
if (activeSink) {
|
|
509
|
+
activeSink.write(text);
|
|
510
|
+
return;
|
|
511
|
+
}
|
|
512
|
+
sink.write(text);
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Write raw text to the active `withBuffer` scope, or straight to stdout when
|
|
516
|
+
* none is active. Lets a runner redirect sub-process output (e.g. a child's
|
|
517
|
+
* stdout) into the same buffer as its `log.*` lines so they flush together.
|
|
518
|
+
*/
|
|
519
|
+
function emitRaw(text) {
|
|
520
|
+
emit(text);
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Run `fn` with all its log output captured into a buffer, then flush the
|
|
524
|
+
* buffer in one shot under `label`. Used by parallel runners to keep each
|
|
525
|
+
* spec's output legible. Output is flushed even when `fn` throws.
|
|
526
|
+
*
|
|
527
|
+
* When `buffered` is false, `fn` runs with no buffer so its output streams
|
|
528
|
+
* live — this is the sequential (concurrency 1) path, unchanged from before.
|
|
529
|
+
*/
|
|
530
|
+
async function withBuffer(label, buffered, fn) {
|
|
531
|
+
if (!buffered) return fn();
|
|
532
|
+
const store = { out: [] };
|
|
533
|
+
try {
|
|
534
|
+
return await bufferStore.run(store, fn);
|
|
535
|
+
} finally {
|
|
536
|
+
emit(`\n──── ${label} ────\n${store.out.join("")}`);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
function header(command, target) {
|
|
540
|
+
emit(`\nccqa ${command}${target ? ` ${target}` : ""}\n\n`);
|
|
541
|
+
}
|
|
542
|
+
function write(scope, message, sink = process.stdout) {
|
|
543
|
+
emit(`[${scope}] ${message}\n`, sink);
|
|
544
|
+
}
|
|
545
|
+
function meta(key, value) {
|
|
546
|
+
write("meta", `${key}: ${value}`);
|
|
547
|
+
}
|
|
548
|
+
function blank() {
|
|
549
|
+
emit("\n");
|
|
550
|
+
}
|
|
551
|
+
function info(message) {
|
|
552
|
+
write("info", message);
|
|
553
|
+
}
|
|
554
|
+
function step(type, stepId, detail) {
|
|
555
|
+
emit(` ${STEP_ICONS[type]} [${stepId}] ${detail}\n`);
|
|
556
|
+
}
|
|
557
|
+
function bash(command) {
|
|
558
|
+
emit(` $ ${command.slice(0, 120)}\n`);
|
|
559
|
+
}
|
|
560
|
+
function error(message) {
|
|
561
|
+
write("error", message, process.stderr);
|
|
562
|
+
}
|
|
563
|
+
function warn(message) {
|
|
564
|
+
write("warn", message, process.stderr);
|
|
565
|
+
}
|
|
566
|
+
function hint(message) {
|
|
567
|
+
emit("\n");
|
|
568
|
+
write("hint", message);
|
|
569
|
+
}
|
|
570
|
+
function fix(message) {
|
|
571
|
+
write("fix", message);
|
|
572
|
+
}
|
|
573
|
+
function run(message) {
|
|
574
|
+
write("run", message);
|
|
575
|
+
}
|
|
576
|
+
/**
|
|
577
|
+
* Render a single-line progress indicator for a step-by-step loop.
|
|
578
|
+
*
|
|
579
|
+
* On a TTY the line is rewritten in place via `\r` so the terminal stays
|
|
580
|
+
* uncluttered. In a non-TTY environment (CI, piped runs) we fall back to
|
|
581
|
+
* a regular `[info]` line every PROGRESS_NONTTY_STRIDE steps to avoid
|
|
582
|
+
* spamming the log with one line per action.
|
|
583
|
+
*
|
|
584
|
+
* Callers MUST call `progressEnd()` when the loop finishes (or aborts) so
|
|
585
|
+
* the carriage-return line gets a final newline; otherwise the next log
|
|
586
|
+
* line lands on the same physical row.
|
|
587
|
+
*/
|
|
588
|
+
const PROGRESS_NONTTY_STRIDE = 5;
|
|
589
|
+
let lastProgressNonTtyEmit = -1;
|
|
590
|
+
function progress(current, total, label) {
|
|
591
|
+
const text = `[info] ${current + 1}/${total} ${label}`;
|
|
592
|
+
if (process.stdout.isTTY && !isBuffered()) {
|
|
593
|
+
process.stdout.write(`\r${text}\x1b[K`);
|
|
594
|
+
return;
|
|
595
|
+
}
|
|
596
|
+
if (current === 0 || current - lastProgressNonTtyEmit >= PROGRESS_NONTTY_STRIDE) {
|
|
597
|
+
emit(`${text}\n`);
|
|
598
|
+
lastProgressNonTtyEmit = current;
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
function progressEnd() {
|
|
602
|
+
if (process.stdout.isTTY && !isBuffered()) process.stdout.write(`\r\x1b[K`);
|
|
603
|
+
lastProgressNonTtyEmit = -1;
|
|
604
|
+
}
|
|
605
|
+
/**
|
|
606
|
+
* Time a long-running step under the given scope, emitting `started` and
|
|
607
|
+
* `finished in N.Ns` markers. Scope must be a tag the user wants to grep
|
|
608
|
+
* for — typically "run" for vitest and "fix" for diagnose-loop steps.
|
|
609
|
+
*/
|
|
610
|
+
async function timedPhase(label, fn, scope = "fix") {
|
|
611
|
+
const startedAt = Date.now();
|
|
612
|
+
write(scope, `${label} started`);
|
|
613
|
+
try {
|
|
614
|
+
const result = await fn();
|
|
615
|
+
write(scope, `${label} finished in ${((Date.now() - startedAt) / 1e3).toFixed(1)}s`);
|
|
616
|
+
return result;
|
|
617
|
+
} catch (err) {
|
|
618
|
+
write(scope, `${label} threw after ${((Date.now() - startedAt) / 1e3).toFixed(1)}s`);
|
|
619
|
+
throw err;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
//#endregion
|
|
623
|
+
//#region src/ir/from-agent-browser.ts
|
|
624
|
+
/**
|
|
625
|
+
* Normalization from the agent-browser side of the recorder into the IR.
|
|
626
|
+
* The trace protocol emits one pipe-delimited `AB_ACTION|...` line per
|
|
627
|
+
* browser action (see `src/prompts/trace.ts` and
|
|
628
|
+
* `claude/invoke.ts:extractAbActionFromBashCommand`); `parseAbActionLine`
|
|
629
|
+
* turns each line into a `RecordedAction`.
|
|
630
|
+
*
|
|
631
|
+
* The mapping is a deterministic re-encoding: `to-agent-browser.ts` is its
|
|
632
|
+
* inverse, and the round-trip identity (ab argv → wire → IR → ab argv) is
|
|
633
|
+
* pinned by `roundtrip.test.ts`.
|
|
634
|
+
*/
|
|
635
|
+
/**
|
|
636
|
+
* Semantic locator strategies exposed by `agent-browser find`. Used by the
|
|
637
|
+
* `find_*` wire commands when a target cannot be uniquely picked out by the
|
|
638
|
+
* ALLOWED CSS forms (e.g. repeated `aria-label='1 reply'` rows where only
|
|
639
|
+
* "the last one" is meaningful).
|
|
640
|
+
*
|
|
641
|
+
* `first` / `last` / `nth` are positional helpers whose value carries an
|
|
642
|
+
* inner CSS selector (`nth` additionally needs an index); they normalize to
|
|
643
|
+
* a `css` Locator plus `index`. The remaining strategies read the value as
|
|
644
|
+
* the human-visible text/id and normalize to the matching `Locator.by`.
|
|
645
|
+
*/
|
|
646
|
+
const FIND_LOCATORS = [
|
|
647
|
+
"role",
|
|
648
|
+
"text",
|
|
649
|
+
"label",
|
|
650
|
+
"placeholder",
|
|
651
|
+
"alt",
|
|
652
|
+
"title",
|
|
653
|
+
"testid",
|
|
654
|
+
"first",
|
|
655
|
+
"last",
|
|
656
|
+
"nth"
|
|
657
|
+
];
|
|
658
|
+
/**
|
|
659
|
+
* Actions reachable via `agent-browser find <locator> ... <action>`. Kept
|
|
660
|
+
* here next to the locator list so all `find` wire knowledge lives in one
|
|
661
|
+
* place — `claude/invoke.ts` imports these instead of redefining its own sets.
|
|
662
|
+
*/
|
|
663
|
+
const FIND_ACTIONS = [
|
|
664
|
+
"click",
|
|
665
|
+
"dblclick",
|
|
666
|
+
"fill",
|
|
667
|
+
"type",
|
|
668
|
+
"hover",
|
|
669
|
+
"focus",
|
|
670
|
+
"check",
|
|
671
|
+
"uncheck"
|
|
672
|
+
];
|
|
673
|
+
const css = (value) => ({
|
|
674
|
+
by: "css",
|
|
675
|
+
value
|
|
676
|
+
});
|
|
677
|
+
function parseAbActionLine(line) {
|
|
678
|
+
if (!line.startsWith("AB_ACTION|")) return null;
|
|
679
|
+
const parts = line.split("|");
|
|
680
|
+
const command = parts[1];
|
|
681
|
+
switch (command) {
|
|
682
|
+
case "cookies_clear": return { action: "cookies_clear" };
|
|
683
|
+
case "open": return {
|
|
684
|
+
action: "navigate",
|
|
685
|
+
value: (parts[2] ?? "").replace(/^["']|["']$/g, "")
|
|
686
|
+
};
|
|
687
|
+
case "press": return {
|
|
688
|
+
action: "press",
|
|
689
|
+
...opt("value", parts[2])
|
|
690
|
+
};
|
|
691
|
+
case "scroll": return {
|
|
692
|
+
action: "scroll",
|
|
693
|
+
...opt("direction", parts[2]),
|
|
694
|
+
...opt("pixels", parts[3])
|
|
695
|
+
};
|
|
696
|
+
case "snapshot": return {
|
|
697
|
+
action: "snapshot",
|
|
698
|
+
...opt("observation", parts[2])
|
|
699
|
+
};
|
|
700
|
+
case "assert": return {
|
|
701
|
+
action: "assert",
|
|
702
|
+
assert: parts[2],
|
|
703
|
+
...parts[3] ? { locator: css(parts[3]) } : {},
|
|
704
|
+
...parts[4] ? { value: parts[4] } : {},
|
|
705
|
+
...parts[5] ? { observation: parts[5] } : {}
|
|
706
|
+
};
|
|
707
|
+
case "click":
|
|
708
|
+
case "dblclick":
|
|
709
|
+
case "check":
|
|
710
|
+
case "uncheck":
|
|
711
|
+
case "hover":
|
|
712
|
+
if (!parts[2]) return null;
|
|
713
|
+
return {
|
|
714
|
+
action: command,
|
|
715
|
+
locator: css(parts[2]),
|
|
716
|
+
...opt("label", parts[3])
|
|
717
|
+
};
|
|
718
|
+
case "wait":
|
|
719
|
+
if (parts[2] === "--text") {
|
|
720
|
+
if (!parts[3]) return null;
|
|
721
|
+
return {
|
|
722
|
+
action: "wait",
|
|
723
|
+
locator: {
|
|
724
|
+
by: "text",
|
|
725
|
+
value: parts[3]
|
|
726
|
+
},
|
|
727
|
+
...opt("label", parts[4])
|
|
728
|
+
};
|
|
729
|
+
}
|
|
730
|
+
if (!parts[2]) return null;
|
|
731
|
+
return {
|
|
732
|
+
action: "wait",
|
|
733
|
+
locator: css(parts[2]),
|
|
734
|
+
...opt("label", parts[3])
|
|
735
|
+
};
|
|
736
|
+
case "fill":
|
|
737
|
+
case "type":
|
|
738
|
+
case "select":
|
|
739
|
+
if (!parts[2]) return null;
|
|
740
|
+
return {
|
|
741
|
+
action: command,
|
|
742
|
+
locator: css(parts[2]),
|
|
743
|
+
...parts[3] !== void 0 ? { value: parts[3] } : {},
|
|
744
|
+
...opt("label", parts[4])
|
|
745
|
+
};
|
|
746
|
+
case "drag":
|
|
747
|
+
if (!parts[2] || !parts[3]) return null;
|
|
748
|
+
return {
|
|
749
|
+
action: "drag",
|
|
750
|
+
locator: css(parts[2]),
|
|
751
|
+
target: css(parts[3]),
|
|
752
|
+
...opt("label", parts[4])
|
|
753
|
+
};
|
|
754
|
+
case "upload": {
|
|
755
|
+
const selector = parts[2];
|
|
756
|
+
const files = parts.slice(3).filter((f) => f !== "");
|
|
757
|
+
if (!selector || files.length === 0) return null;
|
|
758
|
+
return {
|
|
759
|
+
action: "upload",
|
|
760
|
+
locator: css(selector),
|
|
761
|
+
files
|
|
762
|
+
};
|
|
763
|
+
}
|
|
764
|
+
case "find_click":
|
|
765
|
+
case "find_dblclick":
|
|
766
|
+
case "find_hover":
|
|
767
|
+
case "find_focus":
|
|
768
|
+
case "find_check":
|
|
769
|
+
case "find_uncheck": return parseFindAction(command.slice(5), parts, false);
|
|
770
|
+
case "find_fill":
|
|
771
|
+
case "find_type": return parseFindAction(command.slice(5), parts, true);
|
|
772
|
+
case "get_count":
|
|
773
|
+
case "get_url": return null;
|
|
774
|
+
default: return null;
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
/**
|
|
778
|
+
* Promote a `CCQA_ASSERT=<marker>` env marker on an agent-browser command
|
|
779
|
+
* into recorded assert action(s). The marker travels on the same channel as
|
|
780
|
+
* the command itself (see `claude/invoke.ts`), so a verification the model
|
|
781
|
+
* performs anyway (`wait --text`, `get count`) becomes a recorded assert
|
|
782
|
+
* without relying on the `AB_ACTION|assert|...` text protocol.
|
|
783
|
+
*
|
|
784
|
+
* `abAction` is the wire line for the marked command (null when the command
|
|
785
|
+
* has no wire form at all). Mapping — anything else returns null and the
|
|
786
|
+
* caller warns and records the command unpromoted:
|
|
787
|
+
*
|
|
788
|
+
* - `wait --text "X"` + `1` (or `text_visible`) → `assert text_visible X`,
|
|
789
|
+
* REPLACING the wait: the emitted abAssert is itself a timed wait, so
|
|
790
|
+
* keeping both would wait twice.
|
|
791
|
+
* - `get count "<sel>"` + `element_visible` / `element_not_visible`
|
|
792
|
+
* → `assert <marker> <sel>` (the probe records nothing by itself).
|
|
793
|
+
* - any command + `url_contains:<substring>` → the command's own action (if
|
|
794
|
+
* it records one) followed by `assert url_contains <substring>`.
|
|
795
|
+
*/
|
|
796
|
+
function promoteMarkedAssert(abAction, marker) {
|
|
797
|
+
if (marker.startsWith("url_contains:")) {
|
|
798
|
+
const substring = marker.slice(13);
|
|
799
|
+
if (!substring) return null;
|
|
800
|
+
const assert = {
|
|
801
|
+
action: "assert",
|
|
802
|
+
assert: "url_contains",
|
|
803
|
+
value: substring
|
|
804
|
+
};
|
|
805
|
+
const base = abAction === null ? null : parseAbActionLine(abAction);
|
|
806
|
+
return base === null ? [assert] : [base, assert];
|
|
807
|
+
}
|
|
808
|
+
const parts = abAction === null ? [] : abAction.split("|");
|
|
809
|
+
if (marker === "1" || marker === "text_visible") {
|
|
810
|
+
if (parts[1] === "wait" && parts[2] === "--text" && parts[3]) return [{
|
|
811
|
+
action: "assert",
|
|
812
|
+
assert: "text_visible",
|
|
813
|
+
value: parts[3]
|
|
814
|
+
}];
|
|
815
|
+
return null;
|
|
816
|
+
}
|
|
817
|
+
if (marker === "element_visible" || marker === "element_not_visible") {
|
|
818
|
+
if (parts[1] === "get_count" && parts[2]) return [{
|
|
819
|
+
action: "assert",
|
|
820
|
+
assert: marker,
|
|
821
|
+
locator: css(parts[2])
|
|
822
|
+
}];
|
|
823
|
+
return null;
|
|
824
|
+
}
|
|
825
|
+
return null;
|
|
826
|
+
}
|
|
827
|
+
/**
|
|
828
|
+
* Common parser for the `find_*` wire family. `<extra>` carries `--name` for
|
|
829
|
+
* `role`, the integer index for `nth`, and is empty otherwise. We accept a
|
|
830
|
+
* literally empty `<extra>` (the LLM emits a placeholder `|` so the
|
|
831
|
+
* positional layout stays stable across locators).
|
|
832
|
+
*/
|
|
833
|
+
function parseFindAction(action, parts, hasFillValue) {
|
|
834
|
+
const locatorToken = parts[2];
|
|
835
|
+
const findValue = parts[3];
|
|
836
|
+
const extra = parts[4] ?? "";
|
|
837
|
+
const exact = (parts[5] ?? "") === "exact";
|
|
838
|
+
if (!locatorToken || !FIND_LOCATORS.includes(locatorToken) || !findValue) return null;
|
|
839
|
+
let locator;
|
|
840
|
+
let index;
|
|
841
|
+
if (locatorToken === "first" || locatorToken === "last") {
|
|
842
|
+
locator = css(findValue);
|
|
843
|
+
index = locatorToken;
|
|
844
|
+
} else if (locatorToken === "nth") {
|
|
845
|
+
const parsed = extra ? Number.parseInt(extra, 10) : NaN;
|
|
846
|
+
if (Number.isNaN(parsed)) return null;
|
|
847
|
+
locator = css(findValue);
|
|
848
|
+
index = parsed;
|
|
849
|
+
} else if (locatorToken === "role") locator = {
|
|
850
|
+
by: "role",
|
|
851
|
+
value: findValue,
|
|
852
|
+
...extra ? { name: extra } : {},
|
|
853
|
+
...exact ? { exact: true } : {}
|
|
854
|
+
};
|
|
855
|
+
else locator = {
|
|
856
|
+
by: locatorToken,
|
|
857
|
+
value: findValue,
|
|
858
|
+
...exact ? { exact: true } : {}
|
|
859
|
+
};
|
|
860
|
+
return {
|
|
861
|
+
action,
|
|
862
|
+
locator,
|
|
863
|
+
...index !== void 0 ? { index } : {},
|
|
864
|
+
...hasFillValue ? {
|
|
865
|
+
...parts[6] !== void 0 ? { value: parts[6] } : {},
|
|
866
|
+
...opt("label", parts[7])
|
|
867
|
+
} : opt("label", parts[6])
|
|
868
|
+
};
|
|
869
|
+
}
|
|
870
|
+
/** Include an optional string field only when it is non-empty. */
|
|
871
|
+
function opt(key, value) {
|
|
872
|
+
return value ? { [key]: value } : {};
|
|
873
|
+
}
|
|
874
|
+
//#endregion
|
|
875
|
+
//#region src/runtime/env-scrub.ts
|
|
876
|
+
/**
|
|
877
|
+
* Build a list of `[envValue, "${VAR}"]` pairs for every `${VAR}` reference
|
|
878
|
+
* mentioned in the spec OR in any of its expanded (block-inlined) steps.
|
|
879
|
+
* Used at trace time to scrub recorded Claude-text outputs so a value the
|
|
880
|
+
* spec author intentionally threaded through `process.env` is preserved as
|
|
881
|
+
* `${VAR}` in `ir.json` rather than baked in as the concrete
|
|
882
|
+
* trace-time value.
|
|
883
|
+
*
|
|
884
|
+
* Why we walk `spec.steps` AND `expanded`:
|
|
885
|
+
* - `spec.steps` carries the spec's own `instruction` / `expected` + each
|
|
886
|
+
* include's raw `params` (which may themselves be `${ENV}` refs).
|
|
887
|
+
* - `expanded` carries the inlined block-internal steps, whose
|
|
888
|
+
* `instruction` / `expected` may *also* contain `${ENV}` refs that
|
|
889
|
+
* don't go through include params.
|
|
890
|
+
*
|
|
891
|
+
* Each ref resolves against `overrides` first, then `process.env` —
|
|
892
|
+
* `overrides` carries values the invoker injects into the child process,
|
|
893
|
+
* which beat the parent env there. Only refs that resolve non-empty land in
|
|
894
|
+
* the map — scrubbing against an empty string would corrupt unrelated empty
|
|
895
|
+
* strings in the action stream; the rest are returned via `unresolved` so
|
|
896
|
+
* the caller can warn the user.
|
|
897
|
+
*
|
|
898
|
+
* Longer values sort first so a `${SHORT}` whose value is a substring of a
|
|
899
|
+
* `${LONG}` value doesn't clobber the longer one.
|
|
900
|
+
*
|
|
901
|
+
* `title` is deliberately NOT scanned — it never reaches the recorded action
|
|
902
|
+
* stream.
|
|
903
|
+
*/
|
|
904
|
+
function buildSpecEnvScrub(spec, expanded, overrides = {}) {
|
|
905
|
+
const refNames = /* @__PURE__ */ new Set();
|
|
906
|
+
for (const step of spec.steps) if (isIncludeStep(step)) for (const v of Object.values(step.params ?? {})) collect(v, refNames);
|
|
907
|
+
else collectStepRefs(step, refNames);
|
|
908
|
+
for (const step of expanded) collectStepRefs(step, refNames);
|
|
909
|
+
const map = [];
|
|
910
|
+
const unresolved = [];
|
|
911
|
+
for (const name of refNames) {
|
|
912
|
+
const value = overrides[name] ?? process.env[name];
|
|
913
|
+
if (typeof value === "string" && value.length > 0) map.push([value, "${" + name + "}"]);
|
|
914
|
+
else unresolved.push(name);
|
|
915
|
+
}
|
|
916
|
+
map.sort((a, b) => b[0].length - a[0].length);
|
|
917
|
+
return {
|
|
918
|
+
map,
|
|
919
|
+
unresolved
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
function collect(value, into) {
|
|
923
|
+
for (const name of iterEnvRefNames(value)) into.add(name);
|
|
924
|
+
}
|
|
925
|
+
function collectStepRefs(step, into) {
|
|
926
|
+
if (isJudgeBody(step)) {
|
|
927
|
+
collect(step.judgeByLlm, into);
|
|
928
|
+
if (step.from !== void 0) collect(step.from, into);
|
|
929
|
+
return;
|
|
930
|
+
}
|
|
931
|
+
collect(step.instruction, into);
|
|
932
|
+
collect(step.expected, into);
|
|
933
|
+
}
|
|
934
|
+
/** Shorter than this, a value is no secret and matches inside ordinary words. */
|
|
935
|
+
const MIN_PROSE_SCRUB_LENGTH = 4;
|
|
936
|
+
/** Long enough to clear the length bar, still ordinary prose / JSON. */
|
|
937
|
+
const COMMON_PROSE_VALUES = new Set([
|
|
938
|
+
"true",
|
|
939
|
+
"false",
|
|
940
|
+
"null",
|
|
941
|
+
"none",
|
|
942
|
+
"undefined"
|
|
943
|
+
]);
|
|
944
|
+
/**
|
|
945
|
+
* Scrub map for model output, built like {@link buildSpecEnvScrub} but
|
|
946
|
+
* without the values that read as ordinary text (`"1"`, `"true"`): prose
|
|
947
|
+
* runs to paragraphs, where replacing every occurrence of such a value
|
|
948
|
+
* costs more meaning than it protects. Record's own scrub keeps them for
|
|
949
|
+
* its single command lines; the live path reuses this one map for its Bash
|
|
950
|
+
* command log too, trading that short-value coverage for not building a
|
|
951
|
+
* second map.
|
|
952
|
+
*/
|
|
953
|
+
function buildProseEnvScrubMap(spec, expanded, overrides = {}) {
|
|
954
|
+
return buildSpecEnvScrub(spec, expanded, overrides).map.filter(([value]) => value.length >= MIN_PROSE_SCRUB_LENGTH && !COMMON_PROSE_VALUES.has(value.toLowerCase()));
|
|
955
|
+
}
|
|
956
|
+
/**
|
|
957
|
+
* Replace every occurrence of an env value with its `${VAR}` placeholder in
|
|
958
|
+
* `text`. **Caller invariant**: the map must be sorted longest-value-first
|
|
959
|
+
* so a shorter value doesn't shadow a longer one that contains it as a
|
|
960
|
+
* substring. `buildSpecEnvScrub` upholds this; hand-built maps should too.
|
|
961
|
+
*/
|
|
962
|
+
function scrubEnvValues(text, scrubMap) {
|
|
963
|
+
if (scrubMap.length === 0) return text;
|
|
964
|
+
let out = text;
|
|
965
|
+
for (const [value, placeholder] of scrubMap) if (out.includes(value)) out = out.replaceAll(value, placeholder);
|
|
966
|
+
return out;
|
|
967
|
+
}
|
|
968
|
+
//#endregion
|
|
969
|
+
//#region src/claude/native-binary.ts
|
|
970
|
+
const require = createRequire(import.meta.url);
|
|
971
|
+
/**
|
|
972
|
+
* The agent SDK launches Claude through a native `claude` binary that ships in
|
|
973
|
+
* a per-platform package (`@anthropic-ai/claude-agent-sdk-<platform>-<cpu>`),
|
|
974
|
+
* declared as an *optional* dependency of the SDK. Optional means a consumer's
|
|
975
|
+
* lockfile can omit it without any install-time error — and then every Claude
|
|
976
|
+
* call fails at runtime with a message that never reaches our logs. Resolving
|
|
977
|
+
* the package up front lets us say so once, in a line that names the fix.
|
|
978
|
+
*
|
|
979
|
+
* ccqa's own package.json repeats these packages in `optionalDependencies` for
|
|
980
|
+
* the same reason: a second declaration gives the resolver another chance to
|
|
981
|
+
* record them. Keep that list's version range in step with the SDK's.
|
|
982
|
+
*/
|
|
983
|
+
function nativeBinaryPackage(platform = process.platform, arch = process.arch, musl = isMusl(platform)) {
|
|
984
|
+
return `@anthropic-ai/claude-agent-sdk-${platform}-${arch === "arm64" ? "arm64" : "x64"}${platform === "linux" && musl ? "-musl" : ""}`;
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* musl builds (Alpine and friends) need their own binary. Node doesn't expose
|
|
988
|
+
* the libc flavour directly; the absence of `glibcVersionRuntime` in the
|
|
989
|
+
* process report is the usual proxy.
|
|
990
|
+
*/
|
|
991
|
+
function isMusl(platform) {
|
|
992
|
+
if (platform !== "linux") return false;
|
|
993
|
+
return !(process.report?.getReport?.())?.header?.glibcVersionRuntime;
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Name of the platform package this host needs, or `null` when it resolves.
|
|
997
|
+
* The per-platform packages have no `exports`, so the manifest is reachable.
|
|
998
|
+
*/
|
|
999
|
+
function missingNativeBinaryPackage(resolve = require.resolve) {
|
|
1000
|
+
const pkg = nativeBinaryPackage();
|
|
1001
|
+
try {
|
|
1002
|
+
resolve(`${pkg}/package.json`);
|
|
1003
|
+
return null;
|
|
1004
|
+
} catch {
|
|
1005
|
+
return pkg;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
/** Advice shown when the binary is absent — the package name plus how to fix it. */
|
|
1009
|
+
function missingNativeBinaryMessage(pkg) {
|
|
1010
|
+
return `${pkg} is not installed. The Claude Agent SDK needs it to start Claude on this platform, so every Claude-backed command (run in live mode, drift, diagnose) will fail. It ships as an optional dependency of the SDK, which a lockfile can drop silently: reinstall without omitting optional dependencies, or add it to your project as a direct dependency pinned to the same version as @anthropic-ai/claude-agent-sdk.`;
|
|
1011
|
+
}
|
|
1012
|
+
//#endregion
|
|
1013
|
+
//#region src/claude/cost-tally.ts
|
|
1014
|
+
/**
|
|
1015
|
+
* Sum every Claude invocation made inside a scope.
|
|
1016
|
+
*
|
|
1017
|
+
* A command like `record` calls Claude several times — the browser trace, the
|
|
1018
|
+
* codegen cleanup, one diagnosis per auto-fix retry — and the caller wants one
|
|
1019
|
+
* number for the whole command. Threading a cost out of each of those return
|
|
1020
|
+
* types would touch every layer in between, so the tally is scoped instead:
|
|
1021
|
+
* `invokeClaudeStreaming` adds to whichever scope is active, and nothing
|
|
1022
|
+
* between the two has to know.
|
|
1023
|
+
*
|
|
1024
|
+
* Scoped rather than module-global because commands run specs concurrently
|
|
1025
|
+
* (`drift` uses a pool). Two scopes must not fold into each other.
|
|
1026
|
+
*/
|
|
1027
|
+
const tallyStore = new AsyncLocalStorage();
|
|
1028
|
+
/** Record one invocation against the active scope. No-op outside one. */
|
|
1029
|
+
function tallyInvocation(cost) {
|
|
1030
|
+
tallyStore.getStore()?.push(cost);
|
|
1031
|
+
}
|
|
1032
|
+
/** Run `fn` with a fresh tally. Read the total from inside with `readCostTally`. */
|
|
1033
|
+
async function withCostTally(fn) {
|
|
1034
|
+
return tallyStore.run([], fn);
|
|
1035
|
+
}
|
|
1036
|
+
/**
|
|
1037
|
+
* The active scope's total so far, or null outside one.
|
|
1038
|
+
*
|
|
1039
|
+
* Read rather than pushed at the caller because commands end in
|
|
1040
|
+
* `process.exit`, which never reaches a `finally`; whoever opened the scope
|
|
1041
|
+
* reads the total on the way out (see `withCostReporting`).
|
|
1042
|
+
*
|
|
1043
|
+
* Fields stay `null` when no invocation reported them, so a caller can tell
|
|
1044
|
+
* "nothing was billed" from "the SDK didn't say" (mock runs, SDK errors).
|
|
1045
|
+
*/
|
|
1046
|
+
function readCostTally() {
|
|
1047
|
+
const collected = tallyStore.getStore();
|
|
1048
|
+
return collected === void 0 ? null : sum(collected);
|
|
1049
|
+
}
|
|
1050
|
+
function sum(costs) {
|
|
1051
|
+
const add = (pick) => {
|
|
1052
|
+
const present = costs.map(pick).filter((v) => v !== null);
|
|
1053
|
+
return present.length === 0 ? null : present.reduce((a, b) => a + b, 0);
|
|
1054
|
+
};
|
|
1055
|
+
return {
|
|
1056
|
+
totalCostUsd: add((c) => c.totalCostUsd),
|
|
1057
|
+
durationMs: add((c) => c.durationMs),
|
|
1058
|
+
durationApiMs: add((c) => c.durationApiMs),
|
|
1059
|
+
numTurns: add((c) => c.numTurns),
|
|
1060
|
+
inputTokens: add((c) => c.inputTokens),
|
|
1061
|
+
cacheCreationInputTokens: add((c) => c.cacheCreationInputTokens),
|
|
1062
|
+
cacheReadInputTokens: add((c) => c.cacheReadInputTokens),
|
|
1063
|
+
outputTokens: add((c) => c.outputTokens),
|
|
1064
|
+
models: [...new Set(costs.flatMap((c) => c.models))]
|
|
1065
|
+
};
|
|
1066
|
+
}
|
|
1067
|
+
//#endregion
|
|
1068
|
+
//#region src/claude/invoke.ts
|
|
1069
|
+
/** The built-in tools an allow-list names: `Bash(*)` is `Bash`; `mcp__*` are not built-ins. */
|
|
1070
|
+
function builtinToolNames(allowedTools) {
|
|
1071
|
+
const names = allowedTools.map((entry) => entry.replace(/\(.*\)$/, "")).filter((name) => !name.startsWith("mcp__"));
|
|
1072
|
+
return [...new Set(names)];
|
|
1073
|
+
}
|
|
1074
|
+
function resolveModel(explicit) {
|
|
1075
|
+
if (explicit) return explicit;
|
|
1076
|
+
const envModel = process.env["CCQA_MODEL"];
|
|
1077
|
+
return envModel && envModel.length > 0 ? envModel : void 0;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* When both credentials are present the OAuth token wins and the API key is
|
|
1081
|
+
* dropped. Left to the CLI the API key would win, which makes "switch a CI
|
|
1082
|
+
* job to the subscription token" require unwiring the key everywhere; with
|
|
1083
|
+
* this rule, adding the one variable is the whole switch, and removing it is
|
|
1084
|
+
* the whole rollback. The one place the rule lives — both the resolved view
|
|
1085
|
+
* and the env the SDK receives apply it through here.
|
|
1086
|
+
*/
|
|
1087
|
+
function preferOauthToken(env) {
|
|
1088
|
+
if (env["CLAUDE_CODE_OAUTH_TOKEN"]) delete env["ANTHROPIC_API_KEY"];
|
|
1089
|
+
}
|
|
1090
|
+
/**
|
|
1091
|
+
* Drop endpoint variables that are present but empty, so an empty value never
|
|
1092
|
+
* reaches the Claude Code process as an override. "Set to nothing" is how a
|
|
1093
|
+
* caller that cannot omit the key says "use the default" — a CI job wiring
|
|
1094
|
+
* `ANTHROPIC_BASE_URL` from an unset repository variable, most of all.
|
|
1095
|
+
*/
|
|
1096
|
+
function withoutEmptyEndpointVars(env) {
|
|
1097
|
+
const out = { ...env };
|
|
1098
|
+
for (const key of ENDPOINT_ENV_KEYS) if (out[key] === "") delete out[key];
|
|
1099
|
+
return out;
|
|
1100
|
+
}
|
|
1101
|
+
/**
|
|
1102
|
+
* The environment actually handed to the Claude Code process: the full process
|
|
1103
|
+
* environment with the caller's overrides on top, empty endpoint variables
|
|
1104
|
+
* dropped, and — when both credentials survive the merge — the API key removed
|
|
1105
|
+
* so the OAuth token wins.
|
|
1106
|
+
*
|
|
1107
|
+
* That removal MUST happen on the env the SDK receives, not only on the
|
|
1108
|
+
* resolved view: left to the CLI the API key would win, silently moving every
|
|
1109
|
+
* call from the subscription to metered billing when a CI job wires both
|
|
1110
|
+
* (which is exactly what happened before this function existed).
|
|
1111
|
+
*/
|
|
1112
|
+
function buildInvocationEnv(env) {
|
|
1113
|
+
const merged = withoutEmptyEndpointVars({
|
|
1114
|
+
...process.env,
|
|
1115
|
+
...env
|
|
1116
|
+
});
|
|
1117
|
+
preferOauthToken(merged);
|
|
1118
|
+
merged["CLAUDE_CODE_DISABLE_AUTO_MEMORY"] = "1";
|
|
1119
|
+
return merged;
|
|
1120
|
+
}
|
|
1121
|
+
let nativeBinaryWarned = false;
|
|
1122
|
+
/**
|
|
1123
|
+
* Warn once per process when the SDK's per-platform native binary is missing:
|
|
1124
|
+
* every Claude call is about to fail, and the opaque per-step errors alone are
|
|
1125
|
+
* expensive to trace back to a lockfile that dropped an optional dependency.
|
|
1126
|
+
*/
|
|
1127
|
+
function warnOnceIfNativeBinaryMissing() {
|
|
1128
|
+
if (nativeBinaryWarned) return;
|
|
1129
|
+
nativeBinaryWarned = true;
|
|
1130
|
+
const missing = missingNativeBinaryPackage();
|
|
1131
|
+
if (missing) warn(missingNativeBinaryMessage(missing));
|
|
1132
|
+
}
|
|
1133
|
+
/** Whole minutes read best, but the ceiling is set in ms and may be seconds. */
|
|
1134
|
+
function formatDuration(ms) {
|
|
1135
|
+
if (ms < 6e4 || ms % 6e4 !== 0) return `${Math.round(ms / 1e3)}s`;
|
|
1136
|
+
const minutes = ms / 6e4;
|
|
1137
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"}`;
|
|
1138
|
+
}
|
|
1139
|
+
async function invokeClaudeStreaming(options, onEvent) {
|
|
1140
|
+
const { prompt, systemPrompt, allowedTools, disableThinking = false, mcpServers, maxTurns, timeoutMs, env, model, cwd, onAbAction, onAbActionFailed, silenceBashLog = false, envScrubMap = [], relaxAbConstraints = false } = options;
|
|
1141
|
+
const resolvedModel = resolveModel(model);
|
|
1142
|
+
const mergedEnv = buildInvocationEnv(env);
|
|
1143
|
+
const abortController = new AbortController();
|
|
1144
|
+
let lastAbToolUseId = null;
|
|
1145
|
+
const claimAbToolUse = (toolUseId) => {
|
|
1146
|
+
if (toolUseId !== lastAbToolUseId) return false;
|
|
1147
|
+
lastAbToolUseId = null;
|
|
1148
|
+
return true;
|
|
1149
|
+
};
|
|
1150
|
+
const sdkOptions = {
|
|
1151
|
+
systemPrompt,
|
|
1152
|
+
maxTurns,
|
|
1153
|
+
allowedTools,
|
|
1154
|
+
tools: builtinToolNames(allowedTools),
|
|
1155
|
+
strictMcpConfig: true,
|
|
1156
|
+
settingSources: [],
|
|
1157
|
+
permissionMode: "bypassPermissions",
|
|
1158
|
+
allowDangerouslySkipPermissions: true,
|
|
1159
|
+
abortController,
|
|
1160
|
+
...resolvedModel ? { model: resolvedModel } : {},
|
|
1161
|
+
...cwd ? { cwd } : {},
|
|
1162
|
+
env: mergedEnv,
|
|
1163
|
+
...mcpServers ? { mcpServers } : {},
|
|
1164
|
+
...disableThinking ? { thinking: { type: "disabled" } } : {},
|
|
1165
|
+
hooks: onAbAction || onAbActionFailed ? {
|
|
1166
|
+
PreToolUse: [{ hooks: [async (input) => {
|
|
1167
|
+
if (input.hook_event_name !== "PreToolUse") return {};
|
|
1168
|
+
if (input.tool_name !== "Bash") return {};
|
|
1169
|
+
const cmd = input.tool_input?.["command"];
|
|
1170
|
+
if (typeof cmd !== "string") return {};
|
|
1171
|
+
if (!relaxAbConstraints) {
|
|
1172
|
+
if (isBlockedAbSubcommand(cmd)) return {
|
|
1173
|
+
decision: "block",
|
|
1174
|
+
reason: "This agent-browser subcommand is not allowed because it cannot be recorded as a structured test action. Use only the standard commands: click, check, fill, select, hover, press, wait, find (with role/text/label/placeholder/alt/title/testid/first/last/nth). Take a fresh snapshot to find the correct selector."
|
|
1175
|
+
};
|
|
1176
|
+
if (hasRefSelector(cmd)) return {
|
|
1177
|
+
decision: "block",
|
|
1178
|
+
reason: "@ref selectors (like @e14) are session-specific and change every run. They cannot be used in generated tests. Use one of the allowed selector formats instead: [aria-label='...'], text=..., [placeholder='...'], or [type='password']. Take a fresh snapshot and find the element's aria-label or visible text. If an allowed selector already clicks the element but nothing happens, the element is clipped by an inner scroll container: `scrollintoview` it (addressed by a CSS selector, not `text=`) and click again — a @ref would not have fixed that either."
|
|
1179
|
+
};
|
|
1180
|
+
const bareTag = findPositionalBareTag(cmd);
|
|
1181
|
+
if (bareTag !== null) return {
|
|
1182
|
+
decision: "block",
|
|
1183
|
+
reason: `\`find ${bareTag.locator}\` with a bare tag selector (\`${bareTag.selector}\`) is rejected: it matches every <${bareTag.selector}> on the page and is non-deterministic on replay. Pass a specific attribute selector instead, e.g. \`find ${bareTag.locator} "[aria-label='...']" ${bareTag.action}\` or \`find ${bareTag.locator} "[data-qa='...']" ${bareTag.action}\`. Take a fresh snapshot to find the right attribute.`
|
|
1184
|
+
};
|
|
1185
|
+
if (hasMultipleAbInvocations(cmd)) return {
|
|
1186
|
+
decision: "block",
|
|
1187
|
+
reason: "Run each `agent-browser` call as its own Bash command. Chaining multiple invocations with &&, ;, |, or || prevents ccqa from recording them as discrete steps and lets failed attempts leak into the trace. Issue one Bash tool call per agent-browser command."
|
|
1188
|
+
};
|
|
1189
|
+
if (hasErrorSuppression(cmd)) return {
|
|
1190
|
+
decision: "block",
|
|
1191
|
+
reason: "Do not suppress errors on `agent-browser` commands. Remove `|| true`, `|| :`, `2>/dev/null`, `; true`, and similar redirects so ccqa can detect failures and roll back unsuccessful attempts. Run the command standalone and let it surface its exit code."
|
|
1192
|
+
};
|
|
1193
|
+
}
|
|
1194
|
+
const assertMarker = relaxAbConstraints ? null : extractCcqaAssertFromBashCommand(cmd);
|
|
1195
|
+
const ab = relaxAbConstraints ? null : extractAbActionFromBashCommand(cmd) ?? (assertMarker !== null ? extractObservationAbAction(cmd) : null);
|
|
1196
|
+
if ((ab !== null || assertMarker !== null) && onAbAction) {
|
|
1197
|
+
lastAbToolUseId = input.tool_use_id;
|
|
1198
|
+
const stepId = extractCcqaStepFromBashCommand(cmd);
|
|
1199
|
+
onAbAction({
|
|
1200
|
+
...ab !== null ? { abAction: ab } : {},
|
|
1201
|
+
...stepId ? { stepId } : {},
|
|
1202
|
+
...assertMarker !== null ? { assertMarker } : {}
|
|
1203
|
+
});
|
|
1204
|
+
} else lastAbToolUseId = null;
|
|
1205
|
+
return {};
|
|
1206
|
+
}] }],
|
|
1207
|
+
PostToolUse: [{ hooks: [async (input) => {
|
|
1208
|
+
if (input.hook_event_name !== "PostToolUse") return {};
|
|
1209
|
+
if (input.tool_name !== "Bash") return {};
|
|
1210
|
+
if (!isBashToolResponseError(input.tool_response)) return {};
|
|
1211
|
+
if (claimAbToolUse(input.tool_use_id) && onAbActionFailed) onAbActionFailed();
|
|
1212
|
+
return {};
|
|
1213
|
+
}] }],
|
|
1214
|
+
PostToolUseFailure: [{ hooks: [async (input) => {
|
|
1215
|
+
if (input.hook_event_name !== "PostToolUseFailure") return {};
|
|
1216
|
+
if (input.tool_name !== "Bash") return {};
|
|
1217
|
+
if (claimAbToolUse(input.tool_use_id) && onAbActionFailed) onAbActionFailed();
|
|
1218
|
+
return {};
|
|
1219
|
+
}] }]
|
|
1220
|
+
} : void 0
|
|
1221
|
+
};
|
|
1222
|
+
warnOnceIfNativeBinaryMissing();
|
|
1223
|
+
const capTimer = timeoutMs === void 0 ? null : setTimeout(() => abortController.abort(), timeoutMs);
|
|
1224
|
+
capTimer?.unref?.();
|
|
1225
|
+
let result = "";
|
|
1226
|
+
let answered = false;
|
|
1227
|
+
let isError = false;
|
|
1228
|
+
let errorDetail = null;
|
|
1229
|
+
let cost = {
|
|
1230
|
+
totalCostUsd: null,
|
|
1231
|
+
durationMs: null,
|
|
1232
|
+
durationApiMs: null,
|
|
1233
|
+
numTurns: null,
|
|
1234
|
+
inputTokens: null,
|
|
1235
|
+
cacheCreationInputTokens: null,
|
|
1236
|
+
cacheReadInputTokens: null,
|
|
1237
|
+
outputTokens: null,
|
|
1238
|
+
models: []
|
|
1239
|
+
};
|
|
1240
|
+
const q = await buildMessageStream(prompt, sdkOptions);
|
|
1241
|
+
try {
|
|
1242
|
+
for await (const msg of q) {
|
|
1243
|
+
onEvent(msg);
|
|
1244
|
+
if (msg.type === "assistant" && !silenceBashLog) {
|
|
1245
|
+
for (const block of msg.message.content ?? []) if (block.type === "tool_use" && block.name === "Bash") {
|
|
1246
|
+
const cmd = block.input?.["command"];
|
|
1247
|
+
if (typeof cmd === "string") bash(scrubEnvValues(cmd, envScrubMap));
|
|
1248
|
+
}
|
|
1249
|
+
}
|
|
1250
|
+
if (msg.type === "result") {
|
|
1251
|
+
answered = true;
|
|
1252
|
+
isError = msg.is_error ?? false;
|
|
1253
|
+
if (msg.subtype === "success") result = msg.result;
|
|
1254
|
+
else {
|
|
1255
|
+
result = "";
|
|
1256
|
+
errorDetail = `SDK reported ${msg.subtype}`;
|
|
1257
|
+
}
|
|
1258
|
+
cost = extractInvocationCost(msg);
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
} catch (err) {
|
|
1262
|
+
isError = true;
|
|
1263
|
+
errorDetail = err instanceof Error ? err.message : String(err);
|
|
1264
|
+
if (!result) result = errorDetail;
|
|
1265
|
+
} finally {
|
|
1266
|
+
if (capTimer) clearTimeout(capTimer);
|
|
1267
|
+
}
|
|
1268
|
+
if (abortController.signal.aborted && timeoutMs !== void 0 && !answered) {
|
|
1269
|
+
isError = true;
|
|
1270
|
+
errorDetail = `stopped after ${formatDuration(timeoutMs)} (host time limit)`;
|
|
1271
|
+
result = errorDetail;
|
|
1272
|
+
}
|
|
1273
|
+
tallyInvocation(cost);
|
|
1274
|
+
return {
|
|
1275
|
+
result,
|
|
1276
|
+
isError,
|
|
1277
|
+
errorDetail,
|
|
1278
|
+
cost
|
|
1279
|
+
};
|
|
1280
|
+
}
|
|
1281
|
+
/**
|
|
1282
|
+
* Pull the cost / usage / turn / duration fields off the SDK `result` message.
|
|
1283
|
+
* The SDK's success and error result shapes share these fields, so we read
|
|
1284
|
+
* them defensively as `unknown` and coerce — newer SDK versions may rename a
|
|
1285
|
+
* field without breaking our extraction.
|
|
1286
|
+
*/
|
|
1287
|
+
function extractInvocationCost(msg) {
|
|
1288
|
+
const m = msg;
|
|
1289
|
+
const usage = m["usage"];
|
|
1290
|
+
const modelUsage = m["modelUsage"];
|
|
1291
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : null;
|
|
1292
|
+
const models = modelUsage && typeof modelUsage === "object" ? Object.keys(modelUsage) : [];
|
|
1293
|
+
return {
|
|
1294
|
+
totalCostUsd: pricedForClaude(models) ? num(m["total_cost_usd"]) : null,
|
|
1295
|
+
durationMs: num(m["duration_ms"]),
|
|
1296
|
+
durationApiMs: num(m["duration_api_ms"]),
|
|
1297
|
+
numTurns: num(m["num_turns"]),
|
|
1298
|
+
inputTokens: num(usage?.["input_tokens"]),
|
|
1299
|
+
cacheCreationInputTokens: num(usage?.["cache_creation_input_tokens"]),
|
|
1300
|
+
cacheReadInputTokens: num(usage?.["cache_read_input_tokens"]),
|
|
1301
|
+
outputTokens: num(usage?.["output_tokens"]),
|
|
1302
|
+
models
|
|
1303
|
+
};
|
|
1304
|
+
}
|
|
1305
|
+
/**
|
|
1306
|
+
* The SDK prices an unknown model id at a default Claude rate rather than
|
|
1307
|
+
* returning null, so a self-hosted model would report dollars nobody is billed.
|
|
1308
|
+
*/
|
|
1309
|
+
function pricedForClaude(models) {
|
|
1310
|
+
return models.every((id) => /claude/i.test(id));
|
|
1311
|
+
}
|
|
1312
|
+
const BLOCKED_AB_SUBCOMMANDS = new Set([
|
|
1313
|
+
"eval",
|
|
1314
|
+
"js",
|
|
1315
|
+
"label",
|
|
1316
|
+
"textbox"
|
|
1317
|
+
]);
|
|
1318
|
+
/**
|
|
1319
|
+
* Shell-aware tokenizer: splits a command string into tokens respecting single/double quotes.
|
|
1320
|
+
* e.g. `click "[role='dialog'] button:last-child"` → ["click", "[role='dialog'] button:last-child"]
|
|
1321
|
+
*/
|
|
1322
|
+
function shellTokenize(s) {
|
|
1323
|
+
const tokens = [];
|
|
1324
|
+
let cur = "";
|
|
1325
|
+
let quote = null;
|
|
1326
|
+
for (let i = 0; i < s.length; i++) {
|
|
1327
|
+
const ch = s[i];
|
|
1328
|
+
if (quote) if (ch === quote) quote = null;
|
|
1329
|
+
else cur += ch;
|
|
1330
|
+
else if (ch === "\"" || ch === "'") quote = ch;
|
|
1331
|
+
else if (ch === " " || ch === " ") {
|
|
1332
|
+
if (cur) {
|
|
1333
|
+
tokens.push(cur);
|
|
1334
|
+
cur = "";
|
|
1335
|
+
}
|
|
1336
|
+
} else cur += ch;
|
|
1337
|
+
}
|
|
1338
|
+
if (cur) tokens.push(cur);
|
|
1339
|
+
return tokens;
|
|
1340
|
+
}
|
|
1341
|
+
/** Extracts the subcommand from an `agent-browser [flags] <subcommand> [args...]` command string. */
|
|
1342
|
+
function extractAbSubcommand(cmd) {
|
|
1343
|
+
const abIdx = cmd.indexOf("agent-browser");
|
|
1344
|
+
if (abIdx === -1) return null;
|
|
1345
|
+
const parts = shellTokenize(cmd.slice(abIdx + 13).trim());
|
|
1346
|
+
let i = 0;
|
|
1347
|
+
while (i < parts.length && parts[i].startsWith("-")) i += 2;
|
|
1348
|
+
return parts[i] ?? null;
|
|
1349
|
+
}
|
|
1350
|
+
/** Returns true if the agent-browser subcommand is blocked (eval/js/find/etc). */
|
|
1351
|
+
function isBlockedAbSubcommand(cmd) {
|
|
1352
|
+
const sub = extractAbSubcommand(cmd);
|
|
1353
|
+
return sub !== null && BLOCKED_AB_SUBCOMMANDS.has(sub);
|
|
1354
|
+
}
|
|
1355
|
+
/**
|
|
1356
|
+
* Detects "the Bash tool returned an error" from a SDK PostToolUse hook's
|
|
1357
|
+
* `tool_response`. The SDK can shape this two ways depending on how Claude
|
|
1358
|
+
* Code reports Bash failures:
|
|
1359
|
+
*
|
|
1360
|
+
* - `{ is_error: true, ... }` — the canonical Bash failure shape
|
|
1361
|
+
* - `{ output, exitCode, killed?, ... }` — the BashOutput shape; treat
|
|
1362
|
+
* non-zero exit / kill as error
|
|
1363
|
+
*
|
|
1364
|
+
* We accept either. Anything else (including missing fields) is treated as a
|
|
1365
|
+
* successful response so we never roll back over an unrelated tool call.
|
|
1366
|
+
*/
|
|
1367
|
+
function isBashToolResponseError(tool_response) {
|
|
1368
|
+
if (tool_response === null || typeof tool_response !== "object") return false;
|
|
1369
|
+
const r = tool_response;
|
|
1370
|
+
if (r["is_error"] === true) return true;
|
|
1371
|
+
if (typeof r["exitCode"] === "number" && r["exitCode"] !== 0) return true;
|
|
1372
|
+
if (r["killed"] === true) return true;
|
|
1373
|
+
return false;
|
|
1374
|
+
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Detect `agent-browser ... find first|last|nth <bare-tag> <action>`. A bare
|
|
1377
|
+
* tag inside a *positional* finder matches every element of that tag on the
|
|
1378
|
+
* page, so "the last button" picks a different element whenever the page
|
|
1379
|
+
* shape shifts — recorded tests built on top are flaky by construction. The
|
|
1380
|
+
* check is narrow on purpose: `find role button --name X` is fine because
|
|
1381
|
+
* role + accessible name stays stable.
|
|
1382
|
+
*/
|
|
1383
|
+
function findPositionalBareTag(cmd) {
|
|
1384
|
+
if (extractAbSubcommand(cmd) !== "find") return null;
|
|
1385
|
+
const abIdx = cmd.indexOf("agent-browser");
|
|
1386
|
+
const parts = shellTokenize(cmd.slice(abIdx + 13).trim());
|
|
1387
|
+
let i = 0;
|
|
1388
|
+
while (i < parts.length && parts[i].startsWith("-")) i += 2;
|
|
1389
|
+
const locator = parts[i + 1];
|
|
1390
|
+
if (locator !== "first" && locator !== "last" && locator !== "nth") return null;
|
|
1391
|
+
const innerIdx = locator === "nth" ? i + 3 : i + 2;
|
|
1392
|
+
const inner = parts[innerIdx];
|
|
1393
|
+
const action = parts[innerIdx + 1] ?? "";
|
|
1394
|
+
if (!inner) return null;
|
|
1395
|
+
if (!/^[a-zA-Z][a-zA-Z0-9]*$/.test(inner)) return null;
|
|
1396
|
+
return {
|
|
1397
|
+
locator,
|
|
1398
|
+
selector: inner,
|
|
1399
|
+
action
|
|
1400
|
+
};
|
|
1401
|
+
}
|
|
1402
|
+
/** Returns true if any argument to an agent-browser command uses a @ref selector (e.g. @e14). */
|
|
1403
|
+
function hasRefSelector(cmd) {
|
|
1404
|
+
const abIdx = cmd.indexOf("agent-browser");
|
|
1405
|
+
if (abIdx === -1) return false;
|
|
1406
|
+
const parts = shellTokenize(cmd.slice(abIdx + 13).trim());
|
|
1407
|
+
let i = 0;
|
|
1408
|
+
while (i < parts.length && parts[i].startsWith("-")) i += 2;
|
|
1409
|
+
i++;
|
|
1410
|
+
for (; i < parts.length; i++) if (/^@/.test(parts[i])) return true;
|
|
1411
|
+
return false;
|
|
1412
|
+
}
|
|
1413
|
+
/**
|
|
1414
|
+
* Split `cmd` into shell statements at unquoted separators (`;`, `|`, `&`,
|
|
1415
|
+
* newline; consecutive separator chars like `&&` count once). String
|
|
1416
|
+
* literals are honoured so `fill "a;b"` stays a single statement. This is a
|
|
1417
|
+
* heuristic split (no subshell grammar), shared by the compound-invocation
|
|
1418
|
+
* guard and the CCQA_STEP prefix extraction so both agree on what "one
|
|
1419
|
+
* command" means.
|
|
1420
|
+
*/
|
|
1421
|
+
function splitShellStatements(cmd) {
|
|
1422
|
+
const statements = [];
|
|
1423
|
+
let start = 0;
|
|
1424
|
+
let quote = null;
|
|
1425
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
1426
|
+
const ch = cmd[i];
|
|
1427
|
+
if (quote) {
|
|
1428
|
+
if (ch === quote) quote = null;
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
if (ch === "\"" || ch === "'" || ch === "`") {
|
|
1432
|
+
quote = ch;
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
if (ch === ";" || ch === "|" || ch === "&" || ch === "\n") {
|
|
1436
|
+
statements.push(cmd.slice(start, i));
|
|
1437
|
+
while (i + 1 < cmd.length && (cmd[i + 1] === "|" || cmd[i + 1] === "&" || cmd[i + 1] === ";" || cmd[i + 1] === "\n")) i++;
|
|
1438
|
+
start = i + 1;
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
statements.push(cmd.slice(start));
|
|
1442
|
+
return statements;
|
|
1443
|
+
}
|
|
1444
|
+
/** One leading `KEY=value` env assignment; value may be single/double-quoted. */
|
|
1445
|
+
const ENV_ASSIGN_HEAD_RE = /^([A-Za-z_][A-Za-z0-9_]*)=(?:"([^"]*)"|'([^']*)'|(\S*))(?:\s+|$)/;
|
|
1446
|
+
/**
|
|
1447
|
+
* Split a statement into its leading `KEY=value` env assignments and the
|
|
1448
|
+
* command they prefix. An assignment whose value is a command substitution
|
|
1449
|
+
* (`$(...)` / backticks) is NOT treated as a prefix — `result=$(agent-browser
|
|
1450
|
+
* ... snapshot)` is an assignment statement, not an agent-browser invocation,
|
|
1451
|
+
* and must stay invisible to the guards below.
|
|
1452
|
+
*/
|
|
1453
|
+
function splitLeadingEnvAssignments(statement) {
|
|
1454
|
+
const env = /* @__PURE__ */ new Map();
|
|
1455
|
+
let command = statement.trimStart();
|
|
1456
|
+
for (;;) {
|
|
1457
|
+
const m = ENV_ASSIGN_HEAD_RE.exec(command);
|
|
1458
|
+
if (!m) break;
|
|
1459
|
+
const value = m[2] ?? m[3] ?? m[4] ?? "";
|
|
1460
|
+
if (value.startsWith("$(") || value.startsWith("`")) return {
|
|
1461
|
+
env: /* @__PURE__ */ new Map(),
|
|
1462
|
+
command: statement.trimStart()
|
|
1463
|
+
};
|
|
1464
|
+
env.set(m[1], value);
|
|
1465
|
+
command = command.slice(m[0].length);
|
|
1466
|
+
}
|
|
1467
|
+
return {
|
|
1468
|
+
env,
|
|
1469
|
+
command
|
|
1470
|
+
};
|
|
1471
|
+
}
|
|
1472
|
+
/** True when `command` starts with `agent-browser` as the command word. */
|
|
1473
|
+
function isAgentBrowserHead(command) {
|
|
1474
|
+
if (!command.startsWith("agent-browser")) return false;
|
|
1475
|
+
const after = command[13];
|
|
1476
|
+
return after === void 0 || !/[A-Za-z0-9_\-]/.test(after);
|
|
1477
|
+
}
|
|
1478
|
+
/** Step ids passed via `CCQA_STEP=<step-id>` must be a plain slug. */
|
|
1479
|
+
const STEP_SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9_-]*$/;
|
|
1480
|
+
/**
|
|
1481
|
+
* Extract the step id from the `CCQA_STEP=<step-id>` env prefix on the
|
|
1482
|
+
* agent-browser invocation in `cmd` (e.g. `CCQA_STEP=step-03 agent-browser
|
|
1483
|
+
* --session s click "text=Submit"`). The prefix may sit anywhere in the
|
|
1484
|
+
* leading env-assignment run (`FOO=x CCQA_STEP=step-02 agent-browser ...`),
|
|
1485
|
+
* and the invocation may be a later statement of a compound command
|
|
1486
|
+
* (`cd app && CCQA_STEP=step-01 agent-browser ...`). Returns null when the
|
|
1487
|
+
* prefix is absent or its value is not a valid slug — callers then fall back
|
|
1488
|
+
* to the STEP_START text protocol.
|
|
1489
|
+
*/
|
|
1490
|
+
function extractCcqaStepFromBashCommand(cmd) {
|
|
1491
|
+
for (const statement of splitShellStatements(cmd)) {
|
|
1492
|
+
const { env, command } = splitLeadingEnvAssignments(statement);
|
|
1493
|
+
if (!isAgentBrowserHead(command)) continue;
|
|
1494
|
+
const value = env.get("CCQA_STEP");
|
|
1495
|
+
return value !== void 0 && STEP_SLUG_RE.test(value) ? value : null;
|
|
1496
|
+
}
|
|
1497
|
+
return null;
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Extract the assert marker from the `CCQA_ASSERT=<marker>` env prefix on
|
|
1501
|
+
* the agent-browser invocation in `cmd`, e.g. `CCQA_STEP=step-03
|
|
1502
|
+
* CCQA_ASSERT=1 agent-browser --session s wait --text "Submitted" --timeout
|
|
1503
|
+
* 3000`. The marker declares that the command verifies a step signal;
|
|
1504
|
+
* `promoteMarkedAssert` maps it onto recorded assert action(s). Returns the
|
|
1505
|
+
* raw value — semantic validation (which markers combine with which
|
|
1506
|
+
* commands) happens at promotion time so mismatches surface as warnings
|
|
1507
|
+
* instead of being silently dropped here. Returns null when the prefix is
|
|
1508
|
+
* absent or empty.
|
|
1509
|
+
*/
|
|
1510
|
+
function extractCcqaAssertFromBashCommand(cmd) {
|
|
1511
|
+
for (const statement of splitShellStatements(cmd)) {
|
|
1512
|
+
const { env, command } = splitLeadingEnvAssignments(statement);
|
|
1513
|
+
if (!isAgentBrowserHead(command)) continue;
|
|
1514
|
+
const value = env.get("CCQA_ASSERT");
|
|
1515
|
+
return value !== void 0 && value.length > 0 ? value : null;
|
|
1516
|
+
}
|
|
1517
|
+
return null;
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Returns true when `cmd` contains more than one `agent-browser` invocation
|
|
1521
|
+
* chained together via shell operators (`&&`, `||`, `;`, `|`, newline). The
|
|
1522
|
+
* PreToolUse hook only records ONE AB_ACTION per Bash call, so chained
|
|
1523
|
+
* invocations would silently drop every intermediate failure — turning
|
|
1524
|
+
* "I tried four selectors before one worked" into a clean-looking trace
|
|
1525
|
+
* with five orphaned actions that later fail at replay.
|
|
1526
|
+
*
|
|
1527
|
+
* Counts statements whose command word is `agent-browser`, skipping any
|
|
1528
|
+
* leading env assignments (the trace protocol prefixes every invocation
|
|
1529
|
+
* with `CCQA_STEP=<step-id>`). String literals are honoured so
|
|
1530
|
+
* `agent-browser fill 'agent-browser'` doesn't false-fire.
|
|
1531
|
+
*/
|
|
1532
|
+
function hasMultipleAbInvocations(cmd) {
|
|
1533
|
+
let count = 0;
|
|
1534
|
+
for (const statement of splitShellStatements(cmd)) {
|
|
1535
|
+
if (!isAgentBrowserHead(splitLeadingEnvAssignments(statement).command)) continue;
|
|
1536
|
+
count++;
|
|
1537
|
+
if (count > 1) return true;
|
|
1538
|
+
}
|
|
1539
|
+
return false;
|
|
1540
|
+
}
|
|
1541
|
+
/**
|
|
1542
|
+
* Returns true when an `agent-browser` command in `cmd` has its exit
|
|
1543
|
+
* status hidden by a shell decorator that would prevent ccqa from rolling
|
|
1544
|
+
* back a failed attempt:
|
|
1545
|
+
*
|
|
1546
|
+
* - trailing `|| true` / `|| :` / `; true` (force exit 0)
|
|
1547
|
+
* - `2>/dev/null` and friends (drop stderr, sometimes paired with `|| true`)
|
|
1548
|
+
*
|
|
1549
|
+
* The agent-browser command itself returns exit 1 on selector miss, so
|
|
1550
|
+
* once one of these is present the PostToolUse hook sees `is_error=false`
|
|
1551
|
+
* and the bad attempt sneaks into ir.json.
|
|
1552
|
+
*/
|
|
1553
|
+
function hasErrorSuppression(cmd) {
|
|
1554
|
+
if (cmd.indexOf("agent-browser") === -1) return false;
|
|
1555
|
+
if (/\|\|\s*(true|:|\s*$|#)/.test(cmd)) return true;
|
|
1556
|
+
if (/;\s*(true|:)\b/.test(cmd)) return true;
|
|
1557
|
+
if (/2\s*>\s*\/dev\/null/.test(cmd)) return true;
|
|
1558
|
+
if (/&\s*>\s*\/dev\/null/.test(cmd)) return true;
|
|
1559
|
+
return false;
|
|
1560
|
+
}
|
|
1561
|
+
/**
|
|
1562
|
+
* Parse an `agent-browser --session <name> <cmd> [args...]` bash command
|
|
1563
|
+
* and return the corresponding AB_ACTION line, or null if not an agent-browser call.
|
|
1564
|
+
*/
|
|
1565
|
+
function extractAbActionFromBashCommand(cmd) {
|
|
1566
|
+
const subCmd = extractAbSubcommand(cmd);
|
|
1567
|
+
if (!subCmd) return null;
|
|
1568
|
+
const abIdx = cmd.indexOf("agent-browser");
|
|
1569
|
+
const parts = shellTokenize(cmd.slice(abIdx + 13).trim()).filter((t) => !/^(2?>|[|&>])/.test(t));
|
|
1570
|
+
let i = 0;
|
|
1571
|
+
while (i < parts.length && parts[i].startsWith("-")) i += 2;
|
|
1572
|
+
const args = parts.slice(i + 1);
|
|
1573
|
+
switch (subCmd) {
|
|
1574
|
+
case "cookies":
|
|
1575
|
+
if (args[0] === "clear") return "AB_ACTION|cookies_clear";
|
|
1576
|
+
return null;
|
|
1577
|
+
case "open": return `AB_ACTION|open|${args[0] ?? ""}`;
|
|
1578
|
+
case "press": return `AB_ACTION|press|${args[0] ?? ""}`;
|
|
1579
|
+
case "scroll": return `AB_ACTION|scroll|${args.join("|")}`;
|
|
1580
|
+
case "click":
|
|
1581
|
+
case "dblclick":
|
|
1582
|
+
case "check":
|
|
1583
|
+
case "uncheck":
|
|
1584
|
+
case "hover":
|
|
1585
|
+
case "wait": return `AB_ACTION|${subCmd}|${args[0] ?? ""}|${args[1] ?? ""}`;
|
|
1586
|
+
case "fill":
|
|
1587
|
+
case "type":
|
|
1588
|
+
case "select": return `AB_ACTION|${subCmd}|${args[0] ?? ""}|${args[1] ?? ""}|${args[2] ?? ""}`;
|
|
1589
|
+
case "drag": return `AB_ACTION|drag|${args[0] ?? ""}|${args[1] ?? ""}|${args[2] ?? ""}`;
|
|
1590
|
+
case "upload": {
|
|
1591
|
+
const sel = args[0] ?? "";
|
|
1592
|
+
const files = args.slice(1);
|
|
1593
|
+
if (!sel || files.length === 0) return null;
|
|
1594
|
+
return `AB_ACTION|upload|${sel}|${files.join("|")}`;
|
|
1595
|
+
}
|
|
1596
|
+
case "snapshot": return null;
|
|
1597
|
+
case "find": return extractFindAbAction(args);
|
|
1598
|
+
default: return null;
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Wire lines for the observation-only probes `get count <sel>` / `get url`.
|
|
1603
|
+
* These commands read state without mutating it, so they have no place in
|
|
1604
|
+
* the replay sequence and `extractAbActionFromBashCommand` ignores them.
|
|
1605
|
+
* They matter only when a `CCQA_ASSERT=<marker>` env prefix declares the
|
|
1606
|
+
* probe verifies a step signal — the hook layer then surfaces them via this
|
|
1607
|
+
* function so `promoteMarkedAssert` can turn them into recorded asserts.
|
|
1608
|
+
* Only consulted when a marker is present; unmarked `get` commands stay
|
|
1609
|
+
* unobserved as before.
|
|
1610
|
+
*/
|
|
1611
|
+
function extractObservationAbAction(cmd) {
|
|
1612
|
+
if (extractAbSubcommand(cmd) !== "get") return null;
|
|
1613
|
+
const abIdx = cmd.indexOf("agent-browser");
|
|
1614
|
+
const parts = shellTokenize(cmd.slice(abIdx + 13).trim()).filter((t) => !/^(2?>|[|&>])/.test(t));
|
|
1615
|
+
let i = 0;
|
|
1616
|
+
while (i < parts.length && parts[i].startsWith("-")) i += 2;
|
|
1617
|
+
const args = parts.slice(i + 1);
|
|
1618
|
+
if (args[0] === "count" && args[1]) return `AB_ACTION|get_count|${args[1]}`;
|
|
1619
|
+
if (args[0] === "url") return "AB_ACTION|get_url";
|
|
1620
|
+
return null;
|
|
1621
|
+
}
|
|
1622
|
+
const FIND_ACTION_SET = new Set(FIND_ACTIONS);
|
|
1623
|
+
const FIND_LOCATOR_SET = new Set(FIND_LOCATORS);
|
|
1624
|
+
/**
|
|
1625
|
+
* Parse the positional tokens of `agent-browser find <locator> <value> [...]
|
|
1626
|
+
* <action> [fillValue]` and produce a canonical
|
|
1627
|
+
* `AB_ACTION|find_<action>|<locator>|<value>|<extra>|<exact>|...|<label>`
|
|
1628
|
+
* line. The wire format keeps a fixed positional layout across locators so
|
|
1629
|
+
* downstream `parseAbActionLine` in `ir/from-agent-browser.ts` can split on
|
|
1630
|
+
* `|` alone:
|
|
1631
|
+
*
|
|
1632
|
+
* <extra> is `--name` value for role, integer index for nth, "" otherwise.
|
|
1633
|
+
* <exact> is the literal "exact" if --exact was passed, "" otherwise.
|
|
1634
|
+
*
|
|
1635
|
+
* Returns null for malformed invocations — the caller treats null as "not a
|
|
1636
|
+
* structured action" and the Bash command still runs unobserved.
|
|
1637
|
+
*/
|
|
1638
|
+
function extractFindAbAction(args) {
|
|
1639
|
+
const locator = args[0];
|
|
1640
|
+
if (!locator || !FIND_LOCATOR_SET.has(locator)) return null;
|
|
1641
|
+
let i = 1;
|
|
1642
|
+
let value = args[i] ?? "";
|
|
1643
|
+
i++;
|
|
1644
|
+
let extra = "";
|
|
1645
|
+
if (locator === "nth") {
|
|
1646
|
+
extra = value;
|
|
1647
|
+
value = args[i] ?? "";
|
|
1648
|
+
i++;
|
|
1649
|
+
}
|
|
1650
|
+
let action = "";
|
|
1651
|
+
let name = "";
|
|
1652
|
+
let exact = "";
|
|
1653
|
+
let fillValue = "";
|
|
1654
|
+
for (; i < args.length; i++) {
|
|
1655
|
+
const tok = args[i];
|
|
1656
|
+
if (tok === "--name") {
|
|
1657
|
+
name = args[i + 1] ?? "";
|
|
1658
|
+
i++;
|
|
1659
|
+
} else if (tok === "--exact") exact = "exact";
|
|
1660
|
+
else if (FIND_ACTION_SET.has(tok)) action = tok;
|
|
1661
|
+
else if (action) fillValue = tok;
|
|
1662
|
+
}
|
|
1663
|
+
if (!action) return null;
|
|
1664
|
+
if (locator === "role") extra = name;
|
|
1665
|
+
const command = `find_${action}`;
|
|
1666
|
+
if (action === "fill" || action === "type") return `AB_ACTION|${command}|${locator}|${value}|${extra}|${exact}|${fillValue}|`;
|
|
1667
|
+
return `AB_ACTION|${command}|${locator}|${value}|${extra}|${exact}|`;
|
|
1668
|
+
}
|
|
1669
|
+
async function buildMessageStream(prompt, options) {
|
|
1670
|
+
const mockFile = process.env["CCQA_CLAUDE_MOCK_FILE"];
|
|
1671
|
+
if (mockFile) return replayMockMessages(mockFile, options);
|
|
1672
|
+
return query({
|
|
1673
|
+
prompt,
|
|
1674
|
+
options
|
|
1675
|
+
});
|
|
1676
|
+
}
|
|
1677
|
+
async function* replayMockMessages(path, options) {
|
|
1678
|
+
const raw = await readFile(path, "utf8");
|
|
1679
|
+
for (const line of raw.split("\n")) {
|
|
1680
|
+
const trimmed = line.trim();
|
|
1681
|
+
if (!trimmed) continue;
|
|
1682
|
+
const msg = JSON.parse(trimmed);
|
|
1683
|
+
await fireMockPreToolUseHooks(msg, options);
|
|
1684
|
+
yield msg;
|
|
1685
|
+
}
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* The real SDK fires PreToolUse hooks as it executes tool calls; the JSONL
|
|
1689
|
+
* replay approximates that by invoking the configured PreToolUse hooks for
|
|
1690
|
+
* every Bash tool_use block before yielding its message, so e2e stubs
|
|
1691
|
+
* exercise the AB_ACTION recording path (including CCQA_STEP step
|
|
1692
|
+
* attribution). Hook decisions are ignored and post-tool hooks are not
|
|
1693
|
+
* simulated — the replay runs no tools, so there is nothing to block or fail.
|
|
1694
|
+
*/
|
|
1695
|
+
async function fireMockPreToolUseHooks(msg, options) {
|
|
1696
|
+
const matchers = options.hooks?.PreToolUse;
|
|
1697
|
+
if (!matchers || msg.type !== "assistant") return;
|
|
1698
|
+
for (const block of msg.message.content ?? []) {
|
|
1699
|
+
if (block.type !== "tool_use" || block.name !== "Bash") continue;
|
|
1700
|
+
const input = {
|
|
1701
|
+
hook_event_name: "PreToolUse",
|
|
1702
|
+
tool_name: "Bash",
|
|
1703
|
+
tool_input: block.input,
|
|
1704
|
+
tool_use_id: block.id,
|
|
1705
|
+
session_id: "mock",
|
|
1706
|
+
transcript_path: "",
|
|
1707
|
+
cwd: process.cwd()
|
|
1708
|
+
};
|
|
1709
|
+
for (const matcher of matchers) for (const hook of matcher.hooks) await hook(input, block.id, { signal: new AbortController().signal });
|
|
1710
|
+
}
|
|
1711
|
+
}
|
|
1712
|
+
//#endregion
|
|
1713
|
+
//#region src/prompts/format.ts
|
|
1714
|
+
/**
|
|
1715
|
+
* Formatting helpers shared by the Claude prompt builders (diagnose, report,
|
|
1716
|
+
* drift). Centralised so the prompts cannot drift apart on mechanics that
|
|
1717
|
+
* must stay consistent across commands.
|
|
1718
|
+
*/
|
|
1719
|
+
/** Prefix every line with its 1-based number, the form fix suggestions cite. */
|
|
1720
|
+
function numberLines(script) {
|
|
1721
|
+
return script.split("\n").map((l, i) => `${i + 1}: ${l}`).join("\n");
|
|
1722
|
+
}
|
|
1723
|
+
/**
|
|
1724
|
+
* The "## Output language" prompt section. Empty for "auto" so the prompt
|
|
1725
|
+
* stays byte-identical to the no-flag baseline. `fields` names the
|
|
1726
|
+
* human-readable JSON fields to translate; `verbatimNames` names the
|
|
1727
|
+
* enum-like values that must never be translated.
|
|
1728
|
+
*/
|
|
1729
|
+
function outputLanguageBlock(outputLanguage, fields, verbatimNames) {
|
|
1730
|
+
if (outputLanguage === "auto") return "";
|
|
1731
|
+
return `## Output language
|
|
1732
|
+
|
|
1733
|
+
Write all human-readable fields (${fields}) in **${outputLanguage}** (BCP-47 tag).
|
|
1734
|
+
Selectors, file paths, identifiers, ${verbatimNames}, JSON keys, and quoted strings stay verbatim regardless of language.
|
|
1735
|
+
|
|
1736
|
+
`;
|
|
1737
|
+
}
|
|
1738
|
+
/**
|
|
1739
|
+
* The `spec`/`generated` surface-axis definitions, plus the "if both are
|
|
1740
|
+
* stale, answer spec" tie-break rule. Shared verbatim by the audit prompt
|
|
1741
|
+
* (`prompts/drift.ts`) and the run's failure-classification prompt
|
|
1742
|
+
* (`report/prompt.ts`): both fill the same wire field (`DriftSurfaceSchema`),
|
|
1743
|
+
* so a diverging definition in one would make the two paths disagree on what
|
|
1744
|
+
* a spec's own field means.
|
|
1745
|
+
*/
|
|
1746
|
+
function surfaceDefinitionBlock() {
|
|
1747
|
+
return `- **\`spec\`** — spec.yaml asks about something the source no longer has. It has to be rewritten, and the code regenerated after.
|
|
1748
|
+
- **\`generated\`** — the spec still describes the product correctly, but the generated code reaches for a selector or string the source no longer has. Only a regeneration is needed; nobody has to rewrite the spec.
|
|
1749
|
+
|
|
1750
|
+
If both are stale, answer \`spec\`: it is the root, and fixing it regenerates the code.`;
|
|
1751
|
+
}
|
|
1752
|
+
/**
|
|
1753
|
+
* "surface is a separate axis from the label" clarifying example, shared for
|
|
1754
|
+
* the same reason as {@link surfaceDefinitionBlock}. `labelToken` is the
|
|
1755
|
+
* exact text to name the label by (e.g. `"TEST_DRIFT"` or `` "`TEST_DRIFT`" ``)
|
|
1756
|
+
* so each caller keeps its own backtick/bold convention.
|
|
1757
|
+
*/
|
|
1758
|
+
function surfaceAxisAside(labelToken) {
|
|
1759
|
+
return `This is a separate axis from the label. A renamed selector that only the generated code names is ${labelToken} on the \`generated\` surface; a spec whose \`expected\` quotes a string the product renamed is ${labelToken} on the \`spec\` surface.`;
|
|
1760
|
+
}
|
|
1761
|
+
//#endregion
|
|
1762
|
+
//#region src/ir/to-agent-browser.ts
|
|
1763
|
+
const lit = (text) => ({
|
|
1764
|
+
text,
|
|
1765
|
+
expandsEnv: false
|
|
1766
|
+
});
|
|
1767
|
+
const val = (text) => ({
|
|
1768
|
+
text,
|
|
1769
|
+
expandsEnv: true
|
|
1770
|
+
});
|
|
1771
|
+
/**
|
|
1772
|
+
* Render a locator as the selector string a plain agent-browser command
|
|
1773
|
+
* accepts. Only `css` (verbatim) and `text` (`text=` engine form) have a
|
|
1774
|
+
* plain-selector form; other strategies are reachable via `find` only and
|
|
1775
|
+
* fall back to their raw value (callers guard against that case).
|
|
1776
|
+
*/
|
|
1777
|
+
function locatorToSelector(locator) {
|
|
1778
|
+
return locator.by === "text" ? `text=${locator.value}` : locator.value;
|
|
1779
|
+
}
|
|
1780
|
+
/**
|
|
1781
|
+
* Compact human-readable locator form for logs and LLM-prompt summaries: the
|
|
1782
|
+
* raw selector for `css`, `by=value` otherwise. Distinct from
|
|
1783
|
+
* `locatorToSelector` (which produces a selector agent-browser can execute) —
|
|
1784
|
+
* this one is for display only and never round-trips.
|
|
1785
|
+
*/
|
|
1786
|
+
function describeLocator(locator) {
|
|
1787
|
+
return locator.by === "css" ? locator.value : `${locator.by}=${locator.value}`;
|
|
1788
|
+
}
|
|
1789
|
+
/**
|
|
1790
|
+
* Canonical agent-browser argv (sans `--session`) for one action. Returns
|
|
1791
|
+
* null for actions with no direct argv form: observation-only `snapshot`,
|
|
1792
|
+
* `assert` (validation probes / abAssert* helpers live in the consumers),
|
|
1793
|
+
* and structurally incomplete actions (e.g. a missing locator).
|
|
1794
|
+
*/
|
|
1795
|
+
function toAgentBrowserArgs(action) {
|
|
1796
|
+
switch (action.action) {
|
|
1797
|
+
case "cookies_clear": return [lit("cookies"), lit("clear")];
|
|
1798
|
+
case "navigate": return [lit("open"), val(action.value ?? "")];
|
|
1799
|
+
case "press": return [lit("press"), val(action.value ?? "")];
|
|
1800
|
+
case "scroll": return [
|
|
1801
|
+
lit("scroll"),
|
|
1802
|
+
lit(action.direction ?? "down"),
|
|
1803
|
+
...action.pixels ? [lit(action.pixels)] : []
|
|
1804
|
+
];
|
|
1805
|
+
case "select":
|
|
1806
|
+
if (!action.locator) return null;
|
|
1807
|
+
return [
|
|
1808
|
+
lit("select"),
|
|
1809
|
+
val(locatorToSelector(action.locator)),
|
|
1810
|
+
val(action.value ?? "")
|
|
1811
|
+
];
|
|
1812
|
+
case "drag":
|
|
1813
|
+
if (!action.locator || !action.target) return null;
|
|
1814
|
+
return [
|
|
1815
|
+
lit("drag"),
|
|
1816
|
+
val(locatorToSelector(action.locator)),
|
|
1817
|
+
val(locatorToSelector(action.target))
|
|
1818
|
+
];
|
|
1819
|
+
case "upload": {
|
|
1820
|
+
const files = action.files ?? [];
|
|
1821
|
+
if (!action.locator || files.length === 0) return null;
|
|
1822
|
+
return [
|
|
1823
|
+
lit("upload"),
|
|
1824
|
+
val(locatorToSelector(action.locator)),
|
|
1825
|
+
...files.map(val)
|
|
1826
|
+
];
|
|
1827
|
+
}
|
|
1828
|
+
case "wait": {
|
|
1829
|
+
const loc = action.locator;
|
|
1830
|
+
if (!loc) return null;
|
|
1831
|
+
if (loc.by === "text") return [
|
|
1832
|
+
lit("wait"),
|
|
1833
|
+
lit("--text"),
|
|
1834
|
+
val(loc.value)
|
|
1835
|
+
];
|
|
1836
|
+
return [lit("wait"), val(locatorToSelector(loc))];
|
|
1837
|
+
}
|
|
1838
|
+
case "click":
|
|
1839
|
+
case "dblclick":
|
|
1840
|
+
case "check":
|
|
1841
|
+
case "uncheck":
|
|
1842
|
+
case "hover":
|
|
1843
|
+
case "focus":
|
|
1844
|
+
case "fill":
|
|
1845
|
+
case "type": return interactionToArgs(action);
|
|
1846
|
+
case "snapshot":
|
|
1847
|
+
case "assert": return null;
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
/**
|
|
1851
|
+
* Element interactions come in two argv shapes: the plain command
|
|
1852
|
+
* (`click "<css>"`) when the locator is a raw selector string, and the
|
|
1853
|
+
* `find <locator> <value> <action> [input] [--name <n>] [--exact]` form for
|
|
1854
|
+
* semantic locators and positional (`index`) picks. `type` is a ccqa-side
|
|
1855
|
+
* alias of `fill` in both shapes. Flags MUST follow the action token —
|
|
1856
|
+
* putting them before it makes agent-browser fail with "Unknown subaction".
|
|
1857
|
+
*/
|
|
1858
|
+
function interactionToArgs(action) {
|
|
1859
|
+
const loc = action.locator;
|
|
1860
|
+
if (!loc) return null;
|
|
1861
|
+
const abAction = action.action === "type" ? "fill" : action.action;
|
|
1862
|
+
const takesInput = action.action === "fill" || action.action === "type";
|
|
1863
|
+
if (!(loc.by !== "css" || action.index !== void 0 || action.action === "focus")) {
|
|
1864
|
+
const args = [lit(abAction), val(loc.value)];
|
|
1865
|
+
if (takesInput) args.push(val(action.value ?? ""));
|
|
1866
|
+
return args;
|
|
1867
|
+
}
|
|
1868
|
+
if (!loc.value) return null;
|
|
1869
|
+
const out = [lit("find")];
|
|
1870
|
+
if (action.index !== void 0) {
|
|
1871
|
+
if (loc.by !== "css") return null;
|
|
1872
|
+
if (action.index === "first" || action.index === "last") out.push(lit(action.index));
|
|
1873
|
+
else out.push(lit("nth"), lit(String(action.index)));
|
|
1874
|
+
out.push(val(loc.value));
|
|
1875
|
+
} else {
|
|
1876
|
+
if (loc.by === "css") return null;
|
|
1877
|
+
out.push(lit(loc.by), val(loc.value));
|
|
1878
|
+
}
|
|
1879
|
+
out.push(lit(abAction));
|
|
1880
|
+
if (takesInput) out.push(val(action.value ?? ""));
|
|
1881
|
+
if (loc.by === "role" && loc.name) out.push(lit("--name"), val(loc.name));
|
|
1882
|
+
if (loc.by !== "css" && loc.exact) out.push(lit("--exact"));
|
|
1883
|
+
return out;
|
|
1884
|
+
}
|
|
1885
|
+
//#endregion
|
|
1886
|
+
//#region src/diagnose/prompt.ts
|
|
1887
|
+
function buildDiagnosePrompt(input) {
|
|
1888
|
+
const { script, specYaml, actions, failureLog, pageSnapshot, outputLanguage = "auto" } = input;
|
|
1889
|
+
const numbered = numberLines(script);
|
|
1890
|
+
const actionsSummary = actions.map((a, i) => {
|
|
1891
|
+
const parts = [`${i + 1}. ${a.action}`];
|
|
1892
|
+
if (a.assert) parts.push(`assert="${a.assert}"`);
|
|
1893
|
+
if (a.locator) parts.push(`locator="${describeLocator(a.locator)}"`);
|
|
1894
|
+
if (a.index !== void 0) parts.push(`index=${a.index}`);
|
|
1895
|
+
if (a.value) parts.push(`value="${a.value}"`);
|
|
1896
|
+
if (a.observation) parts.push(`→ ${a.observation}`);
|
|
1897
|
+
return parts.join(" ");
|
|
1898
|
+
}).join("\n");
|
|
1899
|
+
return `You are diagnosing a failing E2E test. The test was generated from a recorded trace of the original interaction. Compare the failing run against the original spec and recorded actions to determine WHY the test failed and what the right fix is.
|
|
1900
|
+
|
|
1901
|
+
${outputLanguageBlock(outputLanguage, "`reasoning`, `reason`", "code, type names (TIMING_ISSUE, etc.)")}## You have read-only filesystem tools
|
|
1902
|
+
|
|
1903
|
+
You can call \`Grep\`, \`Glob\`, and \`Read\` against the current repository before producing the JSON.
|
|
1904
|
+
|
|
1905
|
+
For SELECTOR_DRIFT specifically the failure log is usually NOT enough on its own — the runner only reports "selector X not visible". To confirm a rename, search the application source for the *type* of selector that's failing:
|
|
1906
|
+
|
|
1907
|
+
- For \`[aria-label='OLD']\` failures: \`Grep\` for \`aria-label=\` (or i18n key \`OLD\`) in the app source. If you find a near-miss like \`aria-label="NEW"\` whose text is a superset/rephrase of the failing label, that is your evidence.
|
|
1908
|
+
- For \`[placeholder='OLD']\` failures: \`Grep\` for \`placeholder=\`.
|
|
1909
|
+
- For \`[role='OLD']\` or \`[data-testid='OLD']\`: same pattern.
|
|
1910
|
+
- For \`text=OLD\` failures: \`Grep\` the source / i18n bundles for \`OLD\`. Locale files (\`*.json\`, \`*.yml\`, \`messages.ts\`, etc.) often hold the canonical strings.
|
|
1911
|
+
|
|
1912
|
+
You have **up to 10 tool turns**. Spend them on grep/read; do not loop. Only when you have concrete file:line evidence should you emit SELECTOR_DRIFT — otherwise prefer UNKNOWN with confidence < 0.4 and let the human decide.
|
|
1913
|
+
|
|
1914
|
+
Do NOT attempt to write, edit, run shell commands, or hit the network. Only Grep/Glob/Read.
|
|
1915
|
+
|
|
1916
|
+
## Diagnosis categories
|
|
1917
|
+
|
|
1918
|
+
Pick exactly ONE category. The output JSON must follow the shape for that category.
|
|
1919
|
+
|
|
1920
|
+
1. TIMING_ISSUE — element not yet present because the page hasn't loaded / navigated. Fix by inserting or extending sleeps.
|
|
1921
|
+
{
|
|
1922
|
+
"diagnosis": {
|
|
1923
|
+
"type": "TIMING_ISSUE",
|
|
1924
|
+
"fixes": [
|
|
1925
|
+
{ "kind": "insert", "line": <1-based>, "seconds": <int>, "reason": "<short>" },
|
|
1926
|
+
{ "kind": "increase", "line": <1-based of existing sleep>, "increase_to": <int>, "reason": "<short>" }
|
|
1927
|
+
]
|
|
1928
|
+
},
|
|
1929
|
+
"confidence": <0.0-1.0>,
|
|
1930
|
+
"reasoning": "<why timing is the cause>"
|
|
1931
|
+
}
|
|
1932
|
+
|
|
1933
|
+
2. OVER_ASSERTION — the test is asserting something the spec never required, OR a recorded assertion that is environment-dependent (e.g. a placeholder text that varies). The right fix is to remove those lines from the test.
|
|
1934
|
+
{
|
|
1935
|
+
"diagnosis": {
|
|
1936
|
+
"type": "OVER_ASSERTION",
|
|
1937
|
+
"lines": [<1-based line numbers to remove>],
|
|
1938
|
+
"reason": "<short>"
|
|
1939
|
+
},
|
|
1940
|
+
"confidence": <0.0-1.0>,
|
|
1941
|
+
"reasoning": "<why this assertion isn't required by the spec>"
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
3. SELECTOR_DRIFT — the page is healthy but a selector has been renamed/refined since the trace was recorded. The failure log will typically contain a snapshot showing the new selector. ONLY use this when you can name the exact replacement selector.
|
|
1945
|
+
{
|
|
1946
|
+
"diagnosis": {
|
|
1947
|
+
"type": "SELECTOR_DRIFT",
|
|
1948
|
+
"line": <1-based>,
|
|
1949
|
+
"oldSelector": "<exact string in current line>",
|
|
1950
|
+
"newSelector": "<exact replacement>",
|
|
1951
|
+
"reason": "<short>"
|
|
1952
|
+
},
|
|
1953
|
+
"confidence": <0.0-1.0>,
|
|
1954
|
+
"reasoning": "<evidence from failure log>"
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
4. DATA_MISSING — the test depends on data (a record, a setup, a logged-in state) that no longer exists. Not auto-fixable; the human must reseed or update the spec.
|
|
1958
|
+
{
|
|
1959
|
+
"diagnosis": { "type": "DATA_MISSING", "reason": "<what is missing>" },
|
|
1960
|
+
"confidence": <0.0-1.0>,
|
|
1961
|
+
"reasoning": "<evidence>"
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
5. UNKNOWN — none of the above fit, or evidence is too weak to choose.
|
|
1965
|
+
{
|
|
1966
|
+
"diagnosis": { "type": "UNKNOWN", "reason": "<short>" },
|
|
1967
|
+
"confidence": <0.0-1.0>,
|
|
1968
|
+
"reasoning": "<what you saw and why you can't classify>"
|
|
1969
|
+
}
|
|
1970
|
+
|
|
1971
|
+
## Confidence guidance
|
|
1972
|
+
|
|
1973
|
+
- 0.9-1.0: failure log directly shows the cause (e.g. "selector X not found, snapshot lists Y" → SELECTOR_DRIFT)
|
|
1974
|
+
- 0.7-0.9: strong indirect evidence (e.g. timing pattern after navigation, or assertion text that doesn't appear in spec)
|
|
1975
|
+
- 0.4-0.7: plausible classification but multiple categories could explain it
|
|
1976
|
+
- < 0.4: prefer UNKNOWN over guessing
|
|
1977
|
+
|
|
1978
|
+
## Rules
|
|
1979
|
+
|
|
1980
|
+
- Your **final** assistant message must start with \`{\` and end with \`}\` — a single JSON object, nothing before or after. No prose preamble like "Confirmed: ...", no markdown fences, no commentary, no tool calls in the same turn. If you have an analysis sentence, put it in the \`reasoning\` field.
|
|
1981
|
+
- Line numbers refer to the numbered test script below (1-based).
|
|
1982
|
+
- For SELECTOR_DRIFT, \`oldSelector\` must match a substring of the script at that line; \`newSelector\` must be backed by a concrete file:line you read with Grep/Read (do not invent). Cite the evidence in \`reasoning\`.
|
|
1983
|
+
- For OVER_ASSERTION, only include lines that contain assert calls (\`abAssert*\`) or existence-checking waits (\`abWait\`); a recorded \`abWait("[selector]")\` is an implicit existence assertion and a valid removal candidate when the spec never required that element to be present.
|
|
1984
|
+
- Cross-check assertions against the spec YAML. If the spec doesn't require the assertion, OVER_ASSERTION is the better diagnosis than SELECTOR_DRIFT.
|
|
1985
|
+
|
|
1986
|
+
## Test Spec (spec.yaml)
|
|
1987
|
+
${specYaml}
|
|
1988
|
+
|
|
1989
|
+
## Recorded Actions (ir.json summary)
|
|
1990
|
+
${actionsSummary}
|
|
1991
|
+
|
|
1992
|
+
## Test Script (with line numbers)
|
|
1993
|
+
${numbered}
|
|
1994
|
+
|
|
1995
|
+
## Failure Log
|
|
1996
|
+
${failureLog.slice(0, 4e3)}${pageSnapshot ? formatPageSnapshot(pageSnapshot) : ""}`;
|
|
1997
|
+
}
|
|
1998
|
+
/**
|
|
1999
|
+
* Page snapshot captured by ccqa right after the failure (agent-browser
|
|
2000
|
+
* accessibility tree). When present, it usually decides SELECTOR_DRIFT vs
|
|
2001
|
+
* TIMING_ISSUE: a near-miss aria-label / role / placeholder in the
|
|
2002
|
+
* snapshot is direct evidence of a rename, while a tree that doesn't
|
|
2003
|
+
* contain the failing locator at all (without a near-miss) points to a
|
|
2004
|
+
* still-loading page or genuinely missing element.
|
|
2005
|
+
*/
|
|
2006
|
+
function formatPageSnapshot(snapshot) {
|
|
2007
|
+
return `
|
|
2008
|
+
|
|
2009
|
+
## Page Snapshot (accessibility tree captured right after the failure)
|
|
2010
|
+
|
|
2011
|
+
This is the live state of the page when the test failed. Prefer this over your own assumptions:
|
|
2012
|
+
|
|
2013
|
+
- If a near-miss of the failing selector appears here (e.g. failing \`[aria-label='A']\` and snapshot contains \`aria-label="A-prime"\`), that is direct evidence of SELECTOR_DRIFT — propose the snapshot's value as \`newSelector\`.
|
|
2014
|
+
- If the failing locator is genuinely absent and no near-miss exists, the page may be still loading (TIMING_ISSUE) or the spec is asserting something not on this page (OVER_ASSERTION / DATA_MISSING).
|
|
2015
|
+
- If the snapshot looks unrelated to the spec (e.g. error page, login wall), DATA_MISSING is likely.
|
|
2016
|
+
|
|
2017
|
+
\`\`\`
|
|
2018
|
+
${snapshot}
|
|
2019
|
+
\`\`\``;
|
|
2020
|
+
}
|
|
2021
|
+
//#endregion
|
|
2022
|
+
//#region src/diagnose/diagnose.ts
|
|
2023
|
+
async function diagnose(input, options = {}) {
|
|
2024
|
+
const { result: raw, isError } = await invokeClaudeStreaming({
|
|
2025
|
+
prompt: buildDiagnosePrompt(input),
|
|
2026
|
+
allowedTools: [
|
|
2027
|
+
"Read",
|
|
2028
|
+
"Grep",
|
|
2029
|
+
"Glob"
|
|
2030
|
+
],
|
|
2031
|
+
maxTurns: 20,
|
|
2032
|
+
model: options.model
|
|
2033
|
+
}, () => {});
|
|
2034
|
+
if (isError) return {
|
|
2035
|
+
result: null,
|
|
2036
|
+
raw: raw ?? "",
|
|
2037
|
+
sdkError: true
|
|
2038
|
+
};
|
|
2039
|
+
if (!raw) return {
|
|
2040
|
+
result: null,
|
|
2041
|
+
raw: "",
|
|
2042
|
+
sdkError: false
|
|
2043
|
+
};
|
|
2044
|
+
const candidates = extractJsonCandidates(raw);
|
|
2045
|
+
for (const candidate of candidates) {
|
|
2046
|
+
let parsed;
|
|
2047
|
+
try {
|
|
2048
|
+
parsed = JSON.parse(candidate);
|
|
2049
|
+
} catch {
|
|
2050
|
+
continue;
|
|
2051
|
+
}
|
|
2052
|
+
const normalised = normaliseResult(parsed);
|
|
2053
|
+
if (normalised) return {
|
|
2054
|
+
result: normalised,
|
|
2055
|
+
raw,
|
|
2056
|
+
sdkError: false
|
|
2057
|
+
};
|
|
2058
|
+
}
|
|
2059
|
+
return {
|
|
2060
|
+
result: {
|
|
2061
|
+
diagnosis: {
|
|
2062
|
+
type: "UNKNOWN",
|
|
2063
|
+
reason: "diagnose returned no parseable diagnosis JSON"
|
|
2064
|
+
},
|
|
2065
|
+
confidence: 0,
|
|
2066
|
+
reasoning: truncate(raw, 1e3)
|
|
2067
|
+
},
|
|
2068
|
+
raw,
|
|
2069
|
+
sdkError: false
|
|
2070
|
+
};
|
|
2071
|
+
}
|
|
2072
|
+
/**
|
|
2073
|
+
* Pull every plausible JSON object out of `raw`. We try, in order:
|
|
2074
|
+
* 1. The whole string with code fences stripped (the prompt asks for
|
|
2075
|
+
* JSON-only, so this is the happy path).
|
|
2076
|
+
* 2. Each balanced `{...}` block found by scanning the text. The model
|
|
2077
|
+
* sometimes prefixes the JSON with a "Confirmed: ..." sentence or
|
|
2078
|
+
* mentions partial JSON in its tool-using reasoning; we want to
|
|
2079
|
+
* try the *last* well-formed object first because it's most likely
|
|
2080
|
+
* the final answer, then earlier ones as a fallback.
|
|
2081
|
+
*
|
|
2082
|
+
* The caller `JSON.parse`s each candidate and stops at the first match
|
|
2083
|
+
* that normalises to a known DiagnosisResult.
|
|
2084
|
+
*/
|
|
2085
|
+
function extractJsonCandidates(raw) {
|
|
2086
|
+
const out = [];
|
|
2087
|
+
const stripped = stripFence(raw);
|
|
2088
|
+
if (stripped) out.push(stripped);
|
|
2089
|
+
const blocks = [];
|
|
2090
|
+
let depth = 0;
|
|
2091
|
+
let start = -1;
|
|
2092
|
+
let inString = false;
|
|
2093
|
+
let escaped = false;
|
|
2094
|
+
for (let i = 0; i < raw.length; i++) {
|
|
2095
|
+
const ch = raw[i];
|
|
2096
|
+
if (inString) {
|
|
2097
|
+
if (escaped) escaped = false;
|
|
2098
|
+
else if (ch === "\\") escaped = true;
|
|
2099
|
+
else if (ch === "\"") inString = false;
|
|
2100
|
+
continue;
|
|
2101
|
+
}
|
|
2102
|
+
if (ch === "\"") {
|
|
2103
|
+
inString = true;
|
|
2104
|
+
continue;
|
|
2105
|
+
}
|
|
2106
|
+
if (ch === "{") {
|
|
2107
|
+
if (depth === 0) start = i;
|
|
2108
|
+
depth++;
|
|
2109
|
+
} else if (ch === "}") {
|
|
2110
|
+
depth--;
|
|
2111
|
+
if (depth === 0 && start >= 0) {
|
|
2112
|
+
blocks.push(raw.slice(start, i + 1));
|
|
2113
|
+
start = -1;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
for (let i = blocks.length - 1; i >= 0; i--) {
|
|
2118
|
+
const block = blocks[i];
|
|
2119
|
+
if (!out.includes(block)) out.push(block);
|
|
2120
|
+
}
|
|
2121
|
+
return out;
|
|
2122
|
+
}
|
|
2123
|
+
function truncate(s, max) {
|
|
2124
|
+
return s.length <= max ? s : `${s.slice(0, max)}... [truncated, ${s.length - max} more chars]`;
|
|
2125
|
+
}
|
|
2126
|
+
function stripFence(raw) {
|
|
2127
|
+
return raw.trim().replace(/^```(?:json)?\s*\n?/i, "").replace(/\n?```\s*$/i, "").trim();
|
|
2128
|
+
}
|
|
2129
|
+
function normaliseResult(parsed) {
|
|
2130
|
+
if (!isObject(parsed)) return null;
|
|
2131
|
+
const diagnosis = normaliseDiagnosis(parsed["diagnosis"]);
|
|
2132
|
+
if (!diagnosis) return null;
|
|
2133
|
+
return {
|
|
2134
|
+
diagnosis,
|
|
2135
|
+
confidence: typeof parsed["confidence"] === "number" ? clamp(parsed["confidence"], 0, 1) : 0,
|
|
2136
|
+
reasoning: typeof parsed["reasoning"] === "string" ? parsed["reasoning"] : ""
|
|
2137
|
+
};
|
|
2138
|
+
}
|
|
2139
|
+
function normaliseDiagnosis(raw) {
|
|
2140
|
+
if (!isObject(raw)) return null;
|
|
2141
|
+
switch (raw["type"]) {
|
|
2142
|
+
case "TIMING_ISSUE": {
|
|
2143
|
+
const fixes = normaliseSleepFixes(raw["fixes"]);
|
|
2144
|
+
if (fixes.length === 0) return null;
|
|
2145
|
+
return {
|
|
2146
|
+
type: "TIMING_ISSUE",
|
|
2147
|
+
fixes
|
|
2148
|
+
};
|
|
2149
|
+
}
|
|
2150
|
+
case "OVER_ASSERTION": {
|
|
2151
|
+
const lines = Array.isArray(raw["lines"]) ? raw["lines"].filter((n) => typeof n === "number" && Number.isFinite(n)) : [];
|
|
2152
|
+
if (lines.length === 0) return null;
|
|
2153
|
+
return {
|
|
2154
|
+
type: "OVER_ASSERTION",
|
|
2155
|
+
lines,
|
|
2156
|
+
reason: typeof raw["reason"] === "string" ? raw["reason"] : ""
|
|
2157
|
+
};
|
|
2158
|
+
}
|
|
2159
|
+
case "SELECTOR_DRIFT": {
|
|
2160
|
+
const line = typeof raw["line"] === "number" ? raw["line"] : null;
|
|
2161
|
+
const oldSelector = typeof raw["oldSelector"] === "string" ? raw["oldSelector"] : null;
|
|
2162
|
+
const newSelector = typeof raw["newSelector"] === "string" ? raw["newSelector"] : null;
|
|
2163
|
+
if (line === null || !oldSelector || !newSelector) return null;
|
|
2164
|
+
return {
|
|
2165
|
+
type: "SELECTOR_DRIFT",
|
|
2166
|
+
line,
|
|
2167
|
+
oldSelector,
|
|
2168
|
+
newSelector,
|
|
2169
|
+
reason: typeof raw["reason"] === "string" ? raw["reason"] : ""
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
case "DATA_MISSING": return {
|
|
2173
|
+
type: "DATA_MISSING",
|
|
2174
|
+
reason: typeof raw["reason"] === "string" ? raw["reason"] : ""
|
|
2175
|
+
};
|
|
2176
|
+
case "UNKNOWN": return {
|
|
2177
|
+
type: "UNKNOWN",
|
|
2178
|
+
reason: typeof raw["reason"] === "string" ? raw["reason"] : ""
|
|
2179
|
+
};
|
|
2180
|
+
default: return null;
|
|
2181
|
+
}
|
|
2182
|
+
}
|
|
2183
|
+
function normaliseSleepFixes(raw) {
|
|
2184
|
+
if (!Array.isArray(raw)) return [];
|
|
2185
|
+
const out = [];
|
|
2186
|
+
for (const item of raw) {
|
|
2187
|
+
if (!isObject(item)) continue;
|
|
2188
|
+
const line = typeof item["line"] === "number" ? item["line"] : null;
|
|
2189
|
+
if (line === null) continue;
|
|
2190
|
+
const reason = typeof item["reason"] === "string" ? item["reason"] : "";
|
|
2191
|
+
if (item["kind"] === "insert") {
|
|
2192
|
+
const seconds = typeof item["seconds"] === "number" ? item["seconds"] : null;
|
|
2193
|
+
if (seconds === null) continue;
|
|
2194
|
+
out.push({
|
|
2195
|
+
kind: "insert",
|
|
2196
|
+
line,
|
|
2197
|
+
seconds,
|
|
2198
|
+
reason
|
|
2199
|
+
});
|
|
2200
|
+
} else if (item["kind"] === "increase") {
|
|
2201
|
+
const increaseTo = typeof item["increase_to"] === "number" ? item["increase_to"] : null;
|
|
2202
|
+
if (increaseTo === null) continue;
|
|
2203
|
+
out.push({
|
|
2204
|
+
kind: "increase",
|
|
2205
|
+
line,
|
|
2206
|
+
increase_to: increaseTo,
|
|
2207
|
+
reason
|
|
2208
|
+
});
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
return out;
|
|
2212
|
+
}
|
|
2213
|
+
function isObject(v) {
|
|
2214
|
+
return typeof v === "object" && v !== null && !Array.isArray(v);
|
|
2215
|
+
}
|
|
2216
|
+
function clamp(n, lo, hi) {
|
|
2217
|
+
if (n < lo) return lo;
|
|
2218
|
+
if (n > hi) return hi;
|
|
2219
|
+
return n;
|
|
2220
|
+
}
|
|
2221
|
+
//#endregion
|
|
2222
|
+
export { bracedRefsToJsExpression as $, progressEnd as A, isExpandedActionStep as B, error as C, info as D, hint as E, withBuffer as F, tryParseTestSpec as G, isJudgeBody as H, CREDENTIAL_ENV_KEYS as I, SessionNameSchema as J, AGENT_BROWSER_TARGET as K, collectIncludedBlockNames as L, step as M, timedPhase as N, meta as O, warn as P, isParamRequired as Q, expandActionSteps as R, emitRaw as S, header as T, parseBlockSpec as U, isExpandedJudgeByLlmStep as V, parseTestSpec as W, TargetIdSchema as X, SpecModeSchema as Y, isIncludeStep as Z, buildSpecEnvScrub as _, truncate as a, promoteMarkedAssert as b, toAgentBrowserArgs as c, surfaceAxisAside as d, envRefsToJsExpression as et, surfaceDefinitionBlock as f, buildProseEnvScrubMap as g, withCostTally as h, isObject as i, run as j, progress as k, numberLines as l, readCostTally as m, diagnose as n, resolveEnvRefs as nt, describeLocator as o, invokeClaudeStreaming as p, DEFAULT_SPEC_MODE as q, extractJsonCandidates as r, locatorToSelector as s, clamp as t, iterEnvRefNames as tt, outputLanguageBlock as u, scrubEnvValues as v, fix as w, blank as x, parseAbActionLine as y, expandSpec as z };
|