From 6a9d6aa09fe5c5e6f081c3c3df2615ca841b123f Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 11 Sep 2026 18:14:26 +0000 Subject: [PATCH 1/3] feat(cli): restore cursor overlay through page.evaluate --- packages/cli/src/lib/driver/commands/mouse.ts | 20 +++++ .../cli/src/lib/driver/commands/runtime.ts | 10 ++- .../src/lib/driver/commands/unavailable.ts | 9 -- packages/cli/src/lib/driver/cursor-overlay.ts | 65 ++++++++++++++ .../cli/src/lib/driver/session-manager.ts | 10 +++ packages/cli/tests/driver-commands.test.ts | 85 ++++++++++++++++++- 6 files changed, 185 insertions(+), 14 deletions(-) delete mode 100644 packages/cli/src/lib/driver/commands/unavailable.ts create mode 100644 packages/cli/src/lib/driver/cursor-overlay.ts diff --git a/packages/cli/src/lib/driver/commands/mouse.ts b/packages/cli/src/lib/driver/commands/mouse.ts index 62268b172f..cb0c309562 100644 --- a/packages/cli/src/lib/driver/commands/mouse.ts +++ b/packages/cli/src/lib/driver/commands/mouse.ts @@ -1,5 +1,7 @@ import { z } from "zod"; +import { updateCursorOverlayPosition } from "../cursor-overlay.js"; +import type { DriverPage, DriverSessionManager } from "../session-manager.js"; import type { DriverCommandHandlers } from "./types.js"; const ButtonSchema = z.enum(["left", "right", "middle"]).optional(); @@ -17,6 +19,7 @@ export const mouseHandlers: DriverCommandHandlers = { .parse(params); assertXPathUnavailable(returnXPath); const page = await manager.activePage(); + await positionCursorOverlay(manager, page, x, y); await page.click(x, y, { ...(button === undefined ? {} : { button }), ...(clickCount === undefined ? {} : { clickCount }), @@ -34,6 +37,7 @@ export const mouseHandlers: DriverCommandHandlers = { .parse(params); assertXPathUnavailable(returnXPath); const page = await manager.activePage(); + await positionCursorOverlay(manager, page, x, y); await page.hover(x, y); return { hovered: true }; }, @@ -50,6 +54,7 @@ export const mouseHandlers: DriverCommandHandlers = { .parse(params); assertXPathUnavailable(returnXPath); const page = await manager.activePage(); + await positionCursorOverlay(manager, page, x, y); await page.scroll(x, y, deltaX, deltaY); return { scrolled: true }; }, @@ -69,15 +74,30 @@ export const mouseHandlers: DriverCommandHandlers = { .parse(params); assertXPathUnavailable(returnXPath); const page = await manager.activePage(); + await positionCursorOverlay(manager, page, fromX, fromY); await page.dragAndDrop(fromX, fromY, toX, toY, { ...(button === undefined ? {} : { button }), ...(delay === undefined ? {} : { delay }), ...(steps === undefined ? {} : { steps }), }); + // A successful drag may navigate and destroy the old execution context. + // The final marker position is visual-only, so do not turn that race into a + // reported drag failure. + await positionCursorOverlay(manager, page, toX, toY).catch(() => undefined); return { dragged: true }; }, }; +async function positionCursorOverlay( + manager: DriverSessionManager, + page: DriverPage, + x: number, + y: number, +): Promise { + if (!manager.isCursorOverlayEnabled(page)) return; + await page.evaluate(updateCursorOverlayPosition, { x, y }); +} + function assertXPathUnavailable(returnXPath: boolean | undefined): void { if (returnXPath) { throw new Error("Coordinate XPath lookup is not exposed by Stagehand V4"); diff --git a/packages/cli/src/lib/driver/commands/runtime.ts b/packages/cli/src/lib/driver/commands/runtime.ts index 9f06c0fdd9..54dbe3f4f1 100644 --- a/packages/cli/src/lib/driver/commands/runtime.ts +++ b/packages/cli/src/lib/driver/commands/runtime.ts @@ -2,8 +2,8 @@ import { promises as fs } from "node:fs"; import { z } from "zod"; +import { CURSOR_OVERLAY_SCRIPT } from "../cursor-overlay.js"; import type { DriverCommandHandlers } from "./types.js"; -import { unavailableCursorOverlay } from "./unavailable.js"; export const runtimeHandlers: DriverCommandHandlers = { async screenshot(manager, params) { @@ -89,7 +89,13 @@ export const runtimeHandlers: DriverCommandHandlers = { return { waited: true }; }, - cursor: unavailableCursorOverlay, + async cursor(manager) { + const page = await manager.activePage(); + await page.addInitScript(CURSOR_OVERLAY_SCRIPT); + await page.evaluate(CURSOR_OVERLAY_SCRIPT); + manager.markCursorOverlayEnabled(page); + return { enabled: true }; + }, }; function parseTimeoutMs(value: string | undefined): number { diff --git a/packages/cli/src/lib/driver/commands/unavailable.ts b/packages/cli/src/lib/driver/commands/unavailable.ts deleted file mode 100644 index 6f1f3d86e0..0000000000 --- a/packages/cli/src/lib/driver/commands/unavailable.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { DriverError } from "../errors.js"; -import type { DriverCommandHandler } from "./types.js"; - -export const unavailableCursorOverlay: DriverCommandHandler = async () => { - throw new DriverError( - "The visible cursor overlay has not been restored in this Stagehand V4 stack layer.", - { code: "cursor_overlay_unavailable" }, - ); -}; diff --git a/packages/cli/src/lib/driver/cursor-overlay.ts b/packages/cli/src/lib/driver/cursor-overlay.ts new file mode 100644 index 0000000000..de966a9d2d --- /dev/null +++ b/packages/cli/src/lib/driver/cursor-overlay.ts @@ -0,0 +1,65 @@ +export const CURSOR_OVERLAY_SCRIPT = `(() => { + if (globalThis !== globalThis.top) return; + + const cursorId = "__browse_cursor_overlay__"; + const ensureCursor = () => { + const existing = document.getElementById(cursorId); + if (existing instanceof HTMLDivElement) return existing; + + const root = document.documentElement || document.body; + if (!root) return null; + + const cursor = document.createElement("div"); + cursor.id = cursorId; + cursor.setAttribute("aria-hidden", "true"); + Object.assign(cursor.style, { + contain: "layout style paint", + height: "24px", + left: "0px", + mixBlendMode: "normal", + pointerEvents: "none", + position: "fixed", + top: "0px", + userSelect: "none", + width: "16px", + willChange: "left,top", + zIndex: "2147483647", + }); + cursor.innerHTML = + ''; + root.appendChild(cursor); + return cursor; + }; + + const moveCursor = (x, y) => { + const cursor = ensureCursor(); + if (!cursor) return; + cursor.style.left = Math.max(0, x) + "px"; + cursor.style.top = Math.max(0, y) + "px"; + }; + + globalThis.__browseMoveCursorOverlay__ = moveCursor; + ensureCursor(); + if (!globalThis.__browseCursorOverlayListenerInstalled__) { + document.addEventListener( + "mousemove", + (event) => { + moveCursor(event.clientX, event.clientY); + }, + { capture: true }, + ); + globalThis.__browseCursorOverlayListenerInstalled__ = true; + } +})()`; + +export function updateCursorOverlayPosition(position: { + x: number; + y: number; +}): void { + const moveCursor = ( + globalThis as typeof globalThis & { + __browseMoveCursorOverlay__?: (x: number, y: number) => void; + } + ).__browseMoveCursorOverlay__; + moveCursor?.(position.x, position.y); +} diff --git a/packages/cli/src/lib/driver/session-manager.ts b/packages/cli/src/lib/driver/session-manager.ts index 7f30ae3951..4e9381b70e 100644 --- a/packages/cli/src/lib/driver/session-manager.ts +++ b/packages/cli/src/lib/driver/session-manager.ts @@ -80,6 +80,7 @@ export class DriverSessionManager { private browserbaseIdentityValue: BrowserbaseIdentity = {}; private consecutiveInitFailures = 0; private context: DriverContext | null = null; + private cursorOverlayPageIds = new Set(); private lastForwardedEnvSignature: string | null = null; private pendingEnv: ForwardedEnv | undefined; private initFailure: InitFailure | null = null; @@ -207,6 +208,7 @@ export class DriverSessionManager { this.stagehand = null; this.browser = null; this.context = null; + this.cursorOverlayPageIds.clear(); this.browserbaseIdentityValue = {}; this.initFailure = null; this.consecutiveInitFailures = 0; @@ -223,6 +225,14 @@ export class DriverSessionManager { return resolveCachedSelector(selector, this.refMaps); } + markCursorOverlayEnabled(page: DriverPage): void { + this.cursorOverlayPageIds.add(page.pageId); + } + + isCursorOverlayEnabled(page: DriverPage): boolean { + return this.cursorOverlayPageIds.has(page.pageId); + } + setRefMaps(refMaps: RefMaps): void { this.refMaps = refMaps; } diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index 58b9d6e55b..6861b6d2b3 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -326,6 +326,7 @@ describe("driver commands", () => { }; const manager = { activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => false), } as unknown as Parameters< NonNullable<(typeof mouseHandlers)["mouse.click"]> >[0]; @@ -385,6 +386,7 @@ describe("driver commands", () => { }; const manager = { activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => false), } as unknown as Parameters< NonNullable<(typeof mouseHandlers)["mouse.click"]> >[0]; @@ -410,6 +412,34 @@ describe("driver commands", () => { expect(page.dragAndDrop).toHaveBeenCalledWith(70, 80, 90, 100, {}); }); + it("keeps a successful drag successful when navigation races the final cursor update", async () => { + const page = { + dragAndDrop: vi.fn(), + evaluate: vi + .fn() + .mockResolvedValueOnce(undefined) + .mockRejectedValueOnce(new Error("Execution context was destroyed")), + }; + const manager = { + activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => true), + } as unknown as Parameters< + NonNullable<(typeof mouseHandlers)["mouse.drag"]> + >[0]; + + await expect( + mouseHandlers["mouse.drag"]!(manager, { + fromX: 10, + fromY: 20, + toX: 30, + toY: 40, + }), + ).resolves.toEqual({ dragged: true }); + + expect(page.dragAndDrop).toHaveBeenCalledWith(10, 20, 30, 40, {}); + expect(page.evaluate).toHaveBeenCalledTimes(2); + }); + it("fails explicitly for the V4 coordinate XPath capability", async () => { const manager = {} as Parameters< NonNullable<(typeof mouseHandlers)["mouse.click"]> @@ -447,10 +477,59 @@ describe("driver commands", () => { expect(network.enable).toHaveBeenCalledWith(page); }); - it("keeps cursor as an explicit capability gap", async () => { + it("installs the CLI-owned cursor overlay", async () => { + const page = { + addInitScript: vi.fn(), + evaluate: vi.fn(), + pageId: "page-1", + }; + const manager = { + activePage: vi.fn(async () => page), + markCursorOverlayEnabled: vi.fn(), + } as unknown as Parameters< + NonNullable<(typeof runtimeHandlers)["cursor"]> + >[0]; + + await expect(runtimeHandlers.cursor!(manager, {})).resolves.toEqual({ + enabled: true, + }); + expect(page.addInitScript).toHaveBeenCalledOnce(); + expect(page.evaluate).toHaveBeenCalledOnce(); + expect(page.addInitScript).toHaveBeenCalledWith( + page.evaluate.mock.calls[0]?.[0], + ); + expect(manager.markCursorOverlayEnabled).toHaveBeenCalledWith(page); + const cursorInstaller = page.evaluate.mock.calls[0]?.[0]; + expect(cursorInstaller).toEqual(expect.any(String)); + expect(cursorInstaller).toContain("__browse_cursor_overlay__"); + expect(cursorInstaller).toContain("globalThis !== globalThis.top"); + expect(cursorInstaller).toContain('"mousemove"'); + }); + + it("moves an enabled overlay from coordinate input before iframe-targeted actions", async () => { + const page = { + evaluate: vi.fn(), + hover: vi.fn(), + pageId: "page-1", + }; + const manager = { + activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => true), + } as unknown as Parameters< + NonNullable<(typeof mouseHandlers)["mouse.hover"]> + >[0]; + await expect( - runtimeHandlers.cursor!({} as never, {}), - ).rejects.toMatchObject({ code: "cursor_overlay_unavailable" }); + mouseHandlers["mouse.hover"]!(manager, { x: 30, y: 40 }), + ).resolves.toEqual({ hovered: true }); + + expect(page.evaluate).toHaveBeenCalledWith(expect.any(Function), { + x: 30, + y: 40, + }); + expect(page.evaluate.mock.invocationCallOrder[0]).toBeLessThan( + page.hover.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY, + ); }); it("selects a remaining tab after closing the active tab", async () => { From 1035fbbf5dc3c42ddaab94d70cda20989af2d713 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Wed, 16 Sep 2026 01:12:07 +0000 Subject: [PATCH 2/3] fix(cli): harden cursor overlay compatibility Addresses review feedback from AzamAbdul. --- packages/cli/src/lib/driver/commands/mouse.ts | 13 ++-- .../cli/src/lib/driver/commands/runtime.ts | 2 +- packages/cli/src/lib/driver/cursor-overlay.ts | 17 ++++- packages/cli/tests/cursor-overlay.test.ts | 50 ++++++++++++++ packages/cli/tests/driver-commands.test.ts | 66 ++++++++++++++++++- 5 files changed, 140 insertions(+), 8 deletions(-) create mode 100644 packages/cli/tests/cursor-overlay.test.ts diff --git a/packages/cli/src/lib/driver/commands/mouse.ts b/packages/cli/src/lib/driver/commands/mouse.ts index cb0c309562..ee03fa8548 100644 --- a/packages/cli/src/lib/driver/commands/mouse.ts +++ b/packages/cli/src/lib/driver/commands/mouse.ts @@ -80,10 +80,7 @@ export const mouseHandlers: DriverCommandHandlers = { ...(delay === undefined ? {} : { delay }), ...(steps === undefined ? {} : { steps }), }); - // A successful drag may navigate and destroy the old execution context. - // The final marker position is visual-only, so do not turn that race into a - // reported drag failure. - await positionCursorOverlay(manager, page, toX, toY).catch(() => undefined); + await positionCursorOverlay(manager, page, toX, toY); return { dragged: true }; }, }; @@ -95,7 +92,13 @@ async function positionCursorOverlay( y: number, ): Promise { if (!manager.isCursorOverlayEnabled(page)) return; - await page.evaluate(updateCursorOverlayPosition, { x, y }); + // The overlay is visual-only. A navigation can destroy its execution + // context, but that must not prevent or invalidate the real mouse action. + try { + await page.evaluate(updateCursorOverlayPosition, { x, y }); + } catch { + // Best-effort parity with V3's cursor updates. + } } function assertXPathUnavailable(returnXPath: boolean | undefined): void { diff --git a/packages/cli/src/lib/driver/commands/runtime.ts b/packages/cli/src/lib/driver/commands/runtime.ts index 54dbe3f4f1..ce2d79b9c0 100644 --- a/packages/cli/src/lib/driver/commands/runtime.ts +++ b/packages/cli/src/lib/driver/commands/runtime.ts @@ -94,7 +94,7 @@ export const runtimeHandlers: DriverCommandHandlers = { await page.addInitScript(CURSOR_OVERLAY_SCRIPT); await page.evaluate(CURSOR_OVERLAY_SCRIPT); manager.markCursorOverlayEnabled(page); - return { enabled: true }; + return { cursor: "enabled" }; }, }; diff --git a/packages/cli/src/lib/driver/cursor-overlay.ts b/packages/cli/src/lib/driver/cursor-overlay.ts index de966a9d2d..6c3858eb5d 100644 --- a/packages/cli/src/lib/driver/cursor-overlay.ts +++ b/packages/cli/src/lib/driver/cursor-overlay.ts @@ -38,8 +38,23 @@ export const CURSOR_OVERLAY_SCRIPT = `(() => { cursor.style.top = Math.max(0, y) + "px"; }; + const installCursor = () => { + if (ensureCursor()) return; + if (globalThis.__browseCursorOverlayDomReadyListenerInstalled__) return; + + document.addEventListener( + "DOMContentLoaded", + () => { + globalThis.__browseCursorOverlayDomReadyListenerInstalled__ = false; + ensureCursor(); + }, + { once: true }, + ); + globalThis.__browseCursorOverlayDomReadyListenerInstalled__ = true; + }; + globalThis.__browseMoveCursorOverlay__ = moveCursor; - ensureCursor(); + installCursor(); if (!globalThis.__browseCursorOverlayListenerInstalled__) { document.addEventListener( "mousemove", diff --git a/packages/cli/tests/cursor-overlay.test.ts b/packages/cli/tests/cursor-overlay.test.ts new file mode 100644 index 0000000000..d0033440f6 --- /dev/null +++ b/packages/cli/tests/cursor-overlay.test.ts @@ -0,0 +1,50 @@ +import { runInNewContext } from "node:vm"; + +import { describe, expect, it, vi } from "vitest"; + +import { CURSOR_OVERLAY_SCRIPT } from "../src/lib/driver/cursor-overlay.js"; + +describe("cursor overlay", () => { + it("installs after DOMContentLoaded when the document root is not ready", () => { + class FakeDiv { + id = ""; + innerHTML = ""; + style: Record = {}; + + setAttribute(): void {} + } + + const elements = new Map(); + const listeners = new Map void>(); + let documentElement: { appendChild: (element: FakeDiv) => void } | null = + null; + const document = { + addEventListener: vi.fn((name: string, listener: () => void) => { + listeners.set(name, listener); + }), + body: null, + createElement: vi.fn(() => new FakeDiv()), + get documentElement() { + return documentElement; + }, + getElementById: vi.fn((id: string) => elements.get(id) ?? null), + }; + + runInNewContext(`globalThis.top = globalThis;\n${CURSOR_OVERLAY_SCRIPT}`, { + document, + HTMLDivElement: FakeDiv, + }); + + expect(elements.size).toBe(0); + expect(listeners.has("DOMContentLoaded")).toBe(true); + + documentElement = { + appendChild(element) { + elements.set(element.id, element); + }, + }; + listeners.get("DOMContentLoaded")!(); + + expect(elements.get("__browse_cursor_overlay__")).toBeInstanceOf(FakeDiv); + }); +}); diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index 6861b6d2b3..1b11e711e1 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -440,6 +440,69 @@ describe("driver commands", () => { expect(page.evaluate).toHaveBeenCalledTimes(2); }); + it("keeps cursor rendering failures from blocking coordinate mouse actions", async () => { + const page = { + click: vi.fn(), + dragAndDrop: vi.fn(), + evaluate: vi.fn().mockRejectedValue(new Error("Execution context lost")), + hover: vi.fn(), + scroll: vi.fn(), + }; + const manager = { + activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => true), + } as unknown as Parameters< + NonNullable<(typeof mouseHandlers)["mouse.click"]> + >[0]; + + await expect( + mouseHandlers["mouse.click"]!(manager, { x: 10, y: 20 }), + ).resolves.toEqual({ clicked: true }); + await expect( + mouseHandlers["mouse.hover"]!(manager, { x: 30, y: 40 }), + ).resolves.toEqual({ hovered: true }); + await expect( + mouseHandlers["mouse.scroll"]!(manager, { + deltaX: 5, + deltaY: 500, + x: 50, + y: 60, + }), + ).resolves.toEqual({ scrolled: true }); + await expect( + mouseHandlers["mouse.drag"]!(manager, { + fromX: 70, + fromY: 80, + toX: 90, + toY: 100, + }), + ).resolves.toEqual({ dragged: true }); + + expect(page.click).toHaveBeenCalledOnce(); + expect(page.hover).toHaveBeenCalledOnce(); + expect(page.scroll).toHaveBeenCalledOnce(); + expect(page.dragAndDrop).toHaveBeenCalledOnce(); + expect(page.evaluate).toHaveBeenCalledTimes(5); + }); + + it("continues to report failures from the real mouse action", async () => { + const actionError = new Error("Mouse input failed"); + const page = { + click: vi.fn().mockRejectedValue(actionError), + evaluate: vi.fn().mockRejectedValue(new Error("Execution context lost")), + }; + const manager = { + activePage: vi.fn(async () => page), + isCursorOverlayEnabled: vi.fn(() => true), + } as unknown as Parameters< + NonNullable<(typeof mouseHandlers)["mouse.click"]> + >[0]; + + await expect( + mouseHandlers["mouse.click"]!(manager, { x: 10, y: 20 }), + ).rejects.toBe(actionError); + }); + it("fails explicitly for the V4 coordinate XPath capability", async () => { const manager = {} as Parameters< NonNullable<(typeof mouseHandlers)["mouse.click"]> @@ -491,7 +554,7 @@ describe("driver commands", () => { >[0]; await expect(runtimeHandlers.cursor!(manager, {})).resolves.toEqual({ - enabled: true, + cursor: "enabled", }); expect(page.addInitScript).toHaveBeenCalledOnce(); expect(page.evaluate).toHaveBeenCalledOnce(); @@ -503,6 +566,7 @@ describe("driver commands", () => { expect(cursorInstaller).toEqual(expect.any(String)); expect(cursorInstaller).toContain("__browse_cursor_overlay__"); expect(cursorInstaller).toContain("globalThis !== globalThis.top"); + expect(cursorInstaller).toContain('"DOMContentLoaded"'); expect(cursorInstaller).toContain('"mousemove"'); }); From 68f6fcb2c8724769b7cb98958e266d4ca694dccf Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Wed, 16 Sep 2026 01:19:19 +0000 Subject: [PATCH 3/3] test(cli): cover cursor overlay invariants Addresses feedback from cubic-dev-ai[bot]. --- packages/cli/tests/cursor-overlay.test.ts | 147 ++++++++++++++++------ 1 file changed, 110 insertions(+), 37 deletions(-) diff --git a/packages/cli/tests/cursor-overlay.test.ts b/packages/cli/tests/cursor-overlay.test.ts index d0033440f6..6ea8b1188e 100644 --- a/packages/cli/tests/cursor-overlay.test.ts +++ b/packages/cli/tests/cursor-overlay.test.ts @@ -1,4 +1,4 @@ -import { runInNewContext } from "node:vm"; +import { createContext, runInContext } from "node:vm"; import { describe, expect, it, vi } from "vitest"; @@ -6,45 +6,118 @@ import { CURSOR_OVERLAY_SCRIPT } from "../src/lib/driver/cursor-overlay.js"; describe("cursor overlay", () => { it("installs after DOMContentLoaded when the document root is not ready", () => { - class FakeDiv { - id = ""; - innerHTML = ""; - style: Record = {}; - - setAttribute(): void {} - } - - const elements = new Map(); - const listeners = new Map void>(); - let documentElement: { appendChild: (element: FakeDiv) => void } | null = - null; - const document = { - addEventListener: vi.fn((name: string, listener: () => void) => { - listeners.set(name, listener); - }), - body: null, - createElement: vi.fn(() => new FakeDiv()), - get documentElement() { - return documentElement; - }, - getElementById: vi.fn((id: string) => elements.get(id) ?? null), - }; - - runInNewContext(`globalThis.top = globalThis;\n${CURSOR_OVERLAY_SCRIPT}`, { - document, - HTMLDivElement: FakeDiv, + const harness = createCursorHarness({ ready: false }); + + harness.install(); + + expect(harness.elements.size).toBe(0); + expect(harness.listeners.has("DOMContentLoaded")).toBe(true); + + harness.makeDocumentReady(); + harness.listeners.get("DOMContentLoaded")!(); + + expect(harness.cursor()).toBeInstanceOf(FakeDiv); + }); + + it("installs a click-through cursor once in an already-ready document", () => { + const harness = createCursorHarness(); + + harness.install(); + + expect(harness.cursor()?.style).toMatchObject({ + left: "0px", + pointerEvents: "none", + position: "fixed", + top: "0px", + zIndex: "2147483647", + }); + expect(harness.listeners.has("DOMContentLoaded")).toBe(false); + expect(harness.listeners.has("mousemove")).toBe(true); + + harness.install(); + + expect(harness.document.createElement).toHaveBeenCalledOnce(); + expect(harness.document.addEventListener).toHaveBeenCalledOnce(); + expect(harness.elements.size).toBe(1); + }); + + it("moves and clamps the cursor from top-document mouse events", () => { + const harness = createCursorHarness(); + harness.install(); + + const mousemove = harness.listeners.get("mousemove")!; + mousemove({ clientX: -25, clientY: 80 }); + expect(harness.cursor()?.style).toMatchObject({ + left: "0px", + top: "80px", }); - expect(elements.size).toBe(0); - expect(listeners.has("DOMContentLoaded")).toBe(true); + mousemove({ clientX: 140, clientY: -10 }); + expect(harness.cursor()?.style).toMatchObject({ + left: "140px", + top: "0px", + }); + }); + + it("does not install inside a child frame", () => { + const harness = createCursorHarness({ topFrame: false }); - documentElement = { - appendChild(element) { - elements.set(element.id, element); - }, - }; - listeners.get("DOMContentLoaded")!(); + harness.install(); - expect(elements.get("__browse_cursor_overlay__")).toBeInstanceOf(FakeDiv); + expect(harness.elements.size).toBe(0); + expect(harness.document.createElement).not.toHaveBeenCalled(); + expect(harness.document.addEventListener).not.toHaveBeenCalled(); }); }); + +class FakeDiv { + id = ""; + innerHTML = ""; + style: Record = {}; + + setAttribute(): void {} +} + +type CursorEvent = { clientX: number; clientY: number }; +type CursorListener = (event?: CursorEvent) => void; + +function createCursorHarness( + options: { ready?: boolean; topFrame?: boolean } = {}, +) { + const elements = new Map(); + const listeners = new Map(); + const root = { + appendChild(element: FakeDiv) { + elements.set(element.id, element); + }, + }; + let documentElement: typeof root | null = + options.ready === false ? null : root; + const document = { + addEventListener: vi.fn((name: string, listener: CursorListener) => { + listeners.set(name, listener); + }), + body: null, + createElement: vi.fn(() => new FakeDiv()), + get documentElement() { + return documentElement; + }, + getElementById: vi.fn((id: string) => elements.get(id) ?? null), + }; + const context = createContext({ document, HTMLDivElement: FakeDiv }); + runInContext( + `globalThis.top = ${options.topFrame === false ? "{}" : "globalThis"}`, + context, + ); + + return { + cursor: () => elements.get("__browse_cursor_overlay__"), + document, + elements, + install: () => runInContext(CURSOR_OVERLAY_SCRIPT, context), + listeners, + makeDocumentReady: () => { + documentElement = root; + }, + }; +}