ccqa 1.60.0 → 1.60.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/bin/ccqa.mjs +253 -68
- package/dist/package.json +1 -1
- package/dist/runtime/test-helpers.cjs +48 -0
- package/dist/runtime/test-helpers.mjs +48 -0
- package/package.json +1 -1
package/dist/bin/ccqa.mjs
CHANGED
|
@@ -1612,13 +1612,14 @@ async function stampGeneratedTest(ref, testPathAbs) {
|
|
|
1612
1612
|
};
|
|
1613
1613
|
return writeRecording(ref, found.recording);
|
|
1614
1614
|
}
|
|
1615
|
-
function buildRecording(actions, cleanup = []) {
|
|
1615
|
+
function buildRecording(actions, cleanup = [], stepDigests) {
|
|
1616
1616
|
const origin = actions.find((a) => a.action === "navigate")?.value;
|
|
1617
1617
|
return {
|
|
1618
1618
|
recordedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1619
1619
|
...origin ? { origin } : {},
|
|
1620
1620
|
actions,
|
|
1621
|
-
...cleanup.length > 0 ? { cleanup } : {}
|
|
1621
|
+
...cleanup.length > 0 ? { cleanup } : {},
|
|
1622
|
+
...stepDigests ? { stepDigests } : {}
|
|
1622
1623
|
};
|
|
1623
1624
|
}
|
|
1624
1625
|
/**
|
|
@@ -1634,9 +1635,9 @@ function parseRecording(content, path) {
|
|
|
1634
1635
|
return recording;
|
|
1635
1636
|
}
|
|
1636
1637
|
const LEGACY_RECORDING_FILES = ["actions.json", "route.md"];
|
|
1637
|
-
async function saveRecording(ref, actions, cleanup = []) {
|
|
1638
|
+
async function saveRecording(ref, actions, cleanup = [], stepDigests) {
|
|
1638
1639
|
await mkdir(ref.dir, { recursive: true });
|
|
1639
|
-
const recording = buildRecording(actions, cleanup);
|
|
1640
|
+
const recording = buildRecording(actions, cleanup, stepDigests);
|
|
1640
1641
|
const recordingPath = await writeRecording(ref, recording);
|
|
1641
1642
|
await Promise.all([...LEGACY_RECORDING_FILES, FAILED_RECORDING_FILE].map((f) => unlink(join(ref.dir, f)).catch(() => {})));
|
|
1642
1643
|
return {
|
|
@@ -1649,10 +1650,10 @@ async function saveRecording(ref, actions, cleanup = []) {
|
|
|
1649
1650
|
*
|
|
1650
1651
|
* One directory, not one per attempt: a project whose tests belong to its own
|
|
1651
1652
|
* runner never calls `ccqa run`, so this stands in for the run report the
|
|
1652
|
-
* evidence table reads — and what stands in for it is the attempt that passed
|
|
1653
|
-
*
|
|
1654
|
-
*
|
|
1655
|
-
*
|
|
1653
|
+
* evidence table reads — and what stands in for it is the attempt that passed
|
|
1654
|
+
* last, the version generation keeps. `ccqa generate` replaces it only on a
|
|
1655
|
+
* pass and removes it when none passed, because screenshots of a failing run
|
|
1656
|
+
* are a debugging aid and this table presents them as the case working.
|
|
1656
1657
|
*/
|
|
1657
1658
|
function caseRunDir(ref) {
|
|
1658
1659
|
return join(ref.dir, EVIDENCE_DIR);
|
|
@@ -6313,6 +6314,54 @@ function subcommand(args) {
|
|
|
6313
6314
|
*/
|
|
6314
6315
|
function spawnAB(args, opts) {
|
|
6315
6316
|
const timeoutMs = opts?.timeoutMs ?? PROCESS_HARD_TIMEOUT_MS;
|
|
6317
|
+
const result = spawnABRetrying(args, timeoutMs);
|
|
6318
|
+
if (result.status === 0) return result;
|
|
6319
|
+
const session = args[0] === "--session" ? args.slice(0, 2) : [];
|
|
6320
|
+
return scrollCoveredIntoView(session, args.slice(session.length), result) ? spawnABRetrying(args, timeoutMs) : result;
|
|
6321
|
+
}
|
|
6322
|
+
const COVERED_PATTERN = /Element '(.+?)' is covered by /;
|
|
6323
|
+
/**
|
|
6324
|
+
* When agent-browser refused `command` because another element (a sticky
|
|
6325
|
+
* footer, a banner) covers the target's click point, scroll the target to the
|
|
6326
|
+
* centre and say whether the command is worth running again. The refusal comes
|
|
6327
|
+
* before any input is dispatched, so running it again cannot act twice.
|
|
6328
|
+
*/
|
|
6329
|
+
function scrollCoveredIntoView(session, command, failed) {
|
|
6330
|
+
const covered = COVERED_PATTERN.exec(`${failed.stdout}\n${failed.stderr}`)?.[1];
|
|
6331
|
+
if (covered === void 0) return false;
|
|
6332
|
+
const scroll = command[0] === "find" ? scrollToFound(command, session) : ["scrollintoview", covered];
|
|
6333
|
+
return scroll !== null && spawnABOnce([...session, ...scroll], PROCESS_HARD_TIMEOUT_MS).status === 0;
|
|
6334
|
+
}
|
|
6335
|
+
/** The `find` forms whose element can be addressed again; the rest stay refused. */
|
|
6336
|
+
function scrollToFound(find, session) {
|
|
6337
|
+
const [, by = "", value = ""] = find;
|
|
6338
|
+
switch (by) {
|
|
6339
|
+
case "testid": return ["scrollintoview", `[data-testid=${JSON.stringify(value)}]`];
|
|
6340
|
+
case "first": return scrollToNth(value, "0");
|
|
6341
|
+
case "last": return scrollToNth(value, "els.length - 1");
|
|
6342
|
+
case "nth": return scrollToNth(find[3] ?? "", String(Number(value)));
|
|
6343
|
+
case "role": return scrollToRole(find, session);
|
|
6344
|
+
default: return null;
|
|
6345
|
+
}
|
|
6346
|
+
}
|
|
6347
|
+
function scrollToNth(css, index) {
|
|
6348
|
+
return ["eval", `{ const els = document.querySelectorAll(${JSON.stringify(css)}); els[${index}]?.scrollIntoView({ block: "center" }); }`];
|
|
6349
|
+
}
|
|
6350
|
+
/** The snapshot's ref for the element `find role` matches: same role, name by `--exact` or case-insensitive substring. */
|
|
6351
|
+
function scrollToRole(find, session) {
|
|
6352
|
+
const role = (find[2] ?? "").toLowerCase();
|
|
6353
|
+
const flag = find.indexOf("--name");
|
|
6354
|
+
const name = flag === -1 ? null : find[flag + 1] ?? "";
|
|
6355
|
+
const named = (label) => name === null || (find.includes("--exact") ? label === name : label.toLowerCase().includes(name.toLowerCase()));
|
|
6356
|
+
const snapshot = spawnABOnce([
|
|
6357
|
+
...session,
|
|
6358
|
+
"snapshot",
|
|
6359
|
+
"-i"
|
|
6360
|
+
], PROCESS_HARD_TIMEOUT_MS);
|
|
6361
|
+
for (const [, r, label = "", ref] of snapshot.stdout.matchAll(/^\s*- (\S+)(?: "(.*)")?.*\bref=(e\d+)/gm)) if (r.toLowerCase() === role && named(label)) return ["scrollintoview", `@${ref}`];
|
|
6362
|
+
return null;
|
|
6363
|
+
}
|
|
6364
|
+
function spawnABRetrying(args, timeoutMs) {
|
|
6316
6365
|
let result = spawnABOnce(args, timeoutMs);
|
|
6317
6366
|
let elapsed = 0;
|
|
6318
6367
|
let attempt = 0;
|
|
@@ -6486,7 +6535,7 @@ function parseStepComment(line) {
|
|
|
6486
6535
|
* every step then reads as deciding nothing and every rewrite is rejected.
|
|
6487
6536
|
*/
|
|
6488
6537
|
function stepLines(source) {
|
|
6489
|
-
return source.replace(STEP_CALL, (call) => call.replace(/\s*\n\s*/g, " ")).split("\n");
|
|
6538
|
+
return source.replace(STEP_CALL, (call) => "\n".repeat(call.split("\n").length - 1) + call.replace(/\s*\n\s*/g, " ")).split("\n");
|
|
6490
6539
|
}
|
|
6491
6540
|
/**
|
|
6492
6541
|
* The step id a `test.step` title names — the block form only.
|
|
@@ -8289,6 +8338,31 @@ function exportedNames(source) {
|
|
|
8289
8338
|
}
|
|
8290
8339
|
//#endregion
|
|
8291
8340
|
//#region src/targets/emitted-review.ts
|
|
8341
|
+
/**
|
|
8342
|
+
* A mechanical read of the code a generation just wrote, before it is called
|
|
8343
|
+
* done.
|
|
8344
|
+
*
|
|
8345
|
+
* The fix loop already runs the project's own test command and checks, so
|
|
8346
|
+
* what reaches review is code that compiles, lints and passes. None of that
|
|
8347
|
+
* answers whether it is code a reviewer would take: an assertion scoped to
|
|
8348
|
+
* nothing passes, a locator named for a row that matches page-wide text
|
|
8349
|
+
* passes, a test that checks five things the case never asked about passes.
|
|
8350
|
+
* Those are what this reads for, and its findings go back through the same
|
|
8351
|
+
* fix loop rather than to a human.
|
|
8352
|
+
*
|
|
8353
|
+
* Every rule here was calibrated against a large hand-written suite: a rule
|
|
8354
|
+
* that fires on code people wrote and reviewed is a rule that is wrong,
|
|
8355
|
+
* however reasonable it sounds. Two candidates died that way —
|
|
8356
|
+
* "a locator named `…Row` must use `getByRole("row")`" fired across a
|
|
8357
|
+
* sizable share of the hand-written page objects, because that suite names
|
|
8358
|
+
* elements for what they look like rather than for their ARIA role, and
|
|
8359
|
+
* "never `.first()` in an assertion" fired on deliberate uses that each
|
|
8360
|
+
* carried the comment their own guidelines ask for. A third died against
|
|
8361
|
+
* ccqa's own output: the step comment a generated test opens each step with
|
|
8362
|
+
* looked like narration, and is what the evidence table reads back to say
|
|
8363
|
+
* which assertion belongs to which step. Keep that bar for anything added
|
|
8364
|
+
* here.
|
|
8365
|
+
*/
|
|
8292
8366
|
function reviewEmittedFiles(input) {
|
|
8293
8367
|
const locators = collectLocators(input.files);
|
|
8294
8368
|
const said = input.caseText.join("\n");
|
|
@@ -8297,7 +8371,7 @@ function reviewEmittedFiles(input) {
|
|
|
8297
8371
|
...containerOfPageText(file, source),
|
|
8298
8372
|
...unjustifiedFirst(file, source),
|
|
8299
8373
|
...describeEcho(file, source),
|
|
8300
|
-
...unaskedAssertions(file, source, locators, said),
|
|
8374
|
+
...unaskedAssertions(file, source, locators, said, input.blockSteps),
|
|
8301
8375
|
...weakerTwin(file, source),
|
|
8302
8376
|
...unreached(file, source, input)
|
|
8303
8377
|
])];
|
|
@@ -8552,9 +8626,26 @@ function assertedStrings(text, locators) {
|
|
|
8552
8626
|
* precondition broke rather than four steps later, is worth doing and is
|
|
8553
8627
|
* written down when it is.
|
|
8554
8628
|
*/
|
|
8555
|
-
function unaskedAssertions(file, source, locators, said) {
|
|
8629
|
+
function unaskedAssertions(file, source, locators, said, blockSteps = /* @__PURE__ */ new Set()) {
|
|
8556
8630
|
const all = lines(source);
|
|
8631
|
+
const boundaries = stepLines(source);
|
|
8632
|
+
let step = null;
|
|
8633
|
+
let depth = null;
|
|
8557
8634
|
return all.flatMap((text, i) => {
|
|
8635
|
+
const opened = parseStepComment(boundaries[i] ?? "");
|
|
8636
|
+
if (opened !== null) {
|
|
8637
|
+
step = opened;
|
|
8638
|
+
depth = boundaries[i].includes("test.step(") ? 0 : null;
|
|
8639
|
+
}
|
|
8640
|
+
if (depth !== null) {
|
|
8641
|
+
const before = depth;
|
|
8642
|
+
depth += (text.match(/\{/g)?.length ?? 0) - (text.match(/\}/g)?.length ?? 0);
|
|
8643
|
+
if (before > 0 && depth <= 0) {
|
|
8644
|
+
step = null;
|
|
8645
|
+
depth = null;
|
|
8646
|
+
}
|
|
8647
|
+
}
|
|
8648
|
+
if (step !== null && blockSteps.has(step)) return [];
|
|
8558
8649
|
if (!/\bexpect(?:\.soft)?\(/.test(text)) return [];
|
|
8559
8650
|
const strings = assertedStrings(text, locators);
|
|
8560
8651
|
if (strings.length === 0 || strings.some((s) => said.includes(s))) return [];
|
|
@@ -14964,12 +15055,14 @@ async function finalizeAndVerify(p) {
|
|
|
14964
15055
|
review
|
|
14965
15056
|
};
|
|
14966
15057
|
}
|
|
15058
|
+
const FINDINGS_LEFT_OPEN = "generated with review findings still open — the files are kept and the findings are recorded against the case, but nothing acted on them";
|
|
14967
15059
|
/**
|
|
14968
15060
|
* The runCommand verification loop. One round asks the project's own commands,
|
|
14969
|
-
* then the review
|
|
14970
|
-
* clean. On anything else the output goes to Claude for a corrected set and
|
|
15061
|
+
* then the review and the run together — and a round is over when all three
|
|
15062
|
+
* are clean. On anything else the output goes to Claude for a corrected set and
|
|
14971
15063
|
* the next round asks all three again: a fix written for a failing run can
|
|
14972
|
-
* break a rule the round before it cleared
|
|
15064
|
+
* break a rule the round before it cleared, and one written for a finding can
|
|
15065
|
+
* break the run.
|
|
14973
15066
|
*
|
|
14974
15067
|
* `fix.mode` mirrors the agent-browser target's fix UX (which only ever
|
|
14975
15068
|
* prompts inside its own fix loop, never for the first write):
|
|
@@ -14981,8 +15074,9 @@ async function finalizeAndVerify(p) {
|
|
|
14981
15074
|
* verify decides pass/fail.
|
|
14982
15075
|
*
|
|
14983
15076
|
* Exhaustion (or a declined / skipped fix) keeps the files on disk and reports
|
|
14984
|
-
* `passed: false
|
|
14985
|
-
*
|
|
15077
|
+
* `passed: false` — unless an earlier round's files passed their run: those
|
|
15078
|
+
* are put back and the generate passes with the findings open. Targets without
|
|
15079
|
+
* a runCommand are generate-only here and pass trivially.
|
|
14986
15080
|
*/
|
|
14987
15081
|
async function runVerificationLoop(p, state) {
|
|
14988
15082
|
const runCommand = p.ctx.targetConfig.runCommand;
|
|
@@ -15012,33 +15106,61 @@ async function runVerificationLoop(p, state) {
|
|
|
15012
15106
|
const askReviewer = maxRetries > 0;
|
|
15013
15107
|
let reviewerGone = false;
|
|
15014
15108
|
if (!askReviewer && p.reading) info("no fix round could act on a review — the reviewer is not asked; the mechanical read still runs");
|
|
15109
|
+
let passing = null;
|
|
15110
|
+
const original = /* @__PURE__ */ new Map();
|
|
15111
|
+
const settle = async (attempt) => {
|
|
15112
|
+
if (passing === null) {
|
|
15113
|
+
await clearCaseRun(p.ctx.ref);
|
|
15114
|
+
return finish(false);
|
|
15115
|
+
}
|
|
15116
|
+
if (passing.attempt !== attempt) {
|
|
15117
|
+
await restoreFiles(state, passing.files, original);
|
|
15118
|
+
review = passing.review;
|
|
15119
|
+
warn("the final round did not pass — restored the files of the last round that did");
|
|
15120
|
+
}
|
|
15121
|
+
warn(FINDINGS_LEFT_OPEN);
|
|
15122
|
+
return finish(true);
|
|
15123
|
+
};
|
|
15124
|
+
if (captures) await clearCaseRun(p.ctx.ref);
|
|
15015
15125
|
for (let attempt = 0;; attempt++) {
|
|
15016
15126
|
let failing = await runCheckCommands(p.ctx);
|
|
15017
15127
|
let asked = [];
|
|
15018
15128
|
if (failing === null) {
|
|
15019
15129
|
await refreshFromDisk(state);
|
|
15020
|
-
const
|
|
15130
|
+
const questions = [
|
|
15131
|
+
readingOfEmitted(p, state, askReviewer && !reviewerGone),
|
|
15132
|
+
reviewOfEmitted(p.ctx, state, p.writeRoots),
|
|
15133
|
+
verifyRun(p, state, runCommand, attempt, captures)
|
|
15134
|
+
];
|
|
15135
|
+
const [reading, mechanical, run] = await Promise.all(questions).catch(async (err) => {
|
|
15136
|
+
await Promise.allSettled(questions);
|
|
15137
|
+
await clearCaseRun(p.ctx.ref);
|
|
15138
|
+
throw err;
|
|
15139
|
+
});
|
|
15021
15140
|
review = reading;
|
|
15022
|
-
if (
|
|
15023
|
-
|
|
15024
|
-
|
|
15141
|
+
if (run.exitCode === 0) passing = {
|
|
15142
|
+
files: new Map(state),
|
|
15143
|
+
review,
|
|
15144
|
+
attempt
|
|
15145
|
+
};
|
|
15146
|
+
const heard = review?.reviewerFailed ? void 0 : review;
|
|
15147
|
+
if (review && !heard) reviewerGone = true;
|
|
15148
|
+
const unchecked = uncheckedSteps(heard, spent);
|
|
15149
|
+
const violations = guideViolations(heard, spent);
|
|
15025
15150
|
const reads = allReadings(mechanical, unchecked, violations);
|
|
15026
|
-
|
|
15027
|
-
|
|
15028
|
-
|
|
15029
|
-
|
|
15030
|
-
if (reads !== null) warn(
|
|
15031
|
-
|
|
15032
|
-
if (run.exitCode === 0) return finish(true);
|
|
15033
|
-
failing = run;
|
|
15151
|
+
asked = [...unchecked?.asked ?? [], ...violations?.asked ?? []];
|
|
15152
|
+
if (run.exitCode !== 0) failing = reads === null ? run : allReadings(run, reads) ?? run;
|
|
15153
|
+
else if (reads !== null && attempt < maxRetries) failing = reads;
|
|
15154
|
+
else {
|
|
15155
|
+
if (reads !== null) warn(FINDINGS_LEFT_OPEN);
|
|
15156
|
+
return finish(true);
|
|
15034
15157
|
}
|
|
15035
15158
|
}
|
|
15036
15159
|
const outputTail = scrubEnvValues(tail(failing.output), outputScrub);
|
|
15037
15160
|
if (attempt >= maxRetries) {
|
|
15038
|
-
warn(`verification still failing after ${maxRetries} fix attempt(s)
|
|
15161
|
+
warn(`verification still failing after ${maxRetries} fix attempt(s)`);
|
|
15039
15162
|
logOutputTail(outputTail, warn);
|
|
15040
|
-
|
|
15041
|
-
return finish(false);
|
|
15163
|
+
return settle(attempt);
|
|
15042
15164
|
}
|
|
15043
15165
|
fix(`verification failed (exit ${failing.exitCode}) — requesting a fix (${attempt + 1}/${maxRetries})`);
|
|
15044
15166
|
logOutputTail(outputTail, fix);
|
|
@@ -15076,9 +15198,12 @@ async function runVerificationLoop(p, state) {
|
|
|
15076
15198
|
continue;
|
|
15077
15199
|
}
|
|
15078
15200
|
if (p.ctx.fix.mode === "interactive" && !await confirmFixWrite(output.files, p.ctx.cwd)) {
|
|
15079
|
-
info("fix not applied (declined)
|
|
15080
|
-
|
|
15081
|
-
|
|
15201
|
+
info("fix not applied (declined)");
|
|
15202
|
+
return settle(attempt);
|
|
15203
|
+
}
|
|
15204
|
+
for (const f of output.files) {
|
|
15205
|
+
const rel = relative(p.ctx.cwd, resolve(p.ctx.cwd, f.path));
|
|
15206
|
+
if (!state.has(rel) && !original.has(rel)) original.set(rel, await readFile(resolve(p.ctx.cwd, f.path), "utf8").catch(() => null));
|
|
15082
15207
|
}
|
|
15083
15208
|
await writeGeneratedFiles(p.ctx.cwd, output.files, state);
|
|
15084
15209
|
review = void 0;
|
|
@@ -15086,17 +15211,12 @@ async function runVerificationLoop(p, state) {
|
|
|
15086
15211
|
}
|
|
15087
15212
|
/**
|
|
15088
15213
|
* One verification run of the files as they are now, in the shape the fix loop
|
|
15089
|
-
* consumes.
|
|
15090
|
-
* of files the project's own commands and the review have already cleared.
|
|
15214
|
+
* consumes. Asked only of files the project's own commands have cleared.
|
|
15091
15215
|
*/
|
|
15092
15216
|
async function verifyRun(p, state, runCommand, attempt, captures) {
|
|
15093
15217
|
const testFiles = [...state.entries()].filter(([, f]) => f.kind === "test").map(([rel]) => rel);
|
|
15094
15218
|
const artifactsDir = await mkdtemp(join(tmpdir(), "ccqa-verify-artifacts-"));
|
|
15095
|
-
const evidenceDir = captures ?
|
|
15096
|
-
if (evidenceDir) {
|
|
15097
|
-
await clearCaseRun(p.ctx.ref);
|
|
15098
|
-
await mkdir(evidenceDir, { recursive: true });
|
|
15099
|
-
}
|
|
15219
|
+
const evidenceDir = captures ? await mkdtemp(join(tmpdir(), "ccqa-verify-evidence-")) : null;
|
|
15100
15220
|
let command = substituteArtifactsDir(substituteRunCommandFiles(runCommand, testFiles), artifactsDir);
|
|
15101
15221
|
let traceUnavailable;
|
|
15102
15222
|
if (evidenceDir) {
|
|
@@ -15120,6 +15240,8 @@ async function verifyRun(p, state, runCommand, attempt, captures) {
|
|
|
15120
15240
|
warn: (message) => warn(`${p.ctx.ref.id}: ${message}`)
|
|
15121
15241
|
});
|
|
15122
15242
|
if (unavailable) warn(unavailable);
|
|
15243
|
+
await clearCaseRun(p.ctx.ref);
|
|
15244
|
+
await cp(evidenceDir, caseRunDir(p.ctx.ref), { recursive: true });
|
|
15123
15245
|
}
|
|
15124
15246
|
return {
|
|
15125
15247
|
...result,
|
|
@@ -15130,6 +15252,10 @@ async function verifyRun(p, state, runCommand, attempt, captures) {
|
|
|
15130
15252
|
recursive: true,
|
|
15131
15253
|
force: true
|
|
15132
15254
|
});
|
|
15255
|
+
if (evidenceDir) await rm(evidenceDir, {
|
|
15256
|
+
recursive: true,
|
|
15257
|
+
force: true
|
|
15258
|
+
});
|
|
15133
15259
|
}
|
|
15134
15260
|
}
|
|
15135
15261
|
/**
|
|
@@ -15150,6 +15276,10 @@ async function runCheckCommands(ctx) {
|
|
|
15150
15276
|
}
|
|
15151
15277
|
return null;
|
|
15152
15278
|
}
|
|
15279
|
+
function blockStepIds(ctx) {
|
|
15280
|
+
const included = new Set(collectIncludedBlockNames(ctx.spec));
|
|
15281
|
+
return new Set(ctx.steps.filter((s) => included.has(s.source)).map((s) => s.id));
|
|
15282
|
+
}
|
|
15153
15283
|
/**
|
|
15154
15284
|
* The mechanical read of what this attempt wrote, shaped like a failed check
|
|
15155
15285
|
* so the fix loop carries it the same way.
|
|
@@ -15167,6 +15297,7 @@ async function reviewOfEmitted(ctx, state, writeRoots) {
|
|
|
15167
15297
|
...ctx.expectations,
|
|
15168
15298
|
...ctx.cleanupExpectations
|
|
15169
15299
|
],
|
|
15300
|
+
blockSteps: blockStepIds(ctx),
|
|
15170
15301
|
testPath: ctx.testPath
|
|
15171
15302
|
});
|
|
15172
15303
|
if (findings.length === 0) return null;
|
|
@@ -15212,6 +15343,23 @@ async function refreshFromDisk(state) {
|
|
|
15212
15343
|
}
|
|
15213
15344
|
}
|
|
15214
15345
|
/**
|
|
15346
|
+
* Put the files back as `to` holds them. A path written after `to` was taken
|
|
15347
|
+
* goes back to what it held before the loop wrote it, or away if it was new.
|
|
15348
|
+
*/
|
|
15349
|
+
async function restoreFiles(state, to, original) {
|
|
15350
|
+
for (const [rel, f] of state) {
|
|
15351
|
+
if (to.has(rel)) continue;
|
|
15352
|
+
const was = original.get(rel) ?? null;
|
|
15353
|
+
if (was === null) await rm(f.abs, { force: true });
|
|
15354
|
+
else await writeFile(f.abs, was, "utf8");
|
|
15355
|
+
}
|
|
15356
|
+
state.clear();
|
|
15357
|
+
for (const [rel, f] of to) {
|
|
15358
|
+
await writeFile(f.abs, f.contents, "utf8");
|
|
15359
|
+
state.set(rel, f);
|
|
15360
|
+
}
|
|
15361
|
+
}
|
|
15362
|
+
/**
|
|
15215
15363
|
* The reading's findings, shaped like a failed check so the fix loop carries
|
|
15216
15364
|
* them the same way — or null when there is nothing to act on.
|
|
15217
15365
|
*
|
|
@@ -15224,9 +15372,8 @@ async function refreshFromDisk(state) {
|
|
|
15224
15372
|
*
|
|
15225
15373
|
* What the round asks for is verified like any other rewrite, and that can
|
|
15226
15374
|
* end red: an assertion strengthened to check what the case claims may simply
|
|
15227
|
-
* not hold. The
|
|
15228
|
-
*
|
|
15229
|
-
* (restoring what passed weakly) would hide a case the product does not meet.
|
|
15375
|
+
* not hold. The loop then goes back to the last version that passed, and the
|
|
15376
|
+
* finding stays open on the record rather than hidden.
|
|
15230
15377
|
*
|
|
15231
15378
|
* A review that could not be obtained is not acted on either: spending a round
|
|
15232
15379
|
* answering a question nobody asked is worse than leaving it unanswered. Nor
|
|
@@ -15372,7 +15519,7 @@ function allReadings(...reports) {
|
|
|
15372
15519
|
const found = reports.filter((r) => r !== null);
|
|
15373
15520
|
if (found.length === 0) return null;
|
|
15374
15521
|
return {
|
|
15375
|
-
exitCode:
|
|
15522
|
+
exitCode: found[0].exitCode,
|
|
15376
15523
|
command: found.map((r) => r.command).join(" + "),
|
|
15377
15524
|
output: found.map((r) => `${r.command}\n\n${r.output}`).join("\n\n")
|
|
15378
15525
|
};
|
|
@@ -25286,12 +25433,11 @@ function isPassiveAction(action) {
|
|
|
25286
25433
|
* The previous recording as the commands that perform it, for the trace prompt:
|
|
25287
25434
|
* a re-record is usually one moved screen in an otherwise intact route.
|
|
25288
25435
|
*/
|
|
25289
|
-
function renderPreviousRecording(actions, sessionName,
|
|
25290
|
-
const current = new Set([SETUP_STEP_ID, ...currentStepIds]);
|
|
25436
|
+
function renderPreviousRecording(actions, sessionName, replayable) {
|
|
25291
25437
|
const brokenStep = actions.find(failedToReplay)?.stepId;
|
|
25292
25438
|
const byStep = /* @__PURE__ */ new Map();
|
|
25293
25439
|
for (const action of actions) {
|
|
25294
|
-
if (action.stepId === void 0 || !
|
|
25440
|
+
if (action.stepId === void 0 || !replayable.has(action.stepId)) continue;
|
|
25295
25441
|
const command = previousCommand(action, sessionName, action.stepId === brokenStep);
|
|
25296
25442
|
if (command === null) continue;
|
|
25297
25443
|
const lines = byStep.get(action.stepId) ?? [];
|
|
@@ -25303,9 +25449,9 @@ function renderPreviousRecording(actions, sessionName, currentStepIds) {
|
|
|
25303
25449
|
## Previous recording
|
|
25304
25450
|
|
|
25305
25451
|
This case was recorded before. Below is what that recording did, step by step,
|
|
25306
|
-
as the commands that perform it
|
|
25307
|
-
|
|
25308
|
-
map.**
|
|
25452
|
+
as the commands that perform it, for the steps whose text has not changed since.
|
|
25453
|
+
The application may have moved since: **the steps above are the contract, this
|
|
25454
|
+
is a map.**
|
|
25309
25455
|
|
|
25310
25456
|
For a step listed here, this replaces the snapshot-first routine of the
|
|
25311
25457
|
Execution Workflow:
|
|
@@ -25319,19 +25465,16 @@ Execution Workflow:
|
|
|
25319
25465
|
3. When it reports a failure, the commands before it are recorded and the
|
|
25320
25466
|
page is where they left it: finish that step with the Execution Workflow —
|
|
25321
25467
|
snapshot, find what works, record it — without re-running what was
|
|
25322
|
-
recorded.
|
|
25323
|
-
|
|
25324
|
-
from the start. The next step goes back to \`replay_step\`.
|
|
25325
|
-
4. A step you record yourself takes its assertions from its current
|
|
25468
|
+
recorded. The next step goes back to \`replay_step\`.
|
|
25469
|
+
4. A step you finish yourself takes its assertions from its current
|
|
25326
25470
|
\`Expected\`: keep a previous check only while it verifies what
|
|
25327
|
-
\`Expected\` says now.
|
|
25328
|
-
record yourself, since \`replay_step\` records the previous checks as
|
|
25329
|
-
they are.
|
|
25471
|
+
\`Expected\` says now.
|
|
25330
25472
|
5. A command marked \`# did not replay last time\` is where the previous
|
|
25331
25473
|
recording's own validation found the route broken: expect that step to
|
|
25332
25474
|
need the Execution Workflow, and do not keep a form that only half works.
|
|
25333
25475
|
|
|
25334
|
-
A step with no section here
|
|
25476
|
+
A step with no section here — new, or edited since that recording — is
|
|
25477
|
+
recorded the usual way.
|
|
25335
25478
|
|
|
25336
25479
|
${[...byStep].map(([stepId, lines]) => `### ${stepId}\n\n\`\`\`bash\n${lines.join("\n")}\n\`\`\``).join("\n\n")}
|
|
25337
25480
|
`;
|
|
@@ -25404,7 +25547,7 @@ ${input.instruction}
|
|
|
25404
25547
|
const expectationsText = renderExpectations(input.expectations ?? []) + renderCleanupExpectations(input.cleanupExpectations ?? []);
|
|
25405
25548
|
const contextText = renderContext(input.context ?? []);
|
|
25406
25549
|
const conventionsText = renderConventions(input.conventions ?? []);
|
|
25407
|
-
const previousText = renderPreviousRecording(input.previousRecording
|
|
25550
|
+
const previousText = input.previousRecording ? renderPreviousRecording(input.previousRecording.actions, sessionName, input.previousRecording.replayable) : "";
|
|
25408
25551
|
return `You are an expert QA engineer executing a browser E2E test. Execute each step precisely and record every browser action as a structured log line.
|
|
25409
25552
|
|
|
25410
25553
|
## Session
|
|
@@ -25541,6 +25684,7 @@ find nth <index> "<ALLOWED-css>" <action>
|
|
|
25541
25684
|
6. If repeated labels make every ALLOWED selector ambiguous → use the \`find\` subset above.
|
|
25542
25685
|
7. Never guess. If a selector fails, take a fresh snapshot before retrying.
|
|
25543
25686
|
8. **A click that reports success but changes nothing means the element sits outside the visible part of an inner scroll container** — the event dispatches, the app never reacts, and no error is printed. Run \`scrollintoview\` on the element and click again. Scroll to the element itself, addressed by a CSS selector (\`scrollintoview "button.primary-action"\`); \`scrollintoview "text=…"\` can land on a text node that leaves the control still clipped. Do not read this as a product bug or reach for a \`@ref\` — verify by checking that the DOM changed (e.g. \`get count\` on what the click should have produced).
|
|
25687
|
+
9. **A click refused with \`is covered by\`** (a sticky footer or banner over the element) is not a product bug: \`run_commands\` scrolls the element to the centre and clicks again, so send it there.
|
|
25544
25688
|
|
|
25545
25689
|
### Special input types
|
|
25546
25690
|
|
|
@@ -25890,6 +26034,39 @@ ${conventions.map((c) => `### ${c.heading}\n\n${c.body}`).join("\n\n")}
|
|
|
25890
26034
|
`;
|
|
25891
26035
|
}
|
|
25892
26036
|
//#endregion
|
|
26037
|
+
//#region src/ir/step-digests.ts
|
|
26038
|
+
/**
|
|
26039
|
+
* What each step was asked to do when it was recorded, by step id. Case-level
|
|
26040
|
+
* checks land on whichever step the recorder chose, so they are part of every
|
|
26041
|
+
* step's; setup has no text of its own and stands for the case's context.
|
|
26042
|
+
*/
|
|
26043
|
+
function stepDigests(testCase) {
|
|
26044
|
+
return {
|
|
26045
|
+
[SETUP_STEP_ID]: digest(testCase.context),
|
|
26046
|
+
...Object.fromEntries(testCase.steps.filter(isExpandedActionStep).map((s) => [s.id, digest([
|
|
26047
|
+
s.instruction,
|
|
26048
|
+
s.expected,
|
|
26049
|
+
testCase.expectations
|
|
26050
|
+
])])),
|
|
26051
|
+
...Object.fromEntries(testCase.cleanup.map((s) => [s.id, digest([
|
|
26052
|
+
s.instruction,
|
|
26053
|
+
s.expected,
|
|
26054
|
+
testCase.cleanupExpectations
|
|
26055
|
+
])]))
|
|
26056
|
+
};
|
|
26057
|
+
}
|
|
26058
|
+
/**
|
|
26059
|
+
* The steps whose previous commands may be replayed: those still asked to do
|
|
26060
|
+
* what they were then. An edited, added or renumbered step, or any step of a
|
|
26061
|
+
* recording without digests, would replay what the old step asked.
|
|
26062
|
+
*/
|
|
26063
|
+
function unchangedSteps(previous, current) {
|
|
26064
|
+
return new Set(Object.keys(current).filter((id) => previous?.[id] !== void 0 && previous[id] === current[id]));
|
|
26065
|
+
}
|
|
26066
|
+
function digest(value) {
|
|
26067
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16);
|
|
26068
|
+
}
|
|
26069
|
+
//#endregion
|
|
25893
26070
|
//#region src/cli/trace-tools.ts
|
|
25894
26071
|
const TRACE_TOOLS_SERVER = "ccqa";
|
|
25895
26072
|
/** A wedged command must not hold the whole trace; agent-browser's own waits are far shorter. */
|
|
@@ -25935,10 +26112,12 @@ async function runCommands(commands, input) {
|
|
|
25935
26112
|
const checkpoint = input.beforeAbCommand(extractCcqaStepFromBashCommand(cmd) ?? void 0);
|
|
25936
26113
|
if (checkpoint) return stop(checkpoint);
|
|
25937
26114
|
info(`$ ${scrubEnvValues(cmd, input.envScrubMap)}`);
|
|
25938
|
-
const
|
|
26115
|
+
const env = {
|
|
25939
26116
|
...process.env,
|
|
25940
26117
|
...input.env
|
|
25941
|
-
}
|
|
26118
|
+
};
|
|
26119
|
+
let result = await runShell(cmd, env);
|
|
26120
|
+
if (result.status !== 0 && scrollCoveredIntoView(["--session", input.sessionName], abPositionalTokens(cmd), result)) result = await runShell(cmd, env);
|
|
25942
26121
|
const out = result.stdout.trim();
|
|
25943
26122
|
if (!(result.status === 0 && (recorded?.holds ? recorded.holds(out) : true))) {
|
|
25944
26123
|
const detail = (result.stderr || out || `exit ${result.status ?? "timeout"}`).trim().slice(0, 600);
|
|
@@ -25979,7 +26158,7 @@ function cap(out) {
|
|
|
25979
26158
|
}
|
|
25980
26159
|
function replayStep(step, input) {
|
|
25981
26160
|
const actions = input.previous.filter((a) => a.stepId === step && a.action !== "snapshot").map(({ replayUnstable: _u, replayReason: _r, ...a }) => a);
|
|
25982
|
-
if (actions.length === 0) return `No previous commands for ${step}. Record it with the Execution Workflow.`;
|
|
26161
|
+
if (actions.length === 0) return `No previous commands for ${step}: it is new or its text changed since the last recording. Record it with the Execution Workflow.`;
|
|
25983
26162
|
const { passed, unchecked, failed } = replayUntilFailure(actions, {
|
|
25984
26163
|
sessionName: input.sessionName,
|
|
25985
26164
|
envOverrides: { CCQA_RUN_ID: input.env["CCQA_RUN_ID"] ?? input.sessionName }
|
|
@@ -26041,9 +26220,12 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
|
|
|
26041
26220
|
for (const w of conventions.warnings) warn(w);
|
|
26042
26221
|
const caseRef = testCase.ref;
|
|
26043
26222
|
const previous = await tryGetRecording(caseRef);
|
|
26223
|
+
const digests = stepDigests(testCase);
|
|
26224
|
+
const replayable = unchangedSteps(previous?.stepDigests, digests);
|
|
26044
26225
|
const previousActions = previous ? [...previous.actions, ...previous.cleanup ?? []] : [];
|
|
26045
|
-
|
|
26046
|
-
|
|
26226
|
+
const replayableActions = previousActions.filter((a) => a.stepId !== void 0 && replayable.has(a.stepId));
|
|
26227
|
+
if (previous) meta("previous", opts.freshIr ? "ignored (--fresh-ir)" : previous.stepDigests ? `${[...replayable].filter((id) => id !== SETUP_STEP_ID).length} unchanged step(s) offered as a map` : "not offered (recorded before step digests)");
|
|
26228
|
+
const offersMap = !opts.freshIr && replayableActions.length > 0;
|
|
26047
26229
|
const baseSystemPrompt = buildTraceSystemPrompt({
|
|
26048
26230
|
title: testCase.title,
|
|
26049
26231
|
steps,
|
|
@@ -26056,7 +26238,10 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
|
|
|
26056
26238
|
...testCase.cleanupExpectations.length > 0 ? { cleanupExpectations: testCase.cleanupExpectations } : {},
|
|
26057
26239
|
...testCase.context.length > 0 ? { context: testCase.context } : {},
|
|
26058
26240
|
...opts.instruction ? { instruction: opts.instruction } : {},
|
|
26059
|
-
...offersMap ? { previousRecording:
|
|
26241
|
+
...offersMap ? { previousRecording: {
|
|
26242
|
+
actions: previousActions,
|
|
26243
|
+
replayable
|
|
26244
|
+
} } : {}
|
|
26060
26245
|
});
|
|
26061
26246
|
const promptBundle = await loadPromptBundle(opts.hubContext ?? null, "record", opts.cwd ?? process.cwd());
|
|
26062
26247
|
if (promptBundle !== null) meta("prompt", promptBundle.loaded.join(" + "));
|
|
@@ -26115,7 +26300,7 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
|
|
|
26115
26300
|
runId: sessionName
|
|
26116
26301
|
});
|
|
26117
26302
|
const traceTools = buildTraceTools({
|
|
26118
|
-
previous: offersMap ?
|
|
26303
|
+
previous: offersMap ? replayableActions : [],
|
|
26119
26304
|
sessionName,
|
|
26120
26305
|
env: invokeBase.env ?? {},
|
|
26121
26306
|
envScrubMap,
|
|
@@ -26185,7 +26370,7 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
|
|
|
26185
26370
|
const routeActions = validatedActions.filter((a) => !cleanupIds.has(a.stepId ?? ""));
|
|
26186
26371
|
const cleanupActions = validatedActions.filter((a) => cleanupIds.has(a.stepId ?? ""));
|
|
26187
26372
|
if (overallStatus === "passed") {
|
|
26188
|
-
const saved = await saveRecording(caseRef, routeActions, cleanupActions);
|
|
26373
|
+
const saved = await saveRecording(caseRef, routeActions, cleanupActions, digests);
|
|
26189
26374
|
recordingPath = saved.path;
|
|
26190
26375
|
if (previous) await reportRouteDiff(caseRef, previous, saved.recording, steps);
|
|
26191
26376
|
else await removeRouteDiff(caseRef);
|
package/dist/package.json
CHANGED
|
@@ -118,6 +118,54 @@ function subcommand(args) {
|
|
|
118
118
|
*/
|
|
119
119
|
function spawnAB(args, opts) {
|
|
120
120
|
const timeoutMs = opts?.timeoutMs ?? PROCESS_HARD_TIMEOUT_MS;
|
|
121
|
+
const result = spawnABRetrying(args, timeoutMs);
|
|
122
|
+
if (result.status === 0) return result;
|
|
123
|
+
const session = args[0] === "--session" ? args.slice(0, 2) : [];
|
|
124
|
+
return scrollCoveredIntoView(session, args.slice(session.length), result) ? spawnABRetrying(args, timeoutMs) : result;
|
|
125
|
+
}
|
|
126
|
+
const COVERED_PATTERN = /Element '(.+?)' is covered by /;
|
|
127
|
+
/**
|
|
128
|
+
* When agent-browser refused `command` because another element (a sticky
|
|
129
|
+
* footer, a banner) covers the target's click point, scroll the target to the
|
|
130
|
+
* centre and say whether the command is worth running again. The refusal comes
|
|
131
|
+
* before any input is dispatched, so running it again cannot act twice.
|
|
132
|
+
*/
|
|
133
|
+
function scrollCoveredIntoView(session, command, failed) {
|
|
134
|
+
const covered = COVERED_PATTERN.exec(`${failed.stdout}\n${failed.stderr}`)?.[1];
|
|
135
|
+
if (covered === void 0) return false;
|
|
136
|
+
const scroll = command[0] === "find" ? scrollToFound(command, session) : ["scrollintoview", covered];
|
|
137
|
+
return scroll !== null && spawnABOnce([...session, ...scroll], PROCESS_HARD_TIMEOUT_MS).status === 0;
|
|
138
|
+
}
|
|
139
|
+
/** The `find` forms whose element can be addressed again; the rest stay refused. */
|
|
140
|
+
function scrollToFound(find, session) {
|
|
141
|
+
const [, by = "", value = ""] = find;
|
|
142
|
+
switch (by) {
|
|
143
|
+
case "testid": return ["scrollintoview", `[data-testid=${JSON.stringify(value)}]`];
|
|
144
|
+
case "first": return scrollToNth(value, "0");
|
|
145
|
+
case "last": return scrollToNth(value, "els.length - 1");
|
|
146
|
+
case "nth": return scrollToNth(find[3] ?? "", String(Number(value)));
|
|
147
|
+
case "role": return scrollToRole(find, session);
|
|
148
|
+
default: return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function scrollToNth(css, index) {
|
|
152
|
+
return ["eval", `{ const els = document.querySelectorAll(${JSON.stringify(css)}); els[${index}]?.scrollIntoView({ block: "center" }); }`];
|
|
153
|
+
}
|
|
154
|
+
/** The snapshot's ref for the element `find role` matches: same role, name by `--exact` or case-insensitive substring. */
|
|
155
|
+
function scrollToRole(find, session) {
|
|
156
|
+
const role = (find[2] ?? "").toLowerCase();
|
|
157
|
+
const flag = find.indexOf("--name");
|
|
158
|
+
const name = flag === -1 ? null : find[flag + 1] ?? "";
|
|
159
|
+
const named = (label) => name === null || (find.includes("--exact") ? label === name : label.toLowerCase().includes(name.toLowerCase()));
|
|
160
|
+
const snapshot = spawnABOnce([
|
|
161
|
+
...session,
|
|
162
|
+
"snapshot",
|
|
163
|
+
"-i"
|
|
164
|
+
], PROCESS_HARD_TIMEOUT_MS);
|
|
165
|
+
for (const [, r, label = "", ref] of snapshot.stdout.matchAll(/^\s*- (\S+)(?: "(.*)")?.*\bref=(e\d+)/gm)) if (r.toLowerCase() === role && named(label)) return ["scrollintoview", `@${ref}`];
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
function spawnABRetrying(args, timeoutMs) {
|
|
121
169
|
let result = spawnABOnce(args, timeoutMs);
|
|
122
170
|
let elapsed = 0;
|
|
123
171
|
let attempt = 0;
|
|
@@ -118,6 +118,54 @@ function subcommand(args) {
|
|
|
118
118
|
*/
|
|
119
119
|
function spawnAB(args, opts) {
|
|
120
120
|
const timeoutMs = opts?.timeoutMs ?? PROCESS_HARD_TIMEOUT_MS;
|
|
121
|
+
const result = spawnABRetrying(args, timeoutMs);
|
|
122
|
+
if (result.status === 0) return result;
|
|
123
|
+
const session = args[0] === "--session" ? args.slice(0, 2) : [];
|
|
124
|
+
return scrollCoveredIntoView(session, args.slice(session.length), result) ? spawnABRetrying(args, timeoutMs) : result;
|
|
125
|
+
}
|
|
126
|
+
const COVERED_PATTERN = /Element '(.+?)' is covered by /;
|
|
127
|
+
/**
|
|
128
|
+
* When agent-browser refused `command` because another element (a sticky
|
|
129
|
+
* footer, a banner) covers the target's click point, scroll the target to the
|
|
130
|
+
* centre and say whether the command is worth running again. The refusal comes
|
|
131
|
+
* before any input is dispatched, so running it again cannot act twice.
|
|
132
|
+
*/
|
|
133
|
+
function scrollCoveredIntoView(session, command, failed) {
|
|
134
|
+
const covered = COVERED_PATTERN.exec(`${failed.stdout}\n${failed.stderr}`)?.[1];
|
|
135
|
+
if (covered === void 0) return false;
|
|
136
|
+
const scroll = command[0] === "find" ? scrollToFound(command, session) : ["scrollintoview", covered];
|
|
137
|
+
return scroll !== null && spawnABOnce([...session, ...scroll], PROCESS_HARD_TIMEOUT_MS).status === 0;
|
|
138
|
+
}
|
|
139
|
+
/** The `find` forms whose element can be addressed again; the rest stay refused. */
|
|
140
|
+
function scrollToFound(find, session) {
|
|
141
|
+
const [, by = "", value = ""] = find;
|
|
142
|
+
switch (by) {
|
|
143
|
+
case "testid": return ["scrollintoview", `[data-testid=${JSON.stringify(value)}]`];
|
|
144
|
+
case "first": return scrollToNth(value, "0");
|
|
145
|
+
case "last": return scrollToNth(value, "els.length - 1");
|
|
146
|
+
case "nth": return scrollToNth(find[3] ?? "", String(Number(value)));
|
|
147
|
+
case "role": return scrollToRole(find, session);
|
|
148
|
+
default: return null;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function scrollToNth(css, index) {
|
|
152
|
+
return ["eval", `{ const els = document.querySelectorAll(${JSON.stringify(css)}); els[${index}]?.scrollIntoView({ block: "center" }); }`];
|
|
153
|
+
}
|
|
154
|
+
/** The snapshot's ref for the element `find role` matches: same role, name by `--exact` or case-insensitive substring. */
|
|
155
|
+
function scrollToRole(find, session) {
|
|
156
|
+
const role = (find[2] ?? "").toLowerCase();
|
|
157
|
+
const flag = find.indexOf("--name");
|
|
158
|
+
const name = flag === -1 ? null : find[flag + 1] ?? "";
|
|
159
|
+
const named = (label) => name === null || (find.includes("--exact") ? label === name : label.toLowerCase().includes(name.toLowerCase()));
|
|
160
|
+
const snapshot = spawnABOnce([
|
|
161
|
+
...session,
|
|
162
|
+
"snapshot",
|
|
163
|
+
"-i"
|
|
164
|
+
], PROCESS_HARD_TIMEOUT_MS);
|
|
165
|
+
for (const [, r, label = "", ref] of snapshot.stdout.matchAll(/^\s*- (\S+)(?: "(.*)")?.*\bref=(e\d+)/gm)) if (r.toLowerCase() === role && named(label)) return ["scrollintoview", `@${ref}`];
|
|
166
|
+
return null;
|
|
167
|
+
}
|
|
168
|
+
function spawnABRetrying(args, timeoutMs) {
|
|
121
169
|
let result = spawnABOnce(args, timeoutMs);
|
|
122
170
|
let elapsed = 0;
|
|
123
171
|
let attempt = 0;
|