ccqa 1.60.0 → 1.60.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 +114 -27
- 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 {
|
|
@@ -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;
|
|
@@ -25286,12 +25335,11 @@ function isPassiveAction(action) {
|
|
|
25286
25335
|
* The previous recording as the commands that perform it, for the trace prompt:
|
|
25287
25336
|
* a re-record is usually one moved screen in an otherwise intact route.
|
|
25288
25337
|
*/
|
|
25289
|
-
function renderPreviousRecording(actions, sessionName,
|
|
25290
|
-
const current = new Set([SETUP_STEP_ID, ...currentStepIds]);
|
|
25338
|
+
function renderPreviousRecording(actions, sessionName, replayable) {
|
|
25291
25339
|
const brokenStep = actions.find(failedToReplay)?.stepId;
|
|
25292
25340
|
const byStep = /* @__PURE__ */ new Map();
|
|
25293
25341
|
for (const action of actions) {
|
|
25294
|
-
if (action.stepId === void 0 || !
|
|
25342
|
+
if (action.stepId === void 0 || !replayable.has(action.stepId)) continue;
|
|
25295
25343
|
const command = previousCommand(action, sessionName, action.stepId === brokenStep);
|
|
25296
25344
|
if (command === null) continue;
|
|
25297
25345
|
const lines = byStep.get(action.stepId) ?? [];
|
|
@@ -25303,9 +25351,9 @@ function renderPreviousRecording(actions, sessionName, currentStepIds) {
|
|
|
25303
25351
|
## Previous recording
|
|
25304
25352
|
|
|
25305
25353
|
This case was recorded before. Below is what that recording did, step by step,
|
|
25306
|
-
as the commands that perform it
|
|
25307
|
-
|
|
25308
|
-
map.**
|
|
25354
|
+
as the commands that perform it, for the steps whose text has not changed since.
|
|
25355
|
+
The application may have moved since: **the steps above are the contract, this
|
|
25356
|
+
is a map.**
|
|
25309
25357
|
|
|
25310
25358
|
For a step listed here, this replaces the snapshot-first routine of the
|
|
25311
25359
|
Execution Workflow:
|
|
@@ -25319,19 +25367,16 @@ Execution Workflow:
|
|
|
25319
25367
|
3. When it reports a failure, the commands before it are recorded and the
|
|
25320
25368
|
page is where they left it: finish that step with the Execution Workflow —
|
|
25321
25369
|
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
|
|
25370
|
+
recorded. The next step goes back to \`replay_step\`.
|
|
25371
|
+
4. A step you finish yourself takes its assertions from its current
|
|
25326
25372
|
\`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.
|
|
25373
|
+
\`Expected\` says now.
|
|
25330
25374
|
5. A command marked \`# did not replay last time\` is where the previous
|
|
25331
25375
|
recording's own validation found the route broken: expect that step to
|
|
25332
25376
|
need the Execution Workflow, and do not keep a form that only half works.
|
|
25333
25377
|
|
|
25334
|
-
A step with no section here
|
|
25378
|
+
A step with no section here — new, or edited since that recording — is
|
|
25379
|
+
recorded the usual way.
|
|
25335
25380
|
|
|
25336
25381
|
${[...byStep].map(([stepId, lines]) => `### ${stepId}\n\n\`\`\`bash\n${lines.join("\n")}\n\`\`\``).join("\n\n")}
|
|
25337
25382
|
`;
|
|
@@ -25404,7 +25449,7 @@ ${input.instruction}
|
|
|
25404
25449
|
const expectationsText = renderExpectations(input.expectations ?? []) + renderCleanupExpectations(input.cleanupExpectations ?? []);
|
|
25405
25450
|
const contextText = renderContext(input.context ?? []);
|
|
25406
25451
|
const conventionsText = renderConventions(input.conventions ?? []);
|
|
25407
|
-
const previousText = renderPreviousRecording(input.previousRecording
|
|
25452
|
+
const previousText = input.previousRecording ? renderPreviousRecording(input.previousRecording.actions, sessionName, input.previousRecording.replayable) : "";
|
|
25408
25453
|
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
25454
|
|
|
25410
25455
|
## Session
|
|
@@ -25541,6 +25586,7 @@ find nth <index> "<ALLOWED-css>" <action>
|
|
|
25541
25586
|
6. If repeated labels make every ALLOWED selector ambiguous → use the \`find\` subset above.
|
|
25542
25587
|
7. Never guess. If a selector fails, take a fresh snapshot before retrying.
|
|
25543
25588
|
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).
|
|
25589
|
+
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
25590
|
|
|
25545
25591
|
### Special input types
|
|
25546
25592
|
|
|
@@ -25890,6 +25936,39 @@ ${conventions.map((c) => `### ${c.heading}\n\n${c.body}`).join("\n\n")}
|
|
|
25890
25936
|
`;
|
|
25891
25937
|
}
|
|
25892
25938
|
//#endregion
|
|
25939
|
+
//#region src/ir/step-digests.ts
|
|
25940
|
+
/**
|
|
25941
|
+
* What each step was asked to do when it was recorded, by step id. Case-level
|
|
25942
|
+
* checks land on whichever step the recorder chose, so they are part of every
|
|
25943
|
+
* step's; setup has no text of its own and stands for the case's context.
|
|
25944
|
+
*/
|
|
25945
|
+
function stepDigests(testCase) {
|
|
25946
|
+
return {
|
|
25947
|
+
[SETUP_STEP_ID]: digest(testCase.context),
|
|
25948
|
+
...Object.fromEntries(testCase.steps.filter(isExpandedActionStep).map((s) => [s.id, digest([
|
|
25949
|
+
s.instruction,
|
|
25950
|
+
s.expected,
|
|
25951
|
+
testCase.expectations
|
|
25952
|
+
])])),
|
|
25953
|
+
...Object.fromEntries(testCase.cleanup.map((s) => [s.id, digest([
|
|
25954
|
+
s.instruction,
|
|
25955
|
+
s.expected,
|
|
25956
|
+
testCase.cleanupExpectations
|
|
25957
|
+
])]))
|
|
25958
|
+
};
|
|
25959
|
+
}
|
|
25960
|
+
/**
|
|
25961
|
+
* The steps whose previous commands may be replayed: those still asked to do
|
|
25962
|
+
* what they were then. An edited, added or renumbered step, or any step of a
|
|
25963
|
+
* recording without digests, would replay what the old step asked.
|
|
25964
|
+
*/
|
|
25965
|
+
function unchangedSteps(previous, current) {
|
|
25966
|
+
return new Set(Object.keys(current).filter((id) => previous?.[id] !== void 0 && previous[id] === current[id]));
|
|
25967
|
+
}
|
|
25968
|
+
function digest(value) {
|
|
25969
|
+
return createHash("sha256").update(JSON.stringify(value)).digest("hex").slice(0, 16);
|
|
25970
|
+
}
|
|
25971
|
+
//#endregion
|
|
25893
25972
|
//#region src/cli/trace-tools.ts
|
|
25894
25973
|
const TRACE_TOOLS_SERVER = "ccqa";
|
|
25895
25974
|
/** A wedged command must not hold the whole trace; agent-browser's own waits are far shorter. */
|
|
@@ -25935,10 +26014,12 @@ async function runCommands(commands, input) {
|
|
|
25935
26014
|
const checkpoint = input.beforeAbCommand(extractCcqaStepFromBashCommand(cmd) ?? void 0);
|
|
25936
26015
|
if (checkpoint) return stop(checkpoint);
|
|
25937
26016
|
info(`$ ${scrubEnvValues(cmd, input.envScrubMap)}`);
|
|
25938
|
-
const
|
|
26017
|
+
const env = {
|
|
25939
26018
|
...process.env,
|
|
25940
26019
|
...input.env
|
|
25941
|
-
}
|
|
26020
|
+
};
|
|
26021
|
+
let result = await runShell(cmd, env);
|
|
26022
|
+
if (result.status !== 0 && scrollCoveredIntoView(["--session", input.sessionName], abPositionalTokens(cmd), result)) result = await runShell(cmd, env);
|
|
25942
26023
|
const out = result.stdout.trim();
|
|
25943
26024
|
if (!(result.status === 0 && (recorded?.holds ? recorded.holds(out) : true))) {
|
|
25944
26025
|
const detail = (result.stderr || out || `exit ${result.status ?? "timeout"}`).trim().slice(0, 600);
|
|
@@ -25979,7 +26060,7 @@ function cap(out) {
|
|
|
25979
26060
|
}
|
|
25980
26061
|
function replayStep(step, input) {
|
|
25981
26062
|
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.`;
|
|
26063
|
+
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
26064
|
const { passed, unchecked, failed } = replayUntilFailure(actions, {
|
|
25984
26065
|
sessionName: input.sessionName,
|
|
25985
26066
|
envOverrides: { CCQA_RUN_ID: input.env["CCQA_RUN_ID"] ?? input.sessionName }
|
|
@@ -26041,9 +26122,12 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
|
|
|
26041
26122
|
for (const w of conventions.warnings) warn(w);
|
|
26042
26123
|
const caseRef = testCase.ref;
|
|
26043
26124
|
const previous = await tryGetRecording(caseRef);
|
|
26125
|
+
const digests = stepDigests(testCase);
|
|
26126
|
+
const replayable = unchangedSteps(previous?.stepDigests, digests);
|
|
26044
26127
|
const previousActions = previous ? [...previous.actions, ...previous.cleanup ?? []] : [];
|
|
26045
|
-
|
|
26046
|
-
|
|
26128
|
+
const replayableActions = previousActions.filter((a) => a.stepId !== void 0 && replayable.has(a.stepId));
|
|
26129
|
+
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)");
|
|
26130
|
+
const offersMap = !opts.freshIr && replayableActions.length > 0;
|
|
26047
26131
|
const baseSystemPrompt = buildTraceSystemPrompt({
|
|
26048
26132
|
title: testCase.title,
|
|
26049
26133
|
steps,
|
|
@@ -26056,7 +26140,10 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
|
|
|
26056
26140
|
...testCase.cleanupExpectations.length > 0 ? { cleanupExpectations: testCase.cleanupExpectations } : {},
|
|
26057
26141
|
...testCase.context.length > 0 ? { context: testCase.context } : {},
|
|
26058
26142
|
...opts.instruction ? { instruction: opts.instruction } : {},
|
|
26059
|
-
...offersMap ? { previousRecording:
|
|
26143
|
+
...offersMap ? { previousRecording: {
|
|
26144
|
+
actions: previousActions,
|
|
26145
|
+
replayable
|
|
26146
|
+
} } : {}
|
|
26060
26147
|
});
|
|
26061
26148
|
const promptBundle = await loadPromptBundle(opts.hubContext ?? null, "record", opts.cwd ?? process.cwd());
|
|
26062
26149
|
if (promptBundle !== null) meta("prompt", promptBundle.loaded.join(" + "));
|
|
@@ -26115,7 +26202,7 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
|
|
|
26115
26202
|
runId: sessionName
|
|
26116
26203
|
});
|
|
26117
26204
|
const traceTools = buildTraceTools({
|
|
26118
|
-
previous: offersMap ?
|
|
26205
|
+
previous: offersMap ? replayableActions : [],
|
|
26119
26206
|
sessionName,
|
|
26120
26207
|
env: invokeBase.env ?? {},
|
|
26121
26208
|
envScrubMap,
|
|
@@ -26185,7 +26272,7 @@ async function runTrace(testCase, model, validationMode = "lenient", language, o
|
|
|
26185
26272
|
const routeActions = validatedActions.filter((a) => !cleanupIds.has(a.stepId ?? ""));
|
|
26186
26273
|
const cleanupActions = validatedActions.filter((a) => cleanupIds.has(a.stepId ?? ""));
|
|
26187
26274
|
if (overallStatus === "passed") {
|
|
26188
|
-
const saved = await saveRecording(caseRef, routeActions, cleanupActions);
|
|
26275
|
+
const saved = await saveRecording(caseRef, routeActions, cleanupActions, digests);
|
|
26189
26276
|
recordingPath = saved.path;
|
|
26190
26277
|
if (previous) await reportRouteDiff(caseRef, previous, saved.recording, steps);
|
|
26191
26278
|
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;
|