Browser tools: full accessibility snapshot, trusted input, dialogs, evaluate, and tab controls (#1881)

* feat(browser): replace flat snapshot with ARIA tree and page-side refs

* feat(browser): trusted CDP input with actionability waits

* feat(browser): auto-handle javascript dialogs

* feat(browser): add browser_evaluate

* feat(browser): add scroll, resize, and close_tab tools

* fix(browser): review fixes for snapshot/input/dialog seams

* fix(browser): address review findings on dialog capture, truncation, and keypress targets

* fix(harness): drop resize check that cannot run under parked compositor surfaces

* fix(browser): second review round — snapshot fidelity, key input, dialog scoping
This commit is contained in:
Mohamed Boudra
2026-07-04 01:38:07 +02:00
committed by GitHub
parent b28f291493
commit 9c220c2451
29 changed files with 5653 additions and 949 deletions
+812 -1
View File
@@ -1095,12 +1095,823 @@ async function runPermanentParkingGroup() {
return results;
}
function automationFixtureUrl() {
const html = `<!doctype html>
<html>
<head><meta charset="utf-8"><title>Automation Fixture</title></head>
<style>
body { min-height: 1800px; }
#hover-target { display: none; }
#hover-source:hover + #hover-target { display: inline-block; }
#moving {
animation: slide 320ms linear;
}
#drag-target {
display: inline-block;
margin-left: 24px;
padding: 12px;
border: 1px solid #888;
}
@keyframes slide {
from { transform: translateX(80px); }
to { transform: translateX(0); }
}
</style>
<body>
<main>
<h1>Settings</h1>
<section aria-label="Account">
<p>Connected as Maya</p>
<label for="name">Name</label>
<input id="name" value="Maya">
<a href="#docs">Read docs</a>
<button id="save">Save changes</button>
<button id="delayed" disabled>Delayed save</button>
<button id="moving">Moving target</button>
<button id="hover-source">Reveal actions</button>
<button id="hover-target">Revealed action</button>
<button id="drag-source">Drag source</button>
<span id="drag-target">Drop target</span>
<button id="dialog-alert">Open alert</button>
<button id="dialog-confirm">Open confirm</button>
<button id="dialog-prompt">Open prompt</button>
<button id="dialog-beforeunload">Arm beforeunload</button>
<input id="upload" type="file" aria-label="Upload receipt">
</section>
</main>
<script>
window.fixtureLog = [];
document.getElementById("save").addEventListener("click", (event) => {
window.fixtureLog.push({
event: "click-save",
trusted: event.isTrusted,
button: event.button,
detail: event.detail,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
});
});
document.getElementById("save").addEventListener("mousedown", (event) => {
window.fixtureLog.push({
event: "down-save",
trusted: event.isTrusted,
button: event.button,
detail: event.detail,
ctrlKey: event.ctrlKey,
shiftKey: event.shiftKey,
});
});
document.getElementById("name").addEventListener("input", (event) => {
window.fixtureLog.push({ event: "input-name", trusted: event.isTrusted });
});
document.getElementById("delayed").addEventListener("click", (event) => {
window.fixtureLog.push({ event: "click-delayed", trusted: event.isTrusted });
});
document.getElementById("moving").addEventListener("click", (event) => {
window.fixtureLog.push({ event: "click-moving", trusted: event.isTrusted });
});
document.getElementById("drag-source").addEventListener("pointerdown", (event) => {
window.fixtureLog.push({ event: "drag-down", trusted: event.isTrusted });
});
document.getElementById("drag-target").addEventListener("pointerup", (event) => {
window.fixtureLog.push({ event: "drag-up", trusted: event.isTrusted });
});
document.getElementById("dialog-alert").addEventListener("click", () => {
alert("Alert opened");
window.fixtureLog.push({ event: "alert-returned" });
});
document.getElementById("dialog-confirm").addEventListener("click", () => {
window.fixtureLog.push({ event: "confirm-result", value: confirm("Confirm action?") });
});
document.getElementById("dialog-prompt").addEventListener("click", () => {
window.fixtureLog.push({ event: "prompt-result", value: prompt("Prompt value?", "Maya") });
});
window.beforeUnloadEnabled = false;
document.getElementById("dialog-beforeunload").addEventListener("click", () => {
window.beforeUnloadEnabled = true;
window.fixtureLog.push({ event: "beforeunload-armed" });
});
window.addEventListener("beforeunload", (event) => {
if (!window.beforeUnloadEnabled) return;
event.preventDefault();
event.returnValue = "Leave fixture?";
});
setTimeout(() => {
document.getElementById("delayed").disabled = false;
}, 250);
window.pushFixtureState = () => history.pushState({}, "", "#advanced");
window.sameUrlRerender = () => {
const oldSave = document.getElementById("save");
const nextSave = document.createElement("button");
nextSave.id = "save";
nextSave.textContent = "Save later";
oldSave.replaceWith(nextSave);
};
</script>
</body>
</html>`;
return `data:text/html;charset=utf-8,${encodeURIComponent(html)}`;
}
const AUTOMATION_SNAPSHOT_PROBE = String.raw`(() => {
const refs = new Map();
const lines = ['- document "Automation Fixture"'];
let nextRef = 1;
function text(value) {
return String(value || '').replace(/\s+/g, ' ').trim();
}
function fingerprint(element, role, name) {
return { role, name, tagName: element.tagName.toLowerCase(), type: element.getAttribute('type') || '', ariaLabel: element.getAttribute('aria-label') || '' };
}
function runtime() {
const api = {
refs,
resolve(ref, expected) {
const element = refs.get(ref);
if (!element || !element.isConnected) return { ok: false, reason: 'stale_ref' };
const role = roleFor(element);
const name = nameFor(element, role);
const current = fingerprint(element, role, name);
return current.role === expected.role && current.name === expected.name && current.tagName === expected.tagName && current.type === expected.type && current.ariaLabel === expected.ariaLabel
? { ok: true, element }
: { ok: false, reason: 'stale_ref' };
}
};
Object.defineProperty(window, '__PASEO_BROWSER_AUTOMATION__', { configurable: true, value: api });
return api;
}
function roleFor(element) {
const tag = element.tagName.toLowerCase();
if (/^h[1-6]$/.test(tag)) return 'heading';
if (tag === 'input') return element.type === 'file' ? 'button' : 'textbox';
if (tag === 'button') return 'button';
if (element.id === 'drag-target') return 'button';
if (tag === 'a') return 'link';
if (tag === 'section') return 'region';
return '';
}
function nameFor(element, role) {
if (element.getAttribute('aria-label')) return element.getAttribute('aria-label');
if (element.id) {
const label = document.querySelector('label[for="' + element.id + '"]');
if (label) return text(label.textContent);
}
return role === 'textbox' ? text(element.value || element.placeholder) : text(element.textContent);
}
const api = runtime();
const heading = document.querySelector('h1');
lines.push(' - heading "' + nameFor(heading, 'heading') + '" [level=1]');
lines.push(' - text: "Connected as Maya"');
for (const element of document.querySelectorAll('input, a, button, #drag-target')) {
const role = roleFor(element);
const name = nameFor(element, role);
const ref = '@e' + nextRef++;
const fp = fingerprint(element, role, name);
api.refs.set(ref, element);
lines.push(' - ' + role + ' "' + name + '" [ref=' + ref + ']');
}
return {
snapshot: lines.join('\n'),
refs: Array.from(api.refs.entries()).map(([ref, element]) => {
const role = roleFor(element);
return { ref, fingerprint: fingerprint(element, role, nameFor(element, role)) };
})
};
})()`;
async function attachAutomationDebugger(guest) {
if (!guest.debugger.isAttached()) {
guest.debugger.attach("1.3");
}
return (command, params = {}) => guest.debugger.sendCommand(command, params);
}
async function automationRefPoint(guest, ref, fingerprint) {
const result = await guest.executeJavaScript(
String.raw`(async () => {
const fingerprint = ${JSON.stringify(fingerprint)};
const ref = ${JSON.stringify(ref)};
const deadline = performance.now() + 5000;
const nextFrame = () => new Promise((resolve) => requestAnimationFrame(resolve));
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
const sameRect = (a, b) =>
Math.abs(a.x - b.x) < 0.25 &&
Math.abs(a.y - b.y) < 0.25 &&
Math.abs(a.width - b.width) < 0.25 &&
Math.abs(a.height - b.height) < 0.25;
const isDisabled = (element) =>
Boolean(element.closest?.('[aria-disabled="true"]')) ||
Boolean('disabled' in element && element.disabled);
const isVisible = (element, rect) => {
const style = getComputedStyle(element);
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden' && style.display !== 'none';
};
while (performance.now() <= deadline) {
const resolved = window.__PASEO_BROWSER_AUTOMATION__.resolve(ref, fingerprint);
if (!resolved.ok || !resolved.element?.isConnected) return { ok: false, reason: 'stale_ref' };
const element = resolved.element;
const rect = element.getBoundingClientRect();
if (!isVisible(element, rect) || isDisabled(element)) {
await sleep(25);
continue;
}
element.scrollIntoView?.({ block: 'center', inline: 'center' });
await nextFrame();
const first = element.getBoundingClientRect();
await nextFrame();
const second = element.getBoundingClientRect();
if (!sameRect(first, second)) continue;
const x = Math.min(Math.max(second.left + second.width / 2, 0), Math.max(window.innerWidth - 1, 0));
const y = Math.min(Math.max(second.top + second.height / 2, 0), Math.max(window.innerHeight - 1, 0));
const hit = document.elementFromPoint(x, y);
if (hit && (hit === element || element.contains(hit))) return { ok: true, x, y };
await sleep(25);
}
return { ok: false, reason: 'timeout' };
})()`,
true,
);
if (!result || result.ok !== true) {
fail(`automation ref ${ref} was not actionable: ${JSON.stringify(result)}`);
}
return { x: result.x, y: result.y };
}
async function automationClick(guest, refEntry, options = {}) {
const send = await attachAutomationDebugger(guest);
const point = await automationRefPoint(guest, refEntry.ref, refEntry.fingerprint);
const button = options.button || "left";
const modifiers = automationModifierMask(options.modifiers || []);
const clickCount = options.doubleClick ? 2 : 1;
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: point.x,
y: point.y,
button: "none",
modifiers,
});
await send("Input.dispatchMouseEvent", {
type: "mousePressed",
x: point.x,
y: point.y,
button,
buttons: automationButtonMask(button),
clickCount,
modifiers,
});
await send("Input.dispatchMouseEvent", {
type: "mouseReleased",
x: point.x,
y: point.y,
button,
buttons: 0,
clickCount,
modifiers,
});
return point;
}
async function automationHover(guest, refEntry) {
const send = await attachAutomationDebugger(guest);
const point = await automationRefPoint(guest, refEntry.ref, refEntry.fingerprint);
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: point.x,
y: point.y,
button: "none",
});
}
async function automationScroll(guest, deltaX, deltaY) {
const send = await attachAutomationDebugger(guest);
const point = await guest.executeJavaScript(
"({ x: Math.max(0, (window.innerWidth || 1) / 2), y: Math.max(0, (window.innerHeight || 1) / 2) })",
true,
);
await send("Input.dispatchMouseEvent", {
type: "mouseWheel",
x: point.x,
y: point.y,
deltaX,
deltaY,
});
}
async function automationDrag(guest, sourceRef, targetRef) {
const send = await attachAutomationDebugger(guest);
const source = await automationRefPoint(guest, sourceRef.ref, sourceRef.fingerprint);
const target = await automationRefPoint(guest, targetRef.ref, targetRef.fingerprint);
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: source.x,
y: source.y,
button: "none",
});
await send("Input.dispatchMouseEvent", {
type: "mousePressed",
x: source.x,
y: source.y,
button: "left",
buttons: 1,
clickCount: 1,
});
await send("Input.dispatchMouseEvent", {
type: "mouseMoved",
x: target.x,
y: target.y,
button: "left",
buttons: 1,
});
await send("Input.dispatchMouseEvent", {
type: "mouseReleased",
x: target.x,
y: target.y,
button: "left",
buttons: 0,
clickCount: 1,
});
}
async function automationType(guest, refEntry, text) {
const send = await attachAutomationDebugger(guest);
await automationClick(guest, refEntry);
await send("Input.insertText", { text });
}
async function automationEvaluate(guest, functionSource, refEntry) {
return guest.executeJavaScript(
String.raw`(async () => {
const userFunction = (0, eval)(${JSON.stringify(`(${functionSource})`)});
const args = [];
${
refEntry
? `const resolved = window.__PASEO_BROWSER_AUTOMATION__.resolve(${JSON.stringify(refEntry.ref)}, ${JSON.stringify(refEntry.fingerprint)});
if (!resolved.ok) return { ok: false, reason: "stale_ref" };
args.push(resolved.element);`
: ""
}
return JSON.stringify(await userFunction(...args));
})()`,
true,
);
}
const AUTOMATION_DIALOG_POLICY = {
alert: { action: "accepted", accept: true },
confirm: { action: "dismissed", accept: false },
prompt: { action: "dismissed", accept: false },
beforeunload: { action: "dismissed", accept: false },
};
const AUTOMATION_PROMPT_SHIM_INSTALL = String.raw`(() => {
const stateKey = "__PASEO_BROWSER_AUTOMATION_DIALOG_STATE__";
const state = window[stateKey] || { prompts: [], installed: false };
window[stateKey] = state;
if (state.installed) return true;
window.prompt = (message = "", defaultValue = "") => {
state.prompts.push({
type: "prompt",
message: String(message ?? ""),
defaultValue: String(defaultValue ?? ""),
action: "dismissed",
timestamp: Date.now(),
});
return null;
};
state.installed = true;
return true;
})()`;
const AUTOMATION_PROMPT_SHIM_DRAIN = String.raw`(() => {
const state = window.__PASEO_BROWSER_AUTOMATION_DIALOG_STATE__;
if (!state || !Array.isArray(state.prompts)) return [];
return state.prompts.splice(0);
})()`;
async function captureAutomationDialogs(guest, action, expectedCount) {
const send = await attachAutomationDebugger(guest);
await send("Page.enable");
await send("Runtime.evaluate", {
expression: AUTOMATION_PROMPT_SHIM_INSTALL,
returnByValue: true,
});
const dialogs = [];
const listener = (_event, method, params = {}) => {
if (method !== "Page.javascriptDialogOpening") return;
const type = AUTOMATION_DIALOG_POLICY[params.type] ? params.type : "alert";
const policy = AUTOMATION_DIALOG_POLICY[type];
dialogs.push({
type,
message: String(params.message || ""),
...(typeof params.defaultPrompt === "string" ? { defaultValue: params.defaultPrompt } : {}),
action: policy.action,
timestamp: Date.now(),
});
void send("Page.handleJavaScriptDialog", { accept: policy.accept });
};
guest.debugger.on("message", listener);
try {
await action();
const promptResult = await send("Runtime.evaluate", {
expression: AUTOMATION_PROMPT_SHIM_DRAIN,
returnByValue: true,
});
if (Array.isArray(promptResult.result?.value)) {
dialogs.push(...promptResult.result.value);
}
await waitForDialogCount(dialogs, expectedCount);
return dialogs;
} finally {
guest.debugger.removeListener?.("message", listener);
}
}
async function waitForDialogCount(dialogs, expectedCount) {
const deadline = Date.now() + 1000;
do {
if (dialogs.length >= expectedCount) return;
await delay(25);
} while (Date.now() < deadline);
fail(`automation observed ${dialogs.length}/${expectedCount} dialogs`);
}
function assertAutomationDialog(dialogs, expected) {
const dialog = dialogs.find((entry) => entry.type === expected.type);
if (!dialog) {
fail(`automation did not report ${expected.type} dialog: ${JSON.stringify(dialogs)}`);
}
for (const [key, value] of Object.entries(expected)) {
if (dialog[key] !== value) {
fail(`automation ${expected.type} dialog ${key}=${JSON.stringify(dialog[key])}`);
}
}
}
function automationButtonMask(button) {
if (button === "right") return 2;
if (button === "middle") return 4;
return 1;
}
function automationModifierMask(modifiers) {
const masks = { Alt: 1, Control: 2, Meta: 4, Shift: 8 };
return modifiers.reduce((mask, modifier) => mask | masks[modifier], 0);
}
function automationRefByName(snapshot, name) {
const entry = snapshot.refs.find((ref) => ref.fingerprint.name === name);
if (!entry) {
fail(`automation ref missing for ${name}`);
}
return entry;
}
async function automationLog(guest) {
return guest.executeJavaScript("window.fixtureLog", true);
}
async function waitForAutomationLog(guest, predicate, label) {
const deadline = Date.now() + 1000;
do {
const log = await automationLog(guest);
if (Array.isArray(log) && log.some(predicate)) {
return log;
}
await delay(25);
} while (Date.now() < deadline);
fail(`automation log never observed ${label}`);
}
async function runAutomationGroup() {
const results = [];
const handle = createInactiveHarnessWindow({
width: 1000,
height: 700,
backgroundColor: "#202020",
webPreferences: {
webviewTag: true,
contextIsolation: true,
nodeIntegration: false,
sandbox: false,
},
});
const { win } = handle;
installHarnessWebviewGuards(win);
const tracker = trackAttachedGuests(win, { disableGuestBackgroundThrottlingAtAttach: true });
try {
await withTimeout(
win.loadFile(path.join(ROOT, "index.html"), {
query: {
webviewCount: "0",
permanentParkingState: "p1-overflow-1x1",
targetUrl: automationFixtureUrl(),
},
}),
"automation harness window loadFile",
);
await waitForInactiveReveal(handle, "automation harness window");
const { guest } = await appendPermanentWebview({
win,
tracker,
state: { id: "p1-overflow-1x1" },
sourceUrl: automationFixtureUrl(),
});
await waitForGuestLoad(guest);
const first = await guest.executeJavaScript(AUTOMATION_SNAPSHOT_PROBE, true);
assertAutomationSnapshot(first);
pass("automation snapshot renders headings static text controls and refs");
results.push({ group: "automation", check: "snapshot", pass: true });
const saveRef = first.refs.find((ref) => ref.fingerprint.name === "Save changes");
if (!saveRef) {
fail("automation save ref missing");
}
await guest.executeJavaScript("window.pushFixtureState()", true);
const pushStateResult = await guest.executeJavaScript(
`window.__PASEO_BROWSER_AUTOMATION__.resolve(${JSON.stringify(saveRef.ref)}, ${JSON.stringify(saveRef.fingerprint)}).ok`,
true,
);
if (pushStateResult !== true) {
fail("automation ref did not survive pushState");
}
pass("automation ref resolves after pushState");
results.push({ group: "automation", check: "pushState-ref", pass: true });
const pageEvaluate = await automationEvaluate(guest, "() => ({ title: document.title })");
const refEvaluate = await automationEvaluate(
guest,
"(element) => ({ text: element.textContent.trim(), tag: element.tagName.toLowerCase() })",
saveRef,
);
if (
pageEvaluate !== '{"title":"Automation Fixture"}' ||
refEvaluate !== '{"text":"Save changes","tag":"button"}'
) {
fail(`automation evaluate returned page=${pageEvaluate} ref=${refEvaluate}`);
}
pass("automation evaluate runs in page context and receives ref element");
results.push({ group: "automation", check: "evaluate", pass: true });
await guest.executeJavaScript("window.scrollTo(0, 0)", true);
await automationScroll(guest, 0, 500);
await delay(100);
const scrollY = await guest.executeJavaScript("window.scrollY", true);
if (!(scrollY > 0)) {
fail(`automation scroll did not change window.scrollY: ${scrollY}`);
}
pass("automation scroll changes the viewport scroll position");
results.push({ group: "automation", check: "scroll", pass: true });
const nameRef = automationRefByName(first, "Name");
await automationType(guest, nameRef, " Ada");
await automationClick(guest, saveRef);
await waitForAutomationLog(
guest,
(entry) => entry.event === "input-name" && entry.trusted === true,
"trusted input event",
);
await waitForAutomationLog(
guest,
(entry) => entry.event === "click-save" && entry.trusted === true,
"trusted click event",
);
pass("automation trusted click and text input events reach the page");
results.push({ group: "automation", check: "trusted-input", pass: true });
const hoverRef = automationRefByName(first, "Reveal actions");
await automationHover(guest, hoverRef);
const hoverVisible = await guest.executeJavaScript(
"getComputedStyle(document.getElementById('hover-target')).display !== 'none'",
true,
);
if (hoverVisible !== true) {
fail("automation hover did not reveal the hover target");
}
pass("automation hover reveals CSS :hover content");
results.push({ group: "automation", check: "hover-reveal", pass: true });
const delayedRef = automationRefByName(first, "Delayed save");
await automationClick(guest, delayedRef);
const delayedLog = await automationLog(guest);
if (!delayedLog.some((entry) => entry.event === "click-delayed" && entry.trusted === true)) {
fail("automation delayed enabled button did not receive trusted click");
}
pass("automation action waits for delayed enabled state");
results.push({ group: "automation", check: "delayed-enable", pass: true });
const movingRef = automationRefByName(first, "Moving target");
await automationClick(guest, movingRef);
const movingLog = await automationLog(guest);
if (!movingLog.some((entry) => entry.event === "click-moving" && entry.trusted === true)) {
fail("automation moving button did not receive trusted click");
}
pass("automation action waits for moving element stabilization");
results.push({ group: "automation", check: "moving-stable", pass: true });
const dragSourceRef = automationRefByName(first, "Drag source");
const dragTargetRef = automationRefByName(first, "Drop target");
await automationDrag(guest, dragSourceRef, dragTargetRef);
const dragLog = await automationLog(guest);
if (
!dragLog.some((entry) => entry.event === "drag-down" && entry.trusted === true) ||
!dragLog.some((entry) => entry.event === "drag-up" && entry.trusted === true)
) {
fail("automation pointer drag did not produce trusted pointer events");
}
pass("automation pointer-event drag reaches source and target");
results.push({ group: "automation", check: "pointer-drag", pass: true });
await automationClick(guest, saveRef, {
button: "right",
doubleClick: true,
modifiers: ["Control", "Shift"],
});
const optionsLog = await automationLog(guest);
if (
!optionsLog.some(
(entry) =>
entry.event === "down-save" &&
entry.trusted === true &&
entry.button === 2 &&
entry.detail === 2 &&
entry.ctrlKey === true &&
entry.shiftKey === true,
)
) {
fail("automation click options did not affect button/modifiers/double-click count");
}
pass("automation click options affect button modifiers and double-click count");
results.push({ group: "automation", check: "click-options", pass: true });
const alertRef = automationRefByName(first, "Open alert");
const alertDialogs = await captureAutomationDialogs(
guest,
() => automationClick(guest, alertRef),
1,
);
assertAutomationDialog(alertDialogs, {
type: "alert",
message: "Alert opened",
action: "accepted",
});
await waitForAutomationLog(guest, (entry) => entry.event === "alert-returned", "alert return");
pass("automation alert dialogs are accepted and reported");
results.push({ group: "automation", check: "dialog-alert", pass: true });
const confirmRef = automationRefByName(first, "Open confirm");
const confirmDialogs = await captureAutomationDialogs(
guest,
() => automationClick(guest, confirmRef),
1,
);
assertAutomationDialog(confirmDialogs, {
type: "confirm",
message: "Confirm action?",
action: "dismissed",
});
await waitForAutomationLog(
guest,
(entry) => entry.event === "confirm-result" && entry.value === false,
"confirm dismissal",
);
pass("automation confirm dialogs are dismissed and reported");
results.push({ group: "automation", check: "dialog-confirm", pass: true });
const promptRef = automationRefByName(first, "Open prompt");
const promptDialogs = await captureAutomationDialogs(
guest,
() => automationClick(guest, promptRef),
1,
);
assertAutomationDialog(promptDialogs, {
type: "prompt",
message: "Prompt value?",
defaultValue: "Maya",
action: "dismissed",
});
await waitForAutomationLog(
guest,
(entry) => entry.event === "prompt-result" && entry.value === null,
"prompt dismissal",
);
pass("automation prompt dialogs are dismissed and reported");
results.push({ group: "automation", check: "dialog-prompt", pass: true });
const beforeUnloadRef = automationRefByName(first, "Arm beforeunload");
await automationClick(guest, beforeUnloadRef);
const beforeUrl = guest.getURL();
const beforeUnloadDialogs = await captureAutomationDialogs(
guest,
async () => {
await guest.loadURL(automationFixtureUrl()).catch(() => {});
},
1,
);
assertAutomationDialog(beforeUnloadDialogs, {
type: "beforeunload",
action: "dismissed",
});
if (guest.getURL() !== beforeUrl) {
fail("automation beforeunload dismissal did not cancel navigation");
}
pass("automation beforeunload dialogs are dismissed and reported");
results.push({ group: "automation", check: "dialog-beforeunload", pass: true });
await guest.executeJavaScript("window.sameUrlRerender()", true);
const rerenderResult = await guest.executeJavaScript(
`window.__PASEO_BROWSER_AUTOMATION__.resolve(${JSON.stringify(saveRef.ref)}, ${JSON.stringify(saveRef.fingerprint)}).reason`,
true,
);
if (rerenderResult !== "stale_ref") {
fail(`automation same-url rerender returned ${rerenderResult}`);
}
pass("automation same-url rerender stales old ref");
results.push({ group: "automation", check: "same-url-stale-ref", pass: true });
const second = await guest.executeJavaScript(AUTOMATION_SNAPSHOT_PROBE, true);
const uploadRef = second.refs.find((ref) => ref.fingerprint.name === "Upload receipt");
if (!uploadRef) {
fail("automation upload ref missing");
}
if (!guest.debugger.isAttached()) {
guest.debugger.attach("1.3");
}
const evaluated = await guest.debugger.sendCommand("Runtime.evaluate", {
expression: `(() => window.__PASEO_BROWSER_AUTOMATION__.resolve(${JSON.stringify(uploadRef.ref)}, ${JSON.stringify(uploadRef.fingerprint)}).element)()`,
objectGroup: "paseo-browser-automation",
returnByValue: false,
});
const described = await guest.debugger.sendCommand("DOM.describeNode", {
objectId: evaluated.result.objectId,
});
if (!described.node || typeof described.node.backendNodeId !== "number") {
fail("automation upload ref did not resolve to backendNodeId");
}
pass("automation upload ref resolves to backendNodeId");
results.push({ group: "automation", check: "upload-backend-node", pass: true });
// Resize is not harness-testable: the harness hosts webviews in the parked
// 1px resident host, and Electron does not propagate CSS-box resizes to a
// parked guest's capture surface (see docs/browser-capture-harness.md).
// The production resize path is app-owned webview sizing, covered by
// packages/app/src/browser-automation/handler.test.ts.
await fsp.writeFile(
path.join(OUT_DIR, "automation-results.json"),
`${JSON.stringify({ generatedAt: new Date().toISOString(), results }, null, 2)}\n`,
);
return results;
} finally {
await closeHarnessWindow(win);
}
}
function assertAutomationSnapshot(snapshot) {
const text = snapshot && snapshot.snapshot;
if (typeof text !== "string") {
fail("automation snapshot returned no text");
}
for (const expected of [
'heading "Settings"',
'text: "Connected as Maya"',
'textbox "Name" [ref=@e1]',
'link "Read docs"',
'button "Save changes"',
'button "Upload receipt"',
]) {
if (!text.includes(expected)) {
fail(`automation snapshot missing ${expected}`);
}
}
if (text.includes("selector")) {
fail("automation snapshot exposed selector text");
}
}
async function main() {
ensureDirSync(OUT_DIR);
if (!["all", "existing", "permanent-parking"].includes(HARNESS_GROUP)) {
if (!["all", "existing", "permanent-parking", "automation"].includes(HARNESS_GROUP)) {
fail(`unknown harness group ${HARNESS_GROUP}`);
}
if (HARNESS_GROUP === "automation") {
const automationResults = await runAutomationGroup();
await fsp.writeFile(
path.join(OUT_DIR, "results.json"),
`${JSON.stringify(
{
generatedAt: new Date().toISOString(),
automationResults,
},
null,
2,
)}\n`,
);
pass(`capture harness automation complete output=${OUT_DIR}`);
return;
}
if (HARNESS_GROUP === "permanent-parking") {
const permanentParkingResults = await runPermanentParkingGroup();
await fsp.writeFile(