| 1 | import { |
| 2 | chromium, |
| 3 | type Browser, |
| 4 | type BrowserContext, |
| 5 | type CDPSession, |
| 6 | type Page, |
| 7 | } from "playwright-core"; |
| 8 | import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from "vite-plus/test"; |
| 9 | |
| 10 | import { presentAsChrome } from "./ServerBrowserContexts.ts"; |
| 11 | import * as ServerBrowserPage from "./ServerBrowserPage.ts"; |
| 12 | |
| 13 | describe("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 | |
| 281 | describe("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 | }); |