mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-16 21:31:39 +00:00
fix(browser): invalidate stale roleRefs on main-frame navigation (#104308)
* fix(browser): bind refs to document lifecycle Co-authored-by: Pick-cat <huang.ting3@xydigit.com> * fix(browser): guard absent ref cache entries --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
@@ -9,6 +9,9 @@ import {
|
||||
focusPageByTargetIdViaPlaywright,
|
||||
getPageForTargetId,
|
||||
listPagesViaPlaywright,
|
||||
rememberRoleRefsForTarget,
|
||||
restoreRoleRefsForTarget,
|
||||
ensurePageState,
|
||||
setCdpConnectRetryDelayMsForTests,
|
||||
} from "./pw-session.js";
|
||||
|
||||
@@ -20,12 +23,15 @@ type MockPageSpec = {
|
||||
url?: string;
|
||||
title?: string;
|
||||
targetLookupError?: string;
|
||||
navigateDuringTargetLookup?: boolean;
|
||||
subframeNavigationDuringTargetLookup?: boolean;
|
||||
};
|
||||
|
||||
type BrowserMockBundle = {
|
||||
browser: import("playwright-core").Browser;
|
||||
browserClose: ReturnType<typeof vi.fn>;
|
||||
pages: import("playwright-core").Page[];
|
||||
pageHandlers: Array<Map<string, Array<(...args: unknown[]) => void>>>;
|
||||
pageActions: Array<{
|
||||
bringToFront: ReturnType<typeof vi.fn>;
|
||||
close: ReturnType<typeof vi.fn>;
|
||||
@@ -39,12 +45,18 @@ function makeBrowser(pages: MockPageSpec[]): BrowserMockBundle {
|
||||
bringToFront: vi.fn(async () => {}),
|
||||
close: vi.fn(async () => {}),
|
||||
}));
|
||||
const pageHandlers = pages.map(() => new Map<string, Array<(...args: unknown[]) => void>>());
|
||||
|
||||
const pageObjects = pages.map((spec, index) => {
|
||||
const actions = pageActions[index]!;
|
||||
const handlers = pageHandlers[index]!;
|
||||
const mainFrame = { url: () => spec.url ?? `https://page-${index + 1}.example` };
|
||||
const page = {
|
||||
on: vi.fn(),
|
||||
on: vi.fn((event: string, handler: (...args: unknown[]) => void) => {
|
||||
handlers.set(event, [...(handlers.get(event) ?? []), handler]);
|
||||
}),
|
||||
context: () => context,
|
||||
mainFrame: () => mainFrame,
|
||||
title: vi.fn(async () => spec.title ?? spec.targetId ?? `page-${index + 1}`),
|
||||
url: vi.fn(() => spec.url ?? `https://page-${index + 1}.example`),
|
||||
bringToFront: actions.bringToFront,
|
||||
@@ -67,6 +79,16 @@ function makeBrowser(pages: MockPageSpec[]): BrowserMockBundle {
|
||||
if (spec?.targetLookupError) {
|
||||
throw new Error(spec.targetLookupError);
|
||||
}
|
||||
if (spec?.navigateDuringTargetLookup) {
|
||||
const pageIndex = pageObjects.indexOf(page);
|
||||
pageHandlers[pageIndex]?.get("framenavigated")?.[0]?.(page.mainFrame());
|
||||
}
|
||||
if (spec?.subframeNavigationDuringTargetLookup) {
|
||||
const pageIndex = pageObjects.indexOf(page);
|
||||
pageHandlers[pageIndex]?.get("framenavigated")?.[0]?.({
|
||||
url: () => "https://frame.example/new",
|
||||
});
|
||||
}
|
||||
return { targetInfo: { targetId: spec?.targetId } };
|
||||
}),
|
||||
detach: vi.fn(async () => {}),
|
||||
@@ -81,7 +103,7 @@ function makeBrowser(pages: MockPageSpec[]): BrowserMockBundle {
|
||||
close: browserClose,
|
||||
} as unknown as import("playwright-core").Browser;
|
||||
|
||||
return { browser, browserClose, pages: pageObjects, pageActions };
|
||||
return { browser, browserClose, pages: pageObjects, pageHandlers, pageActions };
|
||||
}
|
||||
|
||||
function installBrowser(pages: MockPageSpec[]): BrowserMockBundle {
|
||||
@@ -160,6 +182,58 @@ describe("pw-session getPageForTargetId", () => {
|
||||
expect(resolved).toBe(pages[1]);
|
||||
});
|
||||
|
||||
it("binds a resolved replacement page to target-cache invalidation", async () => {
|
||||
const cdpUrl = "http://127.0.0.1:18792";
|
||||
const targetId = "TARGET_REPLACED";
|
||||
rememberRoleRefsForTarget({
|
||||
cdpUrl,
|
||||
targetId,
|
||||
refs: { e1: { role: "button", name: "Old document" } },
|
||||
});
|
||||
const { pages, pageHandlers } = installBrowser([{ targetId }]);
|
||||
const page = await getPageForTargetId({ cdpUrl, targetId });
|
||||
const navigationHandler = pageHandlers[0]?.get("framenavigated")?.[0];
|
||||
navigationHandler?.(page.mainFrame());
|
||||
restoreRoleRefsForTarget({ cdpUrl, targetId, page });
|
||||
|
||||
expect(navigationHandler).toBeTypeOf("function");
|
||||
expect(ensurePageState(page).roleRefs).toBeUndefined();
|
||||
expect(page).toBe(pages[0]);
|
||||
});
|
||||
|
||||
it("invalidates target refs when navigation races target lookup", async () => {
|
||||
const cdpUrl = "http://127.0.0.1:18792";
|
||||
const targetId = "TARGET_RACE";
|
||||
rememberRoleRefsForTarget({
|
||||
cdpUrl,
|
||||
targetId,
|
||||
refs: { e1: { role: "button", name: "Old document" } },
|
||||
});
|
||||
installBrowser([{ targetId, navigateDuringTargetLookup: true }]);
|
||||
|
||||
const page = await getPageForTargetId({ cdpUrl, targetId });
|
||||
restoreRoleRefsForTarget({ cdpUrl, targetId, page });
|
||||
|
||||
expect(ensurePageState(page).roleRefs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("invalidates page-wide aria refs when subframe navigation races target lookup", async () => {
|
||||
const cdpUrl = "http://127.0.0.1:18792";
|
||||
const targetId = "TARGET_ARIA_RACE";
|
||||
rememberRoleRefsForTarget({
|
||||
cdpUrl,
|
||||
targetId,
|
||||
refs: { e1: { role: "button", name: "Old embedded document" } },
|
||||
mode: "aria",
|
||||
});
|
||||
installBrowser([{ targetId, subframeNavigationDuringTargetLookup: true }]);
|
||||
|
||||
const page = await getPageForTargetId({ cdpUrl, targetId });
|
||||
restoreRoleRefsForTarget({ cdpUrl, targetId, page });
|
||||
|
||||
expect(ensurePageState(page).roleRefs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("focuses and closes only the exact target when URLs are identical", async () => {
|
||||
const { pageActions } = installBrowser([
|
||||
{ targetId: "TARGET_A", url: "https://same.example" },
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Browser tests cover pw session plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import type { Page } from "playwright-core";
|
||||
import type { Frame, Page } from "playwright-core";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { DEFAULT_DOWNLOAD_DIR } from "./paths.js";
|
||||
import { createDownloadCaptureForPage } from "./pw-download-capture.js";
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
refLocator,
|
||||
rememberRoleRefsForTarget,
|
||||
restoreRoleRefsForTarget,
|
||||
storeRoleRefsForTarget,
|
||||
} from "./pw-session.js";
|
||||
import { BROWSER_REF_MARKER_ATTRIBUTE } from "./pw-session.page-cdp.js";
|
||||
|
||||
@@ -29,8 +30,12 @@ afterEach(() => {
|
||||
function fakePage(): {
|
||||
page: Page;
|
||||
handlers: Map<string, Array<(...args: unknown[]) => void>>;
|
||||
mainFrame: { url: () => string };
|
||||
selectedFrame: Frame;
|
||||
mocks: {
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
frameGetByRole: ReturnType<typeof vi.fn>;
|
||||
frameQuery: ReturnType<typeof vi.fn>;
|
||||
getByRole: ReturnType<typeof vi.fn>;
|
||||
frameLocator: ReturnType<typeof vi.fn>;
|
||||
locator: ReturnType<typeof vi.fn>;
|
||||
@@ -56,17 +61,37 @@ function fakePage(): {
|
||||
getByRole: vi.fn(() => ({ nth: vi.fn(() => ({ ok: true })) })),
|
||||
locator: vi.fn(() => ({ nth: vi.fn(() => ({ ok: true })) })),
|
||||
}));
|
||||
const locator = vi.fn(() => ({ nth: vi.fn(() => ({ ok: true })) }));
|
||||
const frameGetByRole = vi.fn(() => ({ nth: vi.fn(() => ({ ok: true })) }));
|
||||
const frameQuery = vi.fn(() => ({ nth: vi.fn(() => ({ ok: true })) }));
|
||||
const selectedFrame = {
|
||||
url: () => "https://frame.example.com",
|
||||
getByRole: frameGetByRole,
|
||||
locator: frameQuery,
|
||||
} as unknown as Frame;
|
||||
const locator = vi.fn(() => ({
|
||||
nth: vi.fn(() => ({ ok: true })),
|
||||
elementHandle: vi.fn(async () => ({
|
||||
contentFrame: vi.fn(async () => selectedFrame),
|
||||
})),
|
||||
}));
|
||||
|
||||
const mainFrame = { url: () => "https://test.example.com" };
|
||||
const page = {
|
||||
on,
|
||||
off,
|
||||
getByRole,
|
||||
frameLocator,
|
||||
locator,
|
||||
mainFrame: () => mainFrame,
|
||||
} as unknown as Page;
|
||||
|
||||
return { page, handlers, mocks: { on, getByRole, frameLocator, locator } };
|
||||
return {
|
||||
page,
|
||||
handlers,
|
||||
mainFrame,
|
||||
selectedFrame,
|
||||
mocks: { on, frameGetByRole, frameQuery, getByRole, frameLocator, locator },
|
||||
};
|
||||
}
|
||||
|
||||
function firstSavePath(saveAs: MutableDownload["saveAs"]): string {
|
||||
@@ -82,15 +107,20 @@ function firstSavePath(saveAs: MutableDownload["saveAs"]): string {
|
||||
}
|
||||
|
||||
describe("pw-session refLocator", () => {
|
||||
it("uses frameLocator for role refs when snapshot was scoped to a frame", () => {
|
||||
const { page, mocks } = fakePage();
|
||||
it("uses the captured Frame for refs from a frame-scoped snapshot", () => {
|
||||
const { page, selectedFrame, mocks } = fakePage();
|
||||
const state = ensurePageState(page);
|
||||
state.roleRefs = { e1: { role: "button", name: "OK" } };
|
||||
state.roleRefsFrameSelector = "iframe#main";
|
||||
state.roleRefsFrame = selectedFrame;
|
||||
|
||||
refLocator(page, "e1");
|
||||
|
||||
expect(mocks.frameLocator).toHaveBeenCalledWith("iframe#main");
|
||||
expect(mocks.frameGetByRole).toHaveBeenCalledWith("button", {
|
||||
name: "OK",
|
||||
exact: true,
|
||||
});
|
||||
expect(mocks.frameLocator).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses page getByRole for role refs by default", () => {
|
||||
@@ -142,7 +172,7 @@ describe("pw-session refLocator", () => {
|
||||
});
|
||||
|
||||
describe("pw-session role refs cache", () => {
|
||||
it("restores refs for a different Page instance (same CDP targetId)", () => {
|
||||
it("restores unscoped refs for a different Page instance", () => {
|
||||
const cdpUrl = "http://127.0.0.1:9222";
|
||||
const targetId = "t1";
|
||||
|
||||
@@ -150,15 +180,136 @@ describe("pw-session role refs cache", () => {
|
||||
cdpUrl,
|
||||
targetId,
|
||||
refs: { e1: { role: "button", name: "OK" } },
|
||||
frameSelector: "iframe#main",
|
||||
mode: "role",
|
||||
});
|
||||
|
||||
const { page, mocks } = fakePage();
|
||||
restoreRoleRefsForTarget({ cdpUrl, targetId, page });
|
||||
|
||||
refLocator(page, "e1");
|
||||
expect(mocks.frameLocator).toHaveBeenCalledWith("iframe#main");
|
||||
expect(mocks.getByRole).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not restore frame-scoped refs onto a replacement Page", () => {
|
||||
const cdpUrl = "http://127.0.0.1:9222";
|
||||
const targetId = "frame-target";
|
||||
rememberRoleRefsForTarget({
|
||||
cdpUrl,
|
||||
targetId,
|
||||
refs: { e1: { role: "button", name: "Old frame" } },
|
||||
frameSelector: "iframe#main",
|
||||
mode: "role",
|
||||
});
|
||||
|
||||
const { page } = fakePage();
|
||||
restoreRoleRefsForTarget({ cdpUrl, targetId, page });
|
||||
|
||||
expect(ensurePageState(page).roleRefs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("invalidates cached refs for replacement Pages after main-frame navigation", () => {
|
||||
const cdpUrl = "http://127.0.0.1:9222";
|
||||
const targetId = "t1";
|
||||
|
||||
const { page: pageA, handlers, mainFrame } = fakePage();
|
||||
storeRoleRefsForTarget({
|
||||
page: pageA,
|
||||
cdpUrl,
|
||||
targetId,
|
||||
refs: { e1: { role: "button", name: "Page A" } },
|
||||
mode: "role",
|
||||
});
|
||||
|
||||
handlers.get("framenavigated")?.[0]?.(mainFrame);
|
||||
expect(ensurePageState(pageA).roleRefs).toBeUndefined();
|
||||
|
||||
const { page: pageB } = fakePage();
|
||||
restoreRoleRefsForTarget({ cdpUrl, targetId, page: pageB });
|
||||
expect(ensurePageState(pageB).roleRefs).toBeUndefined();
|
||||
});
|
||||
|
||||
it("restores fresh post-navigation refs for a replacement Page", () => {
|
||||
const cdpUrl = "http://127.0.0.1:9222";
|
||||
const targetId = "t1";
|
||||
|
||||
const { page: pageA, handlers, mainFrame } = fakePage();
|
||||
storeRoleRefsForTarget({
|
||||
page: pageA,
|
||||
cdpUrl,
|
||||
targetId,
|
||||
refs: { e1: { role: "button", name: "Page A" } },
|
||||
mode: "role",
|
||||
});
|
||||
handlers.get("framenavigated")?.[0]?.(mainFrame);
|
||||
|
||||
storeRoleRefsForTarget({
|
||||
page: pageA,
|
||||
cdpUrl,
|
||||
targetId,
|
||||
refs: { e1: { role: "heading", name: "Page B" } },
|
||||
mode: "aria",
|
||||
});
|
||||
|
||||
const { page: pageB } = fakePage();
|
||||
restoreRoleRefsForTarget({ cdpUrl, targetId, page: pageB });
|
||||
expect(ensurePageState(pageB).roleRefs).toEqual({
|
||||
e1: { role: "heading", name: "Page B" },
|
||||
});
|
||||
expect(ensurePageState(pageB).roleRefsMode).toBe("aria");
|
||||
});
|
||||
|
||||
it("does not let an obsolete Page invalidate a newer cache generation", () => {
|
||||
const cdpUrl = "http://127.0.0.1:9222";
|
||||
const targetId = "shared-target";
|
||||
const { page: oldPage, handlers, mainFrame } = fakePage();
|
||||
storeRoleRefsForTarget({
|
||||
page: oldPage,
|
||||
cdpUrl,
|
||||
targetId,
|
||||
refs: { e1: { role: "button", name: "Old document" } },
|
||||
mode: "role",
|
||||
});
|
||||
|
||||
const { page: currentPage } = fakePage();
|
||||
storeRoleRefsForTarget({
|
||||
page: currentPage,
|
||||
cdpUrl,
|
||||
targetId,
|
||||
refs: { e1: { role: "heading", name: "Current document" } },
|
||||
mode: "aria",
|
||||
});
|
||||
handlers.get("framenavigated")?.[0]?.(mainFrame);
|
||||
|
||||
const { page: replacementPage } = fakePage();
|
||||
restoreRoleRefsForTarget({ cdpUrl, targetId, page: replacementPage });
|
||||
expect(ensurePageState(replacementPage).roleRefs).toEqual({
|
||||
e1: { role: "heading", name: "Current document" },
|
||||
});
|
||||
expect(ensurePageState(replacementPage).roleRefsMode).toBe("aria");
|
||||
});
|
||||
|
||||
it.each(["framenavigated", "framedetached"] as const)(
|
||||
"invalidates page-wide aria refs when a subframe emits %s",
|
||||
(event) => {
|
||||
const cdpUrl = "http://127.0.0.1:9222";
|
||||
const targetId = `aria-target-${event}`;
|
||||
const { page, handlers } = fakePage();
|
||||
storeRoleRefsForTarget({
|
||||
page,
|
||||
cdpUrl,
|
||||
targetId,
|
||||
refs: { e1: { role: "button", name: "Embedded" } },
|
||||
mode: "aria",
|
||||
});
|
||||
|
||||
handlers.get(event)?.[0]?.({ url: () => "https://frame.example/new" });
|
||||
|
||||
expect(ensurePageState(page).roleRefs).toBeUndefined();
|
||||
const { page: replacementPage } = fakePage();
|
||||
restoreRoleRefsForTarget({ cdpUrl, targetId, page: replacementPage });
|
||||
expect(ensurePageState(replacementPage).roleRefs).toBeUndefined();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("pw-session ensurePageState", () => {
|
||||
@@ -691,4 +842,104 @@ describe("pw-session ensurePageState", () => {
|
||||
expect(state2.errors).toStrictEqual([]);
|
||||
expect(state2.requests).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ mode: "role" as const, frameSelector: undefined },
|
||||
{ mode: "aria" as const, frameSelector: "iframe#content" },
|
||||
])("clears $mode role refs on main-frame navigation", ({ mode, frameSelector }) => {
|
||||
const { page, handlers, mainFrame, selectedFrame } = fakePage();
|
||||
const state = ensurePageState(page);
|
||||
|
||||
storeRoleRefsForTarget({
|
||||
page,
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
targetId: "t1",
|
||||
refs: { e1: { role: "button", name: "Save" } },
|
||||
frameSelector,
|
||||
frame: frameSelector ? selectedFrame : undefined,
|
||||
mode,
|
||||
});
|
||||
expect(state.roleRefs).toBeDefined();
|
||||
expect(state.roleRefsMode).toBe(mode);
|
||||
expect(state.roleRefsFrameSelector).toBe(frameSelector);
|
||||
|
||||
handlers.get("framenavigated")?.[0]?.(mainFrame);
|
||||
|
||||
expect(state.roleRefs).toBeUndefined();
|
||||
expect(state.roleRefsMode).toBeUndefined();
|
||||
expect(state.roleRefsFrameSelector).toBeUndefined();
|
||||
expect(() => refLocator(page, "e1")).toThrow(/Unknown ref/);
|
||||
});
|
||||
|
||||
it("preserves role refs on subframe navigation", () => {
|
||||
const { page, handlers } = fakePage();
|
||||
const state = ensurePageState(page);
|
||||
|
||||
storeRoleRefsForTarget({
|
||||
page,
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
targetId: "t1",
|
||||
refs: { e1: { role: "button", name: "MainBtn" } },
|
||||
mode: "role",
|
||||
});
|
||||
expect(Object.keys(state.roleRefs!)).toHaveLength(1);
|
||||
|
||||
const subframe = { url: () => "https://ads.example.com/widget" };
|
||||
handlers.get("framenavigated")?.[0]?.(subframe);
|
||||
|
||||
expect(state.roleRefs).toBeDefined();
|
||||
expect(Object.keys(state.roleRefs!)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each(["framenavigated", "framedetached"] as const)(
|
||||
"clears frame-scoped role refs on %s",
|
||||
(event) => {
|
||||
const { page, handlers, selectedFrame } = fakePage();
|
||||
const state = ensurePageState(page);
|
||||
|
||||
storeRoleRefsForTarget({
|
||||
page,
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
targetId: "t1",
|
||||
refs: { e1: { role: "button", name: "Inside frame" } },
|
||||
frameSelector: "iframe#content",
|
||||
frame: selectedFrame,
|
||||
mode: "role",
|
||||
});
|
||||
|
||||
handlers.get(event)?.[0]?.({ url: () => "https://ads.example.com" });
|
||||
expect(state.roleRefs).toBeDefined();
|
||||
|
||||
handlers.get(event)?.[0]?.(selectedFrame);
|
||||
expect(state.roleRefs).toBeUndefined();
|
||||
expect(state.roleRefsFrameSelector).toBeUndefined();
|
||||
},
|
||||
);
|
||||
|
||||
it("allows new snapshot to store fresh refs after navigation clear", () => {
|
||||
const { page, handlers, mainFrame } = fakePage();
|
||||
const state = ensurePageState(page);
|
||||
|
||||
storeRoleRefsForTarget({
|
||||
page,
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
targetId: "t1",
|
||||
refs: { e1: { role: "button", name: "PageA-Btn" } },
|
||||
mode: "role",
|
||||
});
|
||||
|
||||
handlers.get("framenavigated")?.[0]?.(mainFrame);
|
||||
expect(state.roleRefs).toBeUndefined();
|
||||
|
||||
storeRoleRefsForTarget({
|
||||
page,
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
targetId: "t1",
|
||||
refs: { e1: { role: "heading", name: "PageB Title" } },
|
||||
mode: "aria",
|
||||
});
|
||||
expect(state.roleRefs).toBeDefined();
|
||||
expect(state.roleRefs!.e1.role).toBe("heading");
|
||||
expect(state.roleRefsMode).toBe("aria");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
BrowserContext,
|
||||
ConsoleMessage,
|
||||
Dialog,
|
||||
Frame,
|
||||
Page,
|
||||
Request,
|
||||
Response,
|
||||
@@ -183,13 +184,22 @@ type PageState = {
|
||||
roleRefs?: Record<string, { role: string; name?: string; nth?: number; domMarker?: boolean }>;
|
||||
roleRefsMode?: "role" | "aria";
|
||||
roleRefsFrameSelector?: string;
|
||||
roleRefsFrame?: Frame;
|
||||
/** Target-cache entry owned by the current role refs. */
|
||||
roleRefsTargetKey?: string;
|
||||
/** Cache generation restored or stored by this Page. */
|
||||
roleRefsTargetGeneration?: number;
|
||||
/** Main-frame navigation observed before this Page could be bound to its target. */
|
||||
roleRefsInvalidBeforeGeneration?: number;
|
||||
/** Any frame changed before target binding; invalidates only page-wide aria refs. */
|
||||
roleRefsAriaInvalidBeforeGeneration?: number;
|
||||
};
|
||||
|
||||
type RoleRefs = NonNullable<PageState["roleRefs"]>;
|
||||
type RoleRefsCacheEntry = {
|
||||
refs: RoleRefs;
|
||||
frameSelector?: string;
|
||||
mode?: NonNullable<PageState["roleRefsMode"]>;
|
||||
generation: number;
|
||||
};
|
||||
|
||||
type ContextState = {
|
||||
@@ -205,6 +215,7 @@ const observedPages = new WeakSet<Page>();
|
||||
// for the same CDP target across requests.
|
||||
const roleRefsByTarget = new Map<string, RoleRefsCacheEntry>();
|
||||
const MAX_ROLE_REFS_CACHE = 50;
|
||||
let roleRefsCacheGeneration = 0;
|
||||
|
||||
const MAX_CONSOLE_MESSAGES = 500;
|
||||
const MAX_PAGE_ERRORS = 200;
|
||||
@@ -522,6 +533,33 @@ function roleRefsKey(cdpUrl: string, targetId: string) {
|
||||
return targetKey(cdpUrl, targetId);
|
||||
}
|
||||
|
||||
function bindRoleRefsTarget(page: Page, cdpUrl: string, targetId?: string | null): void {
|
||||
const normalizedTargetId = normalizeOptionalString(targetId ?? undefined);
|
||||
if (!normalizedTargetId) {
|
||||
return;
|
||||
}
|
||||
const state = ensurePageState(page);
|
||||
const key = roleRefsKey(cdpUrl, normalizedTargetId);
|
||||
const invalidBeforeGeneration = state.roleRefsInvalidBeforeGeneration;
|
||||
const ariaInvalidBeforeGeneration = state.roleRefsAriaInvalidBeforeGeneration;
|
||||
const cached = roleRefsByTarget.get(key);
|
||||
if (
|
||||
cached &&
|
||||
((invalidBeforeGeneration !== undefined && cached.generation <= invalidBeforeGeneration) ||
|
||||
(ariaInvalidBeforeGeneration !== undefined &&
|
||||
cached.mode === "aria" &&
|
||||
cached.generation <= ariaInvalidBeforeGeneration))
|
||||
) {
|
||||
roleRefsByTarget.delete(key);
|
||||
}
|
||||
state.roleRefsInvalidBeforeGeneration = undefined;
|
||||
state.roleRefsAriaInvalidBeforeGeneration = undefined;
|
||||
state.roleRefsTargetKey = key;
|
||||
if (!state.roleRefs) {
|
||||
state.roleRefsTargetGeneration = roleRefsByTarget.get(key)?.generation;
|
||||
}
|
||||
}
|
||||
|
||||
function isBlockedTarget(cdpUrl: string, targetId?: string): boolean {
|
||||
const normalizedTargetId = normalizeOptionalString(targetId) ?? "";
|
||||
if (!normalizedTargetId) {
|
||||
@@ -796,15 +834,23 @@ export function rememberRoleRefsForTarget(opts: {
|
||||
refs: RoleRefs;
|
||||
frameSelector?: string;
|
||||
mode?: NonNullable<PageState["roleRefsMode"]>;
|
||||
}): void {
|
||||
}): number | undefined {
|
||||
const targetId = normalizeOptionalString(opts.targetId) ?? "";
|
||||
if (!targetId) {
|
||||
return;
|
||||
return undefined;
|
||||
}
|
||||
roleRefsByTarget.set(roleRefsKey(opts.cdpUrl, targetId), {
|
||||
const key = roleRefsKey(opts.cdpUrl, targetId);
|
||||
// A selector cannot preserve frame identity across replacement Page objects.
|
||||
// Frame-scoped refs remain local and require a fresh snapshot after reconnect.
|
||||
if (opts.frameSelector) {
|
||||
roleRefsByTarget.delete(key);
|
||||
return undefined;
|
||||
}
|
||||
const generation = ++roleRefsCacheGeneration;
|
||||
roleRefsByTarget.set(key, {
|
||||
refs: opts.refs,
|
||||
...(opts.frameSelector ? { frameSelector: opts.frameSelector } : {}),
|
||||
...(opts.mode ? { mode: opts.mode } : {}),
|
||||
generation,
|
||||
});
|
||||
while (roleRefsByTarget.size > MAX_ROLE_REFS_CACHE) {
|
||||
const first = roleRefsByTarget.keys().next();
|
||||
@@ -813,6 +859,7 @@ export function rememberRoleRefsForTarget(opts: {
|
||||
}
|
||||
roleRefsByTarget.delete(first.value);
|
||||
}
|
||||
return generation;
|
||||
}
|
||||
|
||||
/** Store role refs on the page and target cache. */
|
||||
@@ -822,17 +869,25 @@ export function storeRoleRefsForTarget(opts: {
|
||||
targetId?: string;
|
||||
refs: RoleRefs;
|
||||
frameSelector?: string;
|
||||
frame?: Frame;
|
||||
mode: NonNullable<PageState["roleRefsMode"]>;
|
||||
}): void {
|
||||
if (opts.frameSelector && !opts.frame) {
|
||||
throw new Error("Frame-scoped role refs require their resolved frame.");
|
||||
}
|
||||
const state = ensurePageState(opts.page);
|
||||
state.roleRefs = opts.refs;
|
||||
state.roleRefsFrameSelector = opts.frameSelector;
|
||||
state.roleRefsFrame = opts.frame;
|
||||
state.roleRefsMode = opts.mode;
|
||||
const targetId = normalizeOptionalString(opts.targetId);
|
||||
if (!targetId) {
|
||||
state.roleRefsTargetKey = undefined;
|
||||
state.roleRefsTargetGeneration = undefined;
|
||||
return;
|
||||
}
|
||||
rememberRoleRefsForTarget({
|
||||
bindRoleRefsTarget(opts.page, opts.cdpUrl, targetId);
|
||||
state.roleRefsTargetGeneration = rememberRoleRefsForTarget({
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId,
|
||||
refs: opts.refs,
|
||||
@@ -841,6 +896,33 @@ export function storeRoleRefsForTarget(opts: {
|
||||
});
|
||||
}
|
||||
|
||||
function clearRoleRefs(state: PageState): void {
|
||||
if (state.roleRefsTargetKey) {
|
||||
const cached = roleRefsByTarget.get(state.roleRefsTargetKey);
|
||||
// A delayed event from an obsolete Page must not erase refs that a newer
|
||||
// wrapper stored for the same target after this Page's generation.
|
||||
if (cached?.generation === state.roleRefsTargetGeneration) {
|
||||
roleRefsByTarget.delete(state.roleRefsTargetKey);
|
||||
}
|
||||
}
|
||||
state.roleRefs = undefined;
|
||||
state.roleRefsMode = undefined;
|
||||
state.roleRefsFrameSelector = undefined;
|
||||
state.roleRefsFrame = undefined;
|
||||
state.roleRefsTargetKey = undefined;
|
||||
state.roleRefsTargetGeneration = undefined;
|
||||
}
|
||||
|
||||
function currentTargetRoleRefsMode(
|
||||
state: PageState,
|
||||
): NonNullable<PageState["roleRefsMode"]> | undefined {
|
||||
if (!state.roleRefsTargetKey) {
|
||||
return undefined;
|
||||
}
|
||||
const cached = roleRefsByTarget.get(state.roleRefsTargetKey);
|
||||
return cached && cached.generation === state.roleRefsTargetGeneration ? cached.mode : undefined;
|
||||
}
|
||||
|
||||
/** Restore cached role refs onto a newly resolved page. */
|
||||
export function restoreRoleRefsForTarget(opts: {
|
||||
cdpUrl: string;
|
||||
@@ -851,7 +933,9 @@ export function restoreRoleRefsForTarget(opts: {
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
const cached = roleRefsByTarget.get(roleRefsKey(opts.cdpUrl, targetId));
|
||||
const cacheKey = roleRefsKey(opts.cdpUrl, targetId);
|
||||
bindRoleRefsTarget(opts.page, opts.cdpUrl, targetId);
|
||||
const cached = roleRefsByTarget.get(cacheKey);
|
||||
if (!cached) {
|
||||
return;
|
||||
}
|
||||
@@ -859,8 +943,9 @@ export function restoreRoleRefsForTarget(opts: {
|
||||
if (state.roleRefs) {
|
||||
return;
|
||||
}
|
||||
state.roleRefsTargetKey = cacheKey;
|
||||
state.roleRefsTargetGeneration = cached.generation;
|
||||
state.roleRefs = cached.refs;
|
||||
state.roleRefsFrameSelector = cached.frameSelector;
|
||||
state.roleRefsMode = cached.mode;
|
||||
}
|
||||
|
||||
@@ -982,6 +1067,45 @@ export function ensurePageState(page: Page): PageState {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
page.on("framenavigated", (frame) => {
|
||||
// Clear role refs on main-frame navigation so stale refs from the
|
||||
// previous page are never used to locate elements on the new page.
|
||||
// Unscoped refs survive subframe navigation. Frame-scoped refs are
|
||||
// invalid only when their exact Frame replaces its document.
|
||||
const isMainFrame = frame === page.mainFrame();
|
||||
const targetWasBound = state.roleRefsTargetKey !== undefined;
|
||||
if (!targetWasBound) {
|
||||
// Target discovery is asynchronous. Remember an early navigation so
|
||||
// binding removes only cache generations that already existed. Refs a
|
||||
// newer Page stores after this event must survive the delayed lookup.
|
||||
if (isMainFrame) {
|
||||
state.roleRefsInvalidBeforeGeneration = roleRefsCacheGeneration;
|
||||
} else {
|
||||
state.roleRefsAriaInvalidBeforeGeneration = roleRefsCacheGeneration;
|
||||
}
|
||||
}
|
||||
const pageWideAriaRefs =
|
||||
state.roleRefsMode === "aria" || currentTargetRoleRefsMode(state) === "aria";
|
||||
if (isMainFrame || pageWideAriaRefs || frame === state.roleRefsFrame) {
|
||||
// Replacement Page objects restore from this target cache, so local
|
||||
// clearing alone could resurrect refs from the previous document.
|
||||
clearRoleRefs(state);
|
||||
}
|
||||
});
|
||||
page.on("framedetached", (frame) => {
|
||||
if (!state.roleRefsTargetKey) {
|
||||
if (frame === page.mainFrame()) {
|
||||
state.roleRefsInvalidBeforeGeneration = roleRefsCacheGeneration;
|
||||
} else {
|
||||
state.roleRefsAriaInvalidBeforeGeneration = roleRefsCacheGeneration;
|
||||
}
|
||||
}
|
||||
const pageWideAriaRefs =
|
||||
state.roleRefsMode === "aria" || currentTargetRoleRefsMode(state) === "aria";
|
||||
if (pageWideAriaRefs || frame === state.roleRefsFrame) {
|
||||
clearRoleRefs(state);
|
||||
}
|
||||
});
|
||||
page.on("close", () => {
|
||||
clearArmedDialogResponse(state);
|
||||
for (const controller of state.dialogAbortControllers) {
|
||||
@@ -1307,6 +1431,7 @@ async function partitionAccessiblePages(opts: { cdpUrl: string; pages: Page[] })
|
||||
blockedCount += 1;
|
||||
continue;
|
||||
}
|
||||
ensurePageState(page);
|
||||
const targetId = await pageTargetId(page).catch(() => null);
|
||||
// Fail closed when we cannot resolve a target id while this session has
|
||||
// quarantined targets; otherwise a blocked tab can become selectable.
|
||||
@@ -1322,6 +1447,7 @@ async function partitionAccessiblePages(opts: { cdpUrl: string; pages: Page[] })
|
||||
blockedCount += 1;
|
||||
continue;
|
||||
}
|
||||
bindRoleRefsTarget(page, opts.cdpUrl, targetId);
|
||||
accessible.push({ page, targetId });
|
||||
}
|
||||
return { accessible, blockedCount };
|
||||
@@ -1362,13 +1488,15 @@ async function getPageForTargetIdOnce(opts: {
|
||||
}
|
||||
throw new Error("No pages available in the connected browser.");
|
||||
}
|
||||
const first = accessible[0].page;
|
||||
const first = accessible[0];
|
||||
if (!opts.targetId) {
|
||||
return first;
|
||||
bindRoleRefsTarget(first.page, opts.cdpUrl, first.targetId);
|
||||
return first.page;
|
||||
}
|
||||
const found = accessible.find((entry) => entry.targetId === opts.targetId)?.page;
|
||||
const found = accessible.find((entry) => entry.targetId === opts.targetId);
|
||||
if (found) {
|
||||
return found;
|
||||
bindRoleRefsTarget(found.page, opts.cdpUrl, found.targetId);
|
||||
return found.page;
|
||||
}
|
||||
throw new BrowserTabNotFoundError();
|
||||
}
|
||||
@@ -1863,9 +1991,7 @@ export function refLocator(page: Page, ref: string) {
|
||||
if (/^e\d+$/.test(normalized)) {
|
||||
const state = pageStates.get(page);
|
||||
if (state?.roleRefsMode === "aria") {
|
||||
const scope = state.roleRefsFrameSelector
|
||||
? page.frameLocator(state.roleRefsFrameSelector)
|
||||
: page;
|
||||
const scope = state.roleRefsFrame ?? page;
|
||||
return scope.locator(`aria-ref=${normalized}`);
|
||||
}
|
||||
const info = state?.roleRefs?.[normalized];
|
||||
@@ -1874,9 +2000,7 @@ export function refLocator(page: Page, ref: string) {
|
||||
`Unknown ref "${normalized}". Run a new snapshot and use a ref from that snapshot.`,
|
||||
);
|
||||
}
|
||||
const scope = state?.roleRefsFrameSelector
|
||||
? page.frameLocator(state.roleRefsFrameSelector)
|
||||
: page;
|
||||
const scope = state?.roleRefsFrame ?? page;
|
||||
const locAny = scope as unknown as {
|
||||
getByRole: (
|
||||
role: never,
|
||||
@@ -1897,9 +2021,7 @@ export function refLocator(page: Page, ref: string) {
|
||||
`Unknown ref "${normalized}". Run a new snapshot and use a ref from that snapshot.`,
|
||||
);
|
||||
}
|
||||
const scope = state.roleRefsFrameSelector
|
||||
? page.frameLocator(state.roleRefsFrameSelector)
|
||||
: page;
|
||||
const scope = state.roleRefsFrame ?? page;
|
||||
if (info.domMarker) {
|
||||
return scope.locator(`[${BROWSER_REF_MARKER_ATTRIBUTE}="${normalized}"]`);
|
||||
}
|
||||
|
||||
@@ -58,6 +58,16 @@ function requireScopedCdpClientOptions(): ScopedCdpClientOptions {
|
||||
return options as ScopedCdpClientOptions;
|
||||
}
|
||||
|
||||
function makeAriaSnapshotPage(ariaSnapshot: ReturnType<typeof vi.fn>) {
|
||||
const mainFrame = { id: "main-frame" };
|
||||
return {
|
||||
ariaSnapshot,
|
||||
mainFrame: () => mainFrame,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
describe("pw-tools-core aria snapshot storage", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -149,7 +159,7 @@ describe("pw-tools-core aria snapshot storage", () => {
|
||||
|
||||
it("forwards an explicit timeoutMs into the role-aria Playwright ariaSnapshot call", async () => {
|
||||
const ariaSnapshotMock = vi.fn().mockResolvedValue("");
|
||||
const page = { ariaSnapshot: ariaSnapshotMock };
|
||||
const page = makeAriaSnapshotPage(ariaSnapshotMock);
|
||||
getPageForTargetId.mockResolvedValue(page);
|
||||
|
||||
const mod = await import("./pw-tools-core.snapshot.js");
|
||||
@@ -165,7 +175,7 @@ describe("pw-tools-core aria snapshot storage", () => {
|
||||
|
||||
it("uses the default snapshot timeout for non-finite role-aria timeouts", async () => {
|
||||
const ariaSnapshotMock = vi.fn().mockResolvedValue("");
|
||||
const page = { ariaSnapshot: ariaSnapshotMock };
|
||||
const page = makeAriaSnapshotPage(ariaSnapshotMock);
|
||||
getPageForTargetId.mockResolvedValue(page);
|
||||
|
||||
const mod = await import("./pw-tools-core.snapshot.js");
|
||||
@@ -179,9 +189,112 @@ describe("pw-tools-core aria snapshot storage", () => {
|
||||
expect(ariaSnapshotMock).toHaveBeenCalledWith({ mode: "ai", timeout: 5000 });
|
||||
});
|
||||
|
||||
it("rejects page-wide refs when a subframe navigates during capture", async () => {
|
||||
const mainFrame = { id: "main-frame" };
|
||||
const subframe = { id: "subframe" };
|
||||
const handlers = new Map<string, (frame: unknown) => void>();
|
||||
const page = {
|
||||
ariaSnapshot: vi.fn(async () => {
|
||||
handlers.get("framenavigated")?.(subframe);
|
||||
return '- button "Save"';
|
||||
}),
|
||||
mainFrame: () => mainFrame,
|
||||
on: vi.fn((event: string, handler: (frame: unknown) => void) => {
|
||||
handlers.set(event, handler);
|
||||
}),
|
||||
off: vi.fn(),
|
||||
};
|
||||
getPageForTargetId.mockResolvedValue(page);
|
||||
|
||||
const mod = await import("./pw-tools-core.snapshot.js");
|
||||
await expect(
|
||||
mod.snapshotRoleViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
targetId: "tab-1",
|
||||
refsMode: "aria",
|
||||
}),
|
||||
).rejects.toThrow("Frame changed while its browser snapshot was being captured");
|
||||
|
||||
expect(storeRoleRefsForTarget).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stores frame-scoped refs with the exact captured frame", async () => {
|
||||
const ariaSnapshot = vi.fn(async () => '- button "Save"');
|
||||
const frame = { id: "frame-1", locator: vi.fn(() => ({ ariaSnapshot })) };
|
||||
const page = {
|
||||
locator: vi.fn(() => ({
|
||||
elementHandle: vi.fn(async () => ({
|
||||
contentFrame: vi.fn(async () => frame),
|
||||
dispose: vi.fn(async () => {}),
|
||||
})),
|
||||
})),
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
};
|
||||
getPageForTargetId.mockResolvedValue(page);
|
||||
|
||||
const mod = await import("./pw-tools-core.snapshot.js");
|
||||
await mod.snapshotRoleViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
targetId: "tab-1",
|
||||
frameSelector: "iframe#content",
|
||||
});
|
||||
|
||||
expect(storeRoleRefsForTarget).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
page,
|
||||
frameSelector: "iframe#content",
|
||||
frame,
|
||||
}),
|
||||
);
|
||||
expect(page.off).toHaveBeenCalledWith("framenavigated", expect.any(Function));
|
||||
});
|
||||
|
||||
it.each(["framenavigated", "framedetached"] as const)(
|
||||
"rejects frame-scoped refs when that frame emits %s during capture",
|
||||
async (event) => {
|
||||
const handlers = new Map<string, (frame: unknown) => void>();
|
||||
const frame = {
|
||||
id: "frame-1",
|
||||
locator: vi.fn(() => ({
|
||||
ariaSnapshot: vi.fn(async () => {
|
||||
handlers.get(event)?.(frame);
|
||||
return '- button "Save"';
|
||||
}),
|
||||
})),
|
||||
};
|
||||
const page = {
|
||||
locator: vi.fn(() => ({
|
||||
elementHandle: vi.fn(async () => ({
|
||||
contentFrame: vi.fn(async () => frame),
|
||||
dispose: vi.fn(async () => {}),
|
||||
})),
|
||||
})),
|
||||
on: vi.fn((eventName: string, handler: (frame: unknown) => void) => {
|
||||
handlers.set(eventName, handler);
|
||||
}),
|
||||
off: vi.fn(),
|
||||
};
|
||||
getPageForTargetId.mockResolvedValue(page);
|
||||
|
||||
const mod = await import("./pw-tools-core.snapshot.js");
|
||||
await expect(
|
||||
mod.snapshotRoleViaPlaywright({
|
||||
cdpUrl: "http://127.0.0.1:9222",
|
||||
targetId: "tab-1",
|
||||
frameSelector: "iframe#content",
|
||||
}),
|
||||
).rejects.toThrow("Frame changed while its browser snapshot was being captured");
|
||||
|
||||
expect(storeRoleRefsForTarget).not.toHaveBeenCalled();
|
||||
expect(page.off).toHaveBeenCalledWith("framenavigated", expect.any(Function));
|
||||
expect(page.off).toHaveBeenCalledWith("framedetached", expect.any(Function));
|
||||
},
|
||||
);
|
||||
|
||||
it("uses the default snapshot timeout for non-finite ai snapshot timeouts", async () => {
|
||||
const ariaSnapshotMock = vi.fn().mockResolvedValue("");
|
||||
const page = { ariaSnapshot: ariaSnapshotMock };
|
||||
const page = makeAriaSnapshotPage(ariaSnapshotMock);
|
||||
getPageForTargetId.mockResolvedValue(page);
|
||||
|
||||
const mod = await import("./pw-tools-core.snapshot.js");
|
||||
@@ -199,7 +312,7 @@ describe("pw-tools-core aria snapshot storage", () => {
|
||||
const second = `- button "Hidden ${"X".repeat(100)} 🙂" [ref=e2]`;
|
||||
const marker = "[...TRUNCATED - page too large]";
|
||||
const ariaSnapshotMock = vi.fn().mockResolvedValue(`${first}\n${second}`);
|
||||
const page = { ariaSnapshot: ariaSnapshotMock };
|
||||
const page = makeAriaSnapshotPage(ariaSnapshotMock);
|
||||
getPageForTargetId.mockResolvedValue(page);
|
||||
|
||||
const mod = await import("./pw-tools-core.snapshot.js");
|
||||
@@ -227,7 +340,7 @@ describe("pw-tools-core aria snapshot storage", () => {
|
||||
const ariaSnapshotMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(`${first}\n- button "Hidden ${"X".repeat(100)}" [ref=e2]`);
|
||||
const page = { ariaSnapshot: ariaSnapshotMock };
|
||||
const page = makeAriaSnapshotPage(ariaSnapshotMock);
|
||||
getPageForTargetId.mockResolvedValue(page);
|
||||
|
||||
const mod = await import("./pw-tools-core.snapshot.js");
|
||||
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { truncateUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
|
||||
import type { Page } from "playwright-core";
|
||||
import type { Frame, Page } from "playwright-core";
|
||||
import type { SsrFPolicy } from "../infra/net/ssrf.js";
|
||||
import { ACT_MAX_VIEWPORT_DIMENSION } from "./act-policy.js";
|
||||
import { type AriaSnapshotNode, formatAriaSnapshot, type RawAXNode } from "./cdp.js";
|
||||
@@ -267,27 +267,61 @@ export async function snapshotAiViaPlaywright(opts: {
|
||||
ssrfPolicy: opts.ssrfPolicy,
|
||||
});
|
||||
|
||||
let snapshot = await page.ariaSnapshot({
|
||||
mode: "ai",
|
||||
timeout: resolveSnapshotTimeoutMs(opts.timeoutMs),
|
||||
});
|
||||
if (opts.urls) {
|
||||
snapshot = appendSnapshotUrls(snapshot, await collectSnapshotUrls(page));
|
||||
}
|
||||
const built = buildRoleSnapshotFromAiSnapshot(snapshot);
|
||||
const finalized = finalizeRoleSnapshot({
|
||||
snapshot,
|
||||
refs: built.refs,
|
||||
maxChars: opts.maxChars,
|
||||
});
|
||||
storeRoleRefsForTarget({
|
||||
return await withSnapshotFrameGuard({
|
||||
page,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
refs: finalized.refs,
|
||||
mode: "aria",
|
||||
run: async (isFrameCurrent) => {
|
||||
let snapshot = await page.ariaSnapshot({
|
||||
mode: "ai",
|
||||
timeout: resolveSnapshotTimeoutMs(opts.timeoutMs),
|
||||
});
|
||||
if (opts.urls) {
|
||||
snapshot = appendSnapshotUrls(snapshot, await collectSnapshotUrls(page));
|
||||
}
|
||||
const built = buildRoleSnapshotFromAiSnapshot(snapshot);
|
||||
const finalized = finalizeRoleSnapshot({
|
||||
snapshot,
|
||||
refs: built.refs,
|
||||
maxChars: opts.maxChars,
|
||||
});
|
||||
assertSnapshotFrameCurrent(isFrameCurrent);
|
||||
storeRoleRefsForTarget({
|
||||
page,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
refs: finalized.refs,
|
||||
mode: "aria",
|
||||
});
|
||||
return finalized;
|
||||
},
|
||||
});
|
||||
return finalized;
|
||||
}
|
||||
|
||||
function assertSnapshotFrameCurrent(isFrameCurrent: () => boolean): void {
|
||||
if (!isFrameCurrent()) {
|
||||
throw new Error("Frame changed while its browser snapshot was being captured; retry.");
|
||||
}
|
||||
}
|
||||
|
||||
async function withSnapshotFrameGuard<T>(opts: {
|
||||
page: Page;
|
||||
/** Omit for page-wide AI snapshots, whose refs can include every frame. */
|
||||
frame?: Frame;
|
||||
run: (isFrameCurrent: () => boolean) => Promise<T>;
|
||||
}): Promise<T> {
|
||||
let frameCurrent = true;
|
||||
const onFrameChanged = (frame: Frame) => {
|
||||
if (!opts.frame || frame === opts.frame) {
|
||||
frameCurrent = false;
|
||||
}
|
||||
};
|
||||
opts.page.on("framenavigated", onFrameChanged);
|
||||
opts.page.on("framedetached", onFrameChanged);
|
||||
try {
|
||||
return await opts.run(() => frameCurrent);
|
||||
} finally {
|
||||
opts.page.off("framenavigated", onFrameChanged);
|
||||
opts.page.off("framedetached", onFrameChanged);
|
||||
}
|
||||
}
|
||||
|
||||
async function finalizeRoleSnapshotViaPlaywright(params: {
|
||||
@@ -295,6 +329,8 @@ async function finalizeRoleSnapshotViaPlaywright(params: {
|
||||
cdpUrl: string;
|
||||
targetId?: string;
|
||||
frameSelector?: string;
|
||||
frame?: Frame;
|
||||
isFrameCurrent?: () => boolean;
|
||||
mode: "aria" | "role";
|
||||
built: { snapshot: string; refs: RoleRefMap };
|
||||
urls?: boolean;
|
||||
@@ -308,6 +344,9 @@ async function finalizeRoleSnapshotViaPlaywright(params: {
|
||||
const snapshot = params.urls
|
||||
? appendSnapshotUrls(params.built.snapshot, await collectSnapshotUrls(params.page))
|
||||
: params.built.snapshot;
|
||||
if (params.isFrameCurrent) {
|
||||
assertSnapshotFrameCurrent(params.isFrameCurrent);
|
||||
}
|
||||
const finalized = finalizeRoleSnapshot({
|
||||
snapshot,
|
||||
refs: params.built.refs,
|
||||
@@ -319,6 +358,7 @@ async function finalizeRoleSnapshotViaPlaywright(params: {
|
||||
targetId: params.targetId,
|
||||
refs: finalized.refs,
|
||||
...(params.frameSelector ? { frameSelector: params.frameSelector } : {}),
|
||||
...(params.frame ? { frame: params.frame } : {}),
|
||||
mode: params.mode,
|
||||
});
|
||||
return finalized;
|
||||
@@ -354,43 +394,70 @@ export async function snapshotRoleViaPlaywright(opts: {
|
||||
if (normalizeOptionalString(opts.selector) || normalizeOptionalString(opts.frameSelector)) {
|
||||
throw new Error("refs=aria does not support selector/frame snapshots yet.");
|
||||
}
|
||||
const snapshot = await page.ariaSnapshot({
|
||||
mode: "ai",
|
||||
timeout: ariaSnapshotTimeout,
|
||||
});
|
||||
const built = buildRoleSnapshotFromAiSnapshot(snapshot, opts.options);
|
||||
return await finalizeRoleSnapshotViaPlaywright({
|
||||
return await withSnapshotFrameGuard({
|
||||
page,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
built,
|
||||
mode: "aria",
|
||||
urls: opts.urls,
|
||||
maxChars: opts.maxChars,
|
||||
run: async (isFrameCurrent) => {
|
||||
const snapshot = await page.ariaSnapshot({
|
||||
mode: "ai",
|
||||
timeout: ariaSnapshotTimeout,
|
||||
});
|
||||
const built = buildRoleSnapshotFromAiSnapshot(snapshot, opts.options);
|
||||
return await finalizeRoleSnapshotViaPlaywright({
|
||||
page,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
isFrameCurrent,
|
||||
built,
|
||||
mode: "aria",
|
||||
urls: opts.urls,
|
||||
maxChars: opts.maxChars,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const frameSelector = normalizeOptionalString(opts.frameSelector) ?? "";
|
||||
const selector = normalizeOptionalString(opts.selector) ?? "";
|
||||
const locator = frameSelector
|
||||
? selector
|
||||
? page.frameLocator(frameSelector).locator(selector)
|
||||
: page.frameLocator(frameSelector).locator(":root")
|
||||
: selector
|
||||
? page.locator(selector)
|
||||
: page.locator(":root");
|
||||
|
||||
const ariaSnapshot = await locator.ariaSnapshot({ timeout: ariaSnapshotTimeout });
|
||||
const built = buildRoleSnapshotFromAriaSnapshot(ariaSnapshot ?? "", opts.options);
|
||||
return await finalizeRoleSnapshotViaPlaywright({
|
||||
const frameElement = frameSelector
|
||||
? await page.locator(frameSelector).elementHandle({ timeout: ariaSnapshotTimeout })
|
||||
: undefined;
|
||||
let frame: Frame | undefined;
|
||||
if (frameElement) {
|
||||
try {
|
||||
frame = (await frameElement.contentFrame()) ?? undefined;
|
||||
} finally {
|
||||
await frameElement.dispose();
|
||||
}
|
||||
}
|
||||
if (frameSelector && !frame) {
|
||||
throw new Error("Frame was unavailable while its browser snapshot was being captured.");
|
||||
}
|
||||
return await withSnapshotFrameGuard({
|
||||
page,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
frameSelector: frameSelector || undefined,
|
||||
built,
|
||||
mode: "role",
|
||||
urls: opts.urls,
|
||||
maxChars: opts.maxChars,
|
||||
frame: frame ?? page.mainFrame(),
|
||||
run: async (isFrameCurrent) => {
|
||||
const locator = frame
|
||||
? selector
|
||||
? frame.locator(selector)
|
||||
: frame.locator(":root")
|
||||
: selector
|
||||
? page.locator(selector)
|
||||
: page.locator(":root");
|
||||
const ariaSnapshot = await locator.ariaSnapshot({ timeout: ariaSnapshotTimeout });
|
||||
const built = buildRoleSnapshotFromAriaSnapshot(ariaSnapshot ?? "", opts.options);
|
||||
return await finalizeRoleSnapshotViaPlaywright({
|
||||
page,
|
||||
cdpUrl: opts.cdpUrl,
|
||||
targetId: opts.targetId,
|
||||
frameSelector: frameSelector || undefined,
|
||||
frame: frame ?? undefined,
|
||||
isFrameCurrent,
|
||||
built,
|
||||
mode: "role",
|
||||
urls: opts.urls,
|
||||
maxChars: opts.maxChars,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user