fix(browser): resolve unnamed role refs and initialize AX markers (#130881)

* fix(browser): resolve unnamed role refs and initialize AX markers

Keep explicit raw ARIA empty names distinct from omitted serializer names and share one dependency-faithful role lookup. Bind the DOM document in the marker-owning CDP session without adding a round trip.

Related: #130879. The separate snapshot membership and ordering failures remain open.

* chore(browser): shrink ref assertion safety baseline

Record the five type assertions removed by the ref-name repair. Tighten the canonical per-file allowance from seven to two; runtime and browser-test bytes are unchanged.
This commit is contained in:
Peter Steinberger
2026-08-27 05:22:38 -07:00
committed by GitHub
parent 879ab8254c
commit f04a622c1f
10 changed files with 125 additions and 70 deletions
+1
View File
@@ -340,6 +340,7 @@ Docs: https://docs.openclaw.ai
- **Remote browser reliability:** bound persistent Playwright tab enumeration by the existing remote CDP timeout budget and retire timed-out connection attempts so late completions cannot restore a stuck connection. (#80147, #58968) Thanks @HemantSudarshan and @KeaneYan.
- **Browser tab adoption:** preserve the prior implicit tab and stable aliases when new MCP, Playwright, or CDP targets fail final safety validation, abort after creation, or cannot be rediscovered; validate labels before creating tabs and limit managed cleanup to adopted targets. (#105301) Thanks @hugenshen.
- **Browser snapshots:** preserve slash-only control names and keep ref-looking page text from retaining truncated refs or misplacing child frames. Fixes #130571.
- **Browser refs:** keep unnamed and overlong-named controls distinct from ordinary named controls to prevent wrong-target actions, and initialize raw ARIA DOM markers in their owning CDP session.
- **Browser attachment downloads:** return managed URL, filename, and path metadata when direct Playwright navigation starts an attachment download, while validating final URLs before saving bytes and preserving single-owner explicit downloads. (#48045, #89416) Thanks @zhangguiping-xydt.
- **Browser action downloads:** return managed URL, filename, and path metadata when agent actions trigger downloads, while preserving explicit ownership, validating final URLs before saving bytes, and quarantining policy-denied tabs without closing them. (#93250, #93307) Thanks @sunlit-deng.
- **Managed browser cookie persistence:** initialize new isolated macOS headless profiles with a non-interactive encryption key while preserving existing profile keys, and close Chromium through CDP before bounded signal fallback so persistent logins survive graceful browser and Gateway restarts. (#96704, #98284) Thanks @TurboTheTurtle.
+1 -1
View File
@@ -85,7 +85,7 @@ extensions/browser/src/browser/paths.ts 1
extensions/browser/src/browser/playwright-core.runtime.ts 2
extensions/browser/src/browser/pw-download-capture.ts 1
extensions/browser/src/browser/pw-role-snapshot.ts 1
extensions/browser/src/browser/pw-session-actions.ts 7
extensions/browser/src/browser/pw-session-actions.ts 2
extensions/browser/src/browser/pw-session-cdp-transport.ts 1
extensions/browser/src/browser/pw-session-navigation.ts 1
extensions/browser/src/browser/pw-session.page-cdp.ts 4
+1
View File
@@ -310,6 +310,7 @@ OpenClaw supports three "snapshot" styles:
- Actions: `openclaw browser click e12`, `openclaw browser highlight e12`.
- Internally, the ref is resolved via `getByRole(...)` (plus `nth()` for duplicates).
- Names containing quotes, backslashes, or YAML punctuation remain actionable; use the ref rather than reconstructing a locator from the displayed name.
- A missing displayed name can mean an empty accessible name or one above Playwright's 900 UTF-16-unit limit; keep using the returned ref.
- Add `--labels` to include a screenshot with overlayed `e12` labels. On
Playwright-backed profiles this also returns per-ref bounding-box metadata
(`annotations[]`).
@@ -2,9 +2,19 @@ import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { useAutoCleanupTempDirTracker } from "../../test-support.js";
import { getPlaywrightCore } from "./playwright-core.runtime.js";
import { closePlaywrightBrowserConnection } from "./pw-session.js";
import {
closePlaywrightBrowserConnection,
refLocator,
restoreRoleRefsForTarget,
} from "./pw-session.js";
import { BROWSER_REF_MARKER_ATTRIBUTE } from "./pw-session.page-cdp.js";
import { clickViaPlaywright, typeViaPlaywright } from "./pw-tools-core.interactions.actions.js";
import { snapshotAiViaPlaywright, snapshotRoleViaPlaywright } from "./pw-tools-core.snapshot.js";
import {
snapshotAiViaPlaywright,
snapshotAriaViaPlaywright,
snapshotRoleViaPlaywright,
storeAriaSnapshotRefsViaPlaywright,
} from "./pw-tools-core.snapshot.js";
import { getFreePort } from "./test-port.js";
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
@@ -12,7 +22,7 @@ const tempDirs = useAutoCleanupTempDirTracker(afterEach);
describe.runIf(process.env.OPENCLAW_BROWSER_SNAPSHOT_E2E === "1")(
"Chromium snapshot-to-action name fidelity",
() => {
it("resolves encoded duplicate names, textboxes, and frame-qualified AI refs", async () => {
it("resolves encoded and omitted names, raw AX names, and native frame refs", async () => {
const rootDir = tempDirs.make("openclaw-snapshot-labels-");
const port = await getFreePort();
const cdpUrl = `http://127.0.0.1:${port}`;
@@ -26,11 +36,25 @@ describe.runIf(process.env.OPENCLAW_BROWSER_SNAPSHOT_E2E === "1")(
);
try {
const page = context.pages()[0] ?? (await context.newPage());
await page.setContent('<main></main><output></output><iframe title="Nested"></iframe>');
await page.setContent(
'<style>button{min-width:30px;min-height:25px}</style><main></main><output></output><iframe title="Nested"></iframe>',
);
const buttonName = 'Save: "owner\'s" C:\\draft 🦞';
const inputName = 'Project "path"';
const nameControls = [
{ id: "short", name: "Named control" },
{ id: "empty", name: "" },
{ id: "empty-again", name: "" },
{ id: "name-900", name: "x".repeat(900) },
{ id: "name-901", name: "x".repeat(901) },
{ id: "surrogates-900", name: "😀".repeat(450) },
{ id: "surrogates-901", name: "😀".repeat(450) + "x" },
{ id: "normalized-empty", name: "\u200b\u00ad" },
{ id: "normalized-899", name: " x \n".repeat(450) },
{ id: "normalized-901", name: " x \n".repeat(451) },
];
await page.evaluate(
({ buttonName: label, inputName: inputLabel }) => {
({ buttonName: label, inputName: inputLabel, nameControls: controls }) => {
for (const id of ["first", "second"]) {
const button = document.createElement("button");
button.textContent = label;
@@ -48,10 +72,19 @@ describe.runIf(process.env.OPENCLAW_BROWSER_SNAPSHOT_E2E === "1")(
document.querySelector("output")!.textContent = "/";
});
document.querySelector("main")!.append(slash);
for (const { id, name } of controls) {
const button = document.createElement("button");
button.setAttribute("aria-label", name);
button.textContent = name ? "control" : "";
button.addEventListener("click", () => {
document.querySelector("output")!.textContent = id;
});
document.querySelector("main")!.append(button);
}
document.querySelector("iframe")!.srcdoc =
"<button onclick=\"this.textContent='Frame clicked'\">Frame: action</button>";
},
{ buttonName, inputName },
{ buttonName, inputName, nameControls },
);
await page.frameLocator("iframe").getByRole("button").waitFor();
const session = await context.newCDPSession(page);
@@ -68,14 +101,13 @@ describe.runIf(process.env.OPENCLAW_BROWSER_SNAPSHOT_E2E === "1")(
options: { interactive: mode !== "role" },
});
const buttons = Object.entries(snapshot.refs).filter(
([, value]) => value.role === "button" && value.name === buttonName,
([, value]) => value.role === "button" && value.name !== "Frame: action",
);
expect(buttons, mode).toHaveLength(2);
const expectedButtons = ["first", "second", "/", ...nameControls.map(({ id }) => id)];
expect(buttons, mode).toHaveLength(expectedButtons.length);
for (const [index, [ref]] of buttons.entries()) {
await clickViaPlaywright({ ...target, ref, timeoutMs: 1_000 });
expect(await page.locator("output").textContent(), mode).toBe(
index ? "second" : "first",
);
expect(await page.locator("output").textContent(), mode).toBe(expectedButtons[index]);
}
const input = Object.entries(snapshot.refs).find(
([, value]) => value.role === "textbox" && value.name === inputName,
@@ -83,10 +115,6 @@ describe.runIf(process.env.OPENCLAW_BROWSER_SNAPSHOT_E2E === "1")(
expect(input, mode).toBeDefined();
await typeViaPlaywright({ ...target, ref: input![0], text: mode, timeoutMs: 1_000 });
expect(await page.getByRole("textbox").inputValue()).toBe(mode);
const slash = Object.entries(snapshot.refs).find(([, value]) => value.name === "/");
expect(slash, mode).toBeDefined();
await clickViaPlaywright({ ...target, ref: slash![0], timeoutMs: 1_000 });
expect(await page.locator("output").textContent(), mode).toBe("/");
}
const snapshot = await snapshotAiViaPlaywright(target);
const nested = Object.entries(snapshot.refs).find(
@@ -97,6 +125,48 @@ describe.runIf(process.env.OPENCLAW_BROWSER_SNAPSHOT_E2E === "1")(
expect(await page.frameLocator("iframe").getByRole("button").textContent()).toBe(
"Frame clicked",
);
await page.setContent(
"<style>button{min-width:30px;min-height:25px}</style><main></main><output></output>",
);
const rawControls = [
{ id: "raw-short", name: "Raw named control" },
{ id: "raw-empty", name: "" },
{ id: "raw-long", name: "x".repeat(901) },
];
await page.evaluate((controls) => {
for (const { id, name } of controls) {
const button = document.createElement("button");
button.setAttribute("aria-label", name);
button.textContent = name ? "control" : "";
button.addEventListener("click", () => {
document.querySelector("output")!.textContent = id;
});
document.querySelector("main")!.append(button);
}
}, rawControls);
const aria = await snapshotAriaViaPlaywright(target);
const rawButtons = aria.nodes.filter((node) => node.role.toLowerCase() === "button");
expect(rawButtons).toHaveLength(rawControls.length);
expect(
await page
.getByRole("button", { name: "", exact: true })
.getAttribute(BROWSER_REF_MARKER_ATTRIBUTE),
).toBe(rawButtons[1]!.ref);
await clickViaPlaywright({ ...target, ref: rawButtons[1]!.ref, timeoutMs: 1_000 });
expect(await page.locator("output").textContent()).toBe("raw-empty");
// Real AX names with unavailable DOM ids exercise the existing role fallback.
// Restore onto the launcher's distinct Page wrapper to cross the target cache.
await storeAriaSnapshotRefsViaPlaywright({
...target,
nodes: aria.nodes.map(({ backendDOMNodeId: _backendId, ...node }) => node),
});
expect(await page.locator(`[${BROWSER_REF_MARKER_ATTRIBUTE}]`).count()).toBe(0);
restoreRoleRefsForTarget({ ...target, page });
for (const [index, node] of rawButtons.entries()) {
await refLocator(page, node.ref).click({ timeout: 1_000 });
expect(await page.locator("output").textContent()).toBe(rawControls[index]!.id);
}
} finally {
await closePlaywrightBrowserConnection({ cdpUrl });
await context.close();
@@ -82,9 +82,10 @@ export function refLocator(page: Page, ref: string) {
? ref.slice(4)
: ref;
if (/^e\d+$/.test(normalized)) {
const isRoleRef = /^e\d+$/.test(normalized);
if (isRoleRef || AX_REF_PATTERN.test(normalized)) {
const state = pageStates.get(page);
if (state?.roleRefsMode === "aria") {
if (isRoleRef && state?.roleRefsMode === "aria") {
const scope = state.roleRefsFrame ?? page;
return scope.locator(`aria-ref=${normalized}`);
}
@@ -95,39 +96,15 @@ export function refLocator(page: Page, ref: string) {
);
}
const scope = state?.roleRefsFrame ?? page;
const locAny = scope as unknown as {
getByRole: (
role: never,
opts?: { name?: string; exact?: boolean },
) => ReturnType<Page["getByRole"]>;
};
const locator = info.name
? locAny.getByRole(info.role as never, { name: info.name, exact: true })
: locAny.getByRole(info.role as never);
return info.nth !== undefined ? locator.nth(info.nth) : locator;
}
if (AX_REF_PATTERN.test(normalized)) {
const state = pageStates.get(page);
const info = state?.roleRefs?.[normalized];
if (!info) {
throw new Error(
`Unknown ref "${normalized}". Run a new snapshot and use a ref from that snapshot.`,
);
}
const scope = state.roleRefsFrame ?? page;
if (info.domMarker) {
if (!isRoleRef && info.domMarker) {
return scope.locator(`[${BROWSER_REF_MARKER_ATTRIBUTE}="${normalized}"]`);
}
const locAny = scope as unknown as {
getByRole: (
role: never,
opts?: { name?: string; exact?: boolean },
) => ReturnType<Page["getByRole"]>;
};
const locator = info.name
? locAny.getByRole(info.role as never, { name: info.name, exact: true })
: locAny.getByRole(info.role as never);
// Playwright omits empty names and names over 900 UTF-16 units from ARIA text.
// Match that exact bucket before nth; raw AX names (including "") stay explicit.
const locator = scope.getByRole(info.role as never, {
name: info.name ?? /^$|^.{901,}$/s,
exact: true,
});
return info.nth !== undefined ? locator.nth(info.nth) : locator;
}
@@ -58,9 +58,16 @@ describe("pw-session page-scoped CDP client", () => {
expect(sessionDetach).toHaveBeenCalledTimes(1);
});
it("marks backend DOM refs on the page", async () => {
it("requests the document before marking backend DOM refs on the page", async () => {
let documentRequested = false;
const sessionSend = vi.fn(async (method: string, params?: Record<string, unknown>) => {
if (method === "DOM.getDocument") {
documentRequested = true;
}
if (method === "DOM.pushNodesByBackendIdsToFrontend") {
if (!documentRequested) {
throw new Error("Document needs to be requested first");
}
expect(params).toEqual({ backendNodeIds: [42, 84] });
return { nodeIds: [101, 202] };
}
@@ -89,7 +96,8 @@ describe("pw-session page-scoped CDP client", () => {
expect(page.locator).toHaveBeenCalledWith(`[${BROWSER_REF_MARKER_ATTRIBUTE}]`);
expect(evaluateAll).toHaveBeenCalledTimes(1);
expect(sessionSend).toHaveBeenNthCalledWith(1, "DOM.enable", undefined);
expect(marked).toEqual(new Set(["ax1", "ax2"]));
expect(sessionSend).toHaveBeenNthCalledWith(1, "DOM.getDocument", { depth: 0 });
expect(sessionSend).toHaveBeenNthCalledWith(2, "DOM.pushNodesByBackendIdsToFrontend", {
backendNodeIds: [42, 84],
});
@@ -103,7 +111,6 @@ describe("pw-session page-scoped CDP client", () => {
name: BROWSER_REF_MARKER_ATTRIBUTE,
value: "ax2",
});
expect(marked).toEqual(new Set(["ax1", "ax2"]));
expect(sessionDetach).toHaveBeenCalledTimes(1);
});
@@ -99,7 +99,9 @@ export async function markBackendDomRefsOnPage(opts: {
) => Promise<unknown>
)(method, params);
await send("DOM.enable").catch(() => {});
// Backend-id pushes require a bound document in this fresh session.
// getDocument also enables DOM; depth zero avoids fetching the subtree.
await send("DOM.getDocument", { depth: 0 }).catch(() => {});
const backendNodeIds = uniqueValues(refs.map((entry) => Math.floor(entry.backendDOMNodeId)));
const pushed = (await send("DOM.pushNodesByBackendIdsToFrontend", {
@@ -123,14 +123,19 @@ describe("pw-session refLocator", () => {
expect(mocks.frameLocator).not.toHaveBeenCalled();
});
it("uses page getByRole for role refs by default", () => {
it.each([
{ ref: "e1", name: "OK" },
{ ref: "e1", name: "" },
{ ref: "ax12", name: "OK" },
{ ref: "ax12", name: "" },
])("matches the exact name for unmarked $ref with name '$name'", ({ ref, name }) => {
const { page, mocks } = fakePage();
const state = ensurePageState(page);
state.roleRefs = { e1: { role: "button", name: "OK" } };
state.roleRefs = { [ref]: { role: "button", name } };
refLocator(page, "e1");
refLocator(page, ref);
expect(mocks.getByRole).toHaveBeenCalled();
expect(mocks.getByRole).toHaveBeenCalledWith("button", { name, exact: true });
});
it("uses aria-ref locators when refs mode is aria", () => {
@@ -153,16 +158,6 @@ describe("pw-session refLocator", () => {
expect(mocks.locator).toHaveBeenCalledWith(`[${BROWSER_REF_MARKER_ATTRIBUTE}="ax12"]`);
});
it("falls back to role heuristics for ax refs without backend markers", () => {
const { page, mocks } = fakePage();
const state = ensurePageState(page);
state.roleRefs = { ax12: { role: "button", name: "OK" } };
refLocator(page, "ax12");
expect(mocks.getByRole).toHaveBeenCalledWith("button", { name: "OK", exact: true });
});
it("rejects unknown ax refs instead of timing out on aria-ref locators", () => {
const { page, mocks } = fakePage();
@@ -424,6 +424,7 @@ describe("pw-tools-core aria snapshot storage", () => {
nodes: [
{ ref: "ax1", role: "Button", name: "OK", backendDOMNodeId: 42, depth: 0 },
{ ref: "ax2", role: "Button", name: "OK", backendDOMNodeId: 84, depth: 0 },
{ ref: "ax3", role: "Button", name: "", backendDOMNodeId: 126, depth: 0 },
],
});
@@ -434,6 +435,7 @@ describe("pw-tools-core aria snapshot storage", () => {
refs: {
ax1: { role: "button", name: "OK" },
ax2: { role: "button", name: "OK", nth: 1 },
ax3: { role: "button", name: "" },
},
mode: "role",
});
@@ -109,8 +109,8 @@ function buildStoredAriaRefs(
for (const node of nodes) {
const role = normalizeLowercaseStringOrEmpty(node.role) || "unknown";
const name = node.name.trim() || undefined;
const key = `${role}:${name ?? ""}`;
const name = node.name.trim();
const key = `${role}:${name}`;
const nth = counts.get(key) ?? 0;
counts.set(key, nth + 1);
const refsForKey = refsByKey.get(key);
@@ -121,7 +121,7 @@ function buildStoredAriaRefs(
}
refs[node.ref] = {
role,
...(name ? { name } : {}),
name,
...(nth > 0 ? { nth } : {}),
...(markedRefs.has(node.ref) ? { domMarker: true } : {}),
};