1import {
2 chromium,
3 type Browser,
4 type BrowserContext,
5 type CDPSession,
6 type Page,
7} from "playwright-core";
8import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test";
9
10import { presentAsChrome } from "./ServerBrowserContexts.ts";
11import * as ServerBrowserPage from "./ServerBrowserPage.ts";
12
13describe("server browser element refs", () => {
14 let browser: Browser;
15 let context: BrowserContext;
16 let page: Page;
17 let cdp: CDPSession;
18
19 beforeAll(async () => {
20 browser = await chromium.launch({ headless: true });
21 });
22 afterAll(async () => {
23 await browser?.close();
24 });
25 beforeEach(async () => {
26 context = await browser.newContext();
27 page = await context.newPage();
28 cdp = await context.newCDPSession(page);
29 });
30 afterEach(async () => {
31 await context.close();
32 });
33
34 const takeSnapshot = () =>
35 ServerBrowserPage.snapshot({
36 page,
37 cdp,
38 renderScale: 1,
39 consoleEntries: [],
40 networkEntries: [],
41 actionTimeline: [],
42 });
43 const locators = (tree: unknown) => {
44 expect(typeof tree).toBe("string");
45 return Array.from(
46 String(tree).matchAll(/\[ref=([^\]]+)\]/g),
47 (match) => `aria-ref=${match[1]}`,
48 );
49 };
50 const buttonLocator = (tree: unknown, name: string) => {
51 const line = String(tree)
52 .split("\n")
53 .find((line) => line.includes(`button "${name}"`));
54 const locator = locators(line)[0];
55 expect(locator).toBeDefined();
56 return locator!;
57 };
58 const repeatedRows = `<ul>${Array.from({ length: 5 }, (_, i) => `<li>row ${i + 1}<button data-testid="delete-row" onclick="this.parentElement.remove()">delete</button></li>`).join("")}</ul>`;
59
60 it("clicks the fifth repeated delete control without touching row one", async () => {
61 await page.setContent(repeatedRows);
62 const result = await takeSnapshot();
63 const buttons = String(result.accessibilityTree)
64 .split("\n")
65 .filter((line) => line.includes('button "delete"'));
66 expect(buttons).toHaveLength(5);
67 await ServerBrowserPage.click(page, { locator: locators(buttons[4])[0]!, timeoutMs: 1_000 });
68 expect(await page.locator("li").allTextContents()).toEqual([
69 "row 1delete",
70 "row 2delete",
71 "row 3delete",
72 "row 4delete",
73 ]);
74 });
75
76 it("returns from a click once it opens a dialog", async () => {
77 await page.setContent(
78 `<button onclick="document.body.dataset.answer = String(confirm('sure?'))">Confirm</button>`,
79 );
80 const dialog = new Promise<import("playwright-core").Dialog>((resolve) =>
81 page.once("dialog", resolve),
82 );
83 await ServerBrowserPage.click(page, {
84 locator: buttonLocator((await takeSnapshot()).accessibilityTree, "Confirm"),
85 timeoutMs: 1_000,
86 });
87 await (await dialog).accept();
88 await expect.poll(() => page.locator("body").getAttribute("data-answer")).toBe("true");
89 });
90
91 it("rejects ambiguous CSS controls without clicking any row", async () => {
92 await page.setContent(repeatedRows);
93 await expect(
94 ServerBrowserPage.click(page, {
95 selector: 'button[data-testid="delete-row"]',
96 timeoutMs: 1_000,
97 }),
98 ).rejects.toThrow(/strict mode violation/);
99 expect(await page.locator("li").count()).toBe(5);
100 });
101
102 it("does not retarget a removed ref to a replacement node", async () => {
103 await page.setContent("<button onclick=\"this.textContent='clicked'\">original</button>");
104 const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "original");
105 await page.setContent("<button onclick=\"this.textContent='clicked'\">replacement</button>");
106 await expect(ServerBrowserPage.click(page, { locator, timeoutMs: 100 })).rejects.toThrow();
107 expect(await page.locator("button").textContent()).toBe("replacement");
108 });
109
110 it("targets iframe input refs and preserves the parent form", async () => {
111 await page.setContent(
112 '<input aria-label="parent"><iframe srcdoc="<input aria-label=child>"></iframe>',
113 );
114 await page.frameLocator("iframe").getByRole("textbox").waitFor();
115 const result = await takeSnapshot();
116 const line = String(result.accessibilityTree)
117 .split("\n")
118 .find((line) => line.includes('textbox "child"'));
119 const locator = locators(line)[0]!;
120 await ServerBrowserPage.type(page, { locator, text: "inside iframe", clear: true });
121 expect(await page.frameLocator("iframe").getByRole("textbox").inputValue()).toBe(
122 "inside iframe",
123 );
124 expect(await page.getByRole("textbox", { name: "parent" }).inputValue()).toBe("");
125 });
126
127 it("rejects refs from another tab", async () => {
128 await page.setContent("<button>same label</button>");
129 const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "same label");
130 const other = await context.newPage();
131 await other.setContent("<button>same label</button>");
132 await expect(ServerBrowserPage.click(other, { locator })).rejects.toThrow(/another tab/);
133 });
134
135 it("revokes refs on takeover and issues usable refs in the next snapshot", async () => {
136 await page.setContent("<button onclick=\"this.textContent='clicked'\">continue</button>");
137 const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "continue");
138 ServerBrowserPage.invalidateRefs(page);
139 await expect(ServerBrowserPage.click(page, { locator })).rejects.toThrow(/stale/);
140 const fresh = buttonLocator((await takeSnapshot()).accessibilityTree, "continue");
141 await ServerBrowserPage.click(page, { locator: fresh });
142 expect(await page.locator("button").textContent()).toBe("clicked");
143 });
144
145 it("revokes refs after navigation even when labels are identical", async () => {
146 await page.goto("data:text/html,<button>continue</button>");
147 const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "continue");
148 await page.goto("data:text/html,<button>continue</button><p>new document</p>");
149 await expect(ServerBrowserPage.click(page, { locator })).rejects.toThrow(/stale/);
150 });
151
152 it("only accepts refs from the most recent snapshot", async () => {
153 await page.setContent("<button>continue</button>");
154 const locator = buttonLocator((await takeSnapshot()).accessibilityTree, "continue");
155 await takeSnapshot();
156 await expect(ServerBrowserPage.click(page, { locator })).rejects.toThrow(/stale/);
157 });
158
159 it.each(["aria-ref=e1", " aria-ref=e1", "css=body >> aria-ref=e1"])(
160 "does not allow native refs to bypass generation validation (%s)",
161 async (locator) => {
162 await page.setContent("<button>continue</button>");
163 await takeSnapshot();
164 await expect(ServerBrowserPage.click(page, { locator })).rejects.toThrow(/stale/);
165 },
166 );
167
168 it("preserves CSS selectors containing an aria-ref attribute", async () => {
169 await page.setContent(
170 '<button aria-ref="save" onclick="this.textContent=\'saved\'">save</button>',
171 );
172 await ServerBrowserPage.click(page, { selector: 'button[aria-ref="save"]' });
173 expect(await page.locator("button").textContent()).toBe("saved");
174 });
175
176 it("preserves quoted attribute values containing ref engine text", async () => {
177 await page.setContent(
178 '<button data-example=" >> aria-ref=e1" onclick="this.textContent=\'saved\'">save</button>',
179 );
180 await ServerBrowserPage.click(page, { selector: 'button[data-example=" >> aria-ref=e1"]' });
181 expect(await page.locator("button").textContent()).toBe("saved");
182 });
183
184 it("right-clicks, double-clicks, hovers, selects, and drags like a pointer user", async () => {
185 await page.setContent(`
186 <style>#menu { position: absolute; display: none } #hover:hover + #menu { display: block }</style>
187 <button id="target">target</button>
188 <div id="hover">hover me</div><div id="menu">menu item</div>
189 <select id="size"><option value="s">Small</option><option value="l">Large</option></select>
190 <div id="card" draggable="true">card</div><div id="lane" style="height:40px">lane</div>
191 <p id="log"></p>
192 <script>
193 const log = (text) => (document.getElementById("log").textContent += text + ";");
194 const target = document.getElementById("target");
195 target.addEventListener("contextmenu", (event) => { event.preventDefault(); log("context"); });
196 target.addEventListener("dblclick", () => log("dblclick"));
197 document.getElementById("card").addEventListener("dragstart", (event) =>
198 event.dataTransfer.setData("text/plain", "card"),
199 );
200 for (const type of ["dragenter", "dragover"])
201 document.getElementById("lane").addEventListener(type, (event) => event.preventDefault());
202 document.getElementById("lane").addEventListener("drop", () => log("drop"));
203 </script>`);
204 await ServerBrowserPage.click(page, { locator: "#target", button: "right" });
205 await ServerBrowserPage.click(page, { locator: "#target", clickCount: 2 });
206 await ServerBrowserPage.hover(page, { locator: "#hover" });
207 expect(await page.isVisible("#menu")).toBe(true);
208 // A visible label selects the same option as its value.
209 expect(await ServerBrowserPage.select(page, { locator: "#size", values: ["Large"] })).toEqual({
210 selected: ["l"],
211 });
212 await ServerBrowserPage.drag(page, { source: "#card", target: "#lane" });
213 expect(await page.textContent("#log")).toBe("context;dblclick;drop;");
214 await expect(
215 ServerBrowserPage.select(page, { locator: "#target", values: ["s"] }),
216 ).rejects.toMatchObject({ tag: "PreviewAutomationTargetNotEditableError" });
217 });
218
219 it("shows the agent's pointer at each target before the action reaches the page", async () => {
220 await page.setContent(`
221 <button id="go" style="position:absolute;left:100px;top:40px;width:80px;height:20px">go</button>
222 <div id="tip" style="position:absolute;left:300px;top:40px;width:60px;height:20px">tip</div>
223 <div id="card" draggable="true" style="position:absolute;left:20px;top:120px;width:40px;height:40px">card</div>
224 <div id="lane" style="position:absolute;left:220px;top:120px;width:100px;height:40px">lane</div>
225 <script>
226 window.seen = [];
227 go.onclick = () => seen.push("click");
228 tip.onmouseenter = () => seen.push("hover");
229 card.ondragstart = (event) => event.dataTransfer.setData("text/plain", "card");
230 lane.ondragover = (event) => event.preventDefault();
231 lane.ondrop = () => seen.push("drop");
232 </script>`);
233 const shown: Array<string> = [];
234 const pointer: ServerBrowserPage.PointerReporter = async ({ x, y }, phase) => {
235 const seen = await page.evaluate("window.seen.length");
236 shown.push(`${phase}@${Math.round(x)},${Math.round(y)} after ${seen}`);
237 };
238 await ServerBrowserPage.click(page, { locator: "#go" }, pointer);
239 await ServerBrowserPage.hover(page, { locator: "#tip" }, pointer);
240 await ServerBrowserPage.drag(page, { source: "#card", target: "#lane" }, pointer);
241 expect(shown).toEqual([
242 "click@140,50 after 0",
243 "move@330,50 after 1",
244 "move@40,140 after 2",
245 "move@270,140 after 2",
246 ]);
247 expect(await page.evaluate("window.seen")).toEqual(["click", "hover", "drop"]);
248 });
249
250 it("presents a headless page as Chrome, with client hints that agree", async () => {
251 await presentAsChrome(cdp, { platform: "linux", arch: "x64" });
252 // userAgentData exists only in secure contexts; https comes from a route.
253 let secChUa: string | undefined;
254 await page.route("https://example.test/", (route) => {
255 secChUa = route.request().headers()["sec-ch-ua"];
256 return route.fulfill({ contentType: "text/html", body: "<p>hi</p>" });
257 });
258 await page.goto("https://example.test/");
259 const identity = (await page.evaluate(`(async () => ({
260 userAgent: navigator.userAgent,
261 brands: navigator.userAgentData.brands.map((brand) => brand.brand),
262 full: (await navigator.userAgentData.getHighEntropyValues(["fullVersionList"]))
263 .fullVersionList.map((brand) => brand.brand),
264 }))()`)) as { userAgent: string; brands: string[]; full: string[] };
265 expect(identity.userAgent).not.toContain("Headless");
266 expect(identity.userAgent).toMatch(/ Chrome\/\d+\.0\.0\.0 /);
267 expect(identity.brands).toContain("Google Chrome");
268 expect([...identity.brands, ...identity.full].join()).not.toContain("Headless");
269 expect(secChUa).toContain('"Google Chrome"');
270 expect(secChUa).not.toContain("Headless");
271 });
272
273 it("stops an evaluation at its deadline so the page answers the next one", async () => {
274 await expect(
275 ServerBrowserPage.evaluate(cdp, { expression: "for (;;) {}" }, 200),
276 ).rejects.toMatchObject({ tag: "PreviewAutomationTimeoutError" });
277 expect(await ServerBrowserPage.evaluate(cdp, { expression: "1 + 1" }, 2_000)).toBe(2);
278 });
279});
280
281describe("server browser drag", () => {
282 it("fails the drag, not the server, when it times out while the cursor is still moving", async () => {
283 const timedOut = new Error("locator.dragTo: Timeout 30000ms exceeded.");
284 const box = { x: 0, y: 0, width: 20, height: 20 };
285 const targetBox = Promise.withResolvers<typeof box>();
286 // Only the calls `drag` makes. The drag fails at once; the target's position arrives only
287 // when the test hands it over, after the rejection has had a turn to go unobserved.
288 const page = {
289 locator: (selector: string) =>
290 selector === "#card"
291 ? {
292 scrollIntoViewIfNeeded: async () => {},
293 boundingBox: async () => box,
294 dragTo: () => Promise.reject(timedOut),
295 }
296 : { boundingBox: () => targetBox.promise },
297 } as unknown as Page;
298 const unobserved: Array<unknown> = [];
299 const onUnhandled = (reason: unknown) => unobserved.push(reason);
300 process.on("unhandledRejection", onUnhandled);
301 try {
302 let finished = false;
303 const settled = ServerBrowserPage.drag(page, { source: "#card", target: "#lane" }).then(
304 () => null,
305 (error: unknown) => error,
306 );
307 void settled.then(() => (finished = true));
308 // Two macrotask turns: Node reports a rejection nobody observed after the first.
309 await new Promise((resolve) => setImmediate(resolve));
310 await new Promise((resolve) => setImmediate(resolve));
311 // The failed drag waits for the cursor to reach the target before it reports.
312 expect(finished).toBe(false);
313 targetBox.resolve(box);
314
315 expect(await settled).toBe(timedOut);
316 expect(unobserved).toEqual([]);
317 } finally {
318 process.off("unhandledRejection", onUnhandled);
319 }
320 });
321});