diff --git a/packages/extension/understudy/deepLocator.ts b/packages/extension/understudy/deepLocator.ts index f09be1b1e..f0495f1f3 100644 --- a/packages/extension/understudy/deepLocator.ts +++ b/packages/extension/understudy/deepLocator.ts @@ -138,11 +138,11 @@ export class DeepLocatorDelegate { async hover(progress?: Progress) { return (await this.real(progress)).hover(progress); } - async fill(value: string) { - return (await this.real()).fill(value); + async fill(value: string, progress?: Progress) { + return (await this.real(progress)).fill(value, progress); } - async type(text: string, options?: { delay?: number }) { - return (await this.real()).type(text, options); + async type(text: string, options?: { delay?: number }, progress?: Progress) { + return (await this.real(progress)).type(text, options, progress); } async selectOption(values: string | string[], progress?: Progress) { return (await this.real(progress)).selectOption(values, progress); @@ -174,12 +174,15 @@ export class DeepLocatorDelegate { async backendNodeId(progress?: Progress) { return (await this.real(progress)).backendNodeId(progress); } - async highlight(options?: { - durationMs?: number; - borderColor?: { r: number; g: number; b: number; a?: number }; - contentColor?: { r: number; g: number; b: number; a?: number }; - }) { - return (await this.real()).highlight(options); + async highlight( + options?: { + durationMs?: number; + borderColor?: { r: number; g: number; b: number; a?: number }; + contentColor?: { r: number; g: number; b: number; a?: number }; + }, + progress?: Progress, + ) { + return (await this.real(progress)).highlight(options, progress); } async sendClickEvent( options?: { @@ -192,8 +195,8 @@ export class DeepLocatorDelegate { ) { return (await this.real(progress)).sendClickEvent(options, progress); } - async setInputFiles(files: SetInputFilesArgument) { - return (await this.real()).setInputFiles(files); + async setInputFiles(files: SetInputFilesArgument, progress?: Progress) { + return (await this.real(progress)).setInputFiles(files, progress); } first() { return this.nth(0); diff --git a/packages/extension/understudy/frameLocator.ts b/packages/extension/understudy/frameLocator.ts index e3dc40ca5..0c6435e3e 100644 --- a/packages/extension/understudy/frameLocator.ts +++ b/packages/extension/understudy/frameLocator.ts @@ -132,11 +132,11 @@ class LocatorDelegate { async hover(progress?: Progress) { return (await this.real(progress)).hover(progress); } - async fill(value: string) { - return (await this.real()).fill(value); + async fill(value: string, progress?: Progress) { + return (await this.real(progress)).fill(value, progress); } - async type(text: string, options?: { delay?: number }) { - return (await this.real()).type(text, options); + async type(text: string, options?: { delay?: number }, progress?: Progress) { + return (await this.real(progress)).type(text, options, progress); } async selectOption(values: string | string[], progress?: Progress) { return (await this.real(progress)).selectOption(values, progress); diff --git a/packages/extension/understudy/locator.ts b/packages/extension/understudy/locator.ts index d57aa77bd..e550922ab 100644 --- a/packages/extension/understudy/locator.ts +++ b/packages/extension/understudy/locator.ts @@ -78,33 +78,36 @@ export class Locator { * File objects in the page. Filesystem paths are not available in workers. * - Passing an empty array clears the selection. */ - public async setInputFiles(files: SetInputFilesArgument): Promise { + public async setInputFiles(files: SetInputFilesArgument, progress?: Progress): Promise { const session = this.frame.session; - const { objectId } = await this.resolveNode(); + const { objectId } = await this.resolveNode(progress); + let completed = false; try { // Validate element is an - const res = await session.send( - "Runtime.callFunctionOn", - { + const res = await runLocatorStep(progress, "validating file input", () => + session.send("Runtime.callFunctionOn", { objectId, functionDeclaration: ensureFileInputElement.toString(), returnByValue: true, - }, + }), ); const ok = Boolean(res.result.value); if (!ok) throw new TypeError('Target is not an element'); - const normalized = await normalizeInputFiles(files); - - if (!normalized.length) { - await this.assignFilesViaPayloadInjection(objectId, []); - return; - } - - await this.assignFilesViaPayloadInjection(objectId, normalized); + const normalized = await runLocatorStep(progress, "preparing file uploads", () => + normalizeInputFiles(files), + ); + await this.assignFilesViaPayloadInjection(objectId, normalized, progress); + completed = true; } finally { - await session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + const release = () => + session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + // A failed action must reach the caller before cleanup can consume its deadline. + if (progress && !completed) void progress.cleanup(release); + else if (progress) await progress.cleanup(release); + else await release(); + progress?.throwIfStopped(); } } @@ -112,27 +115,31 @@ export class Locator { async assignFilesViaPayloadInjection( objectId: Protocol.Runtime.RemoteObjectId, files: NormalizedFilePayload[], + progress?: Progress, ): Promise { const session = this.frame.session; - for (const payload of files) { - if (payload.bytes.length > MAX_REMOTE_UPLOAD_BYTES) { - throw new RangeError( - `setInputFiles(): file "${payload.name}" is larger than the 50MB limit for remote uploads`, - ); + const serialized = await runLocatorStep(progress, "encoding file uploads", async () => { + for (const payload of files) { + if (payload.bytes.length > MAX_REMOTE_UPLOAD_BYTES) { + throw new RangeError( + `setInputFiles(): file "${payload.name}" is larger than the 50MB limit for remote uploads`, + ); + } } - } - - const serialized = files.map((payload) => ({ - name: payload.name, - mimeType: payload.mimeType, - lastModified: payload.lastModified, - base64: bytesToBase64(payload.bytes), - })); + return files.map((payload) => { + progress?.throwIfStopped(); + return { + name: payload.name, + mimeType: payload.mimeType, + lastModified: payload.lastModified, + base64: bytesToBase64(payload.bytes), + }; + }); + }); - const res = await session.send( - "Runtime.callFunctionOn", - { + const res = await runLocatorStep(progress, "assigning files", () => + session.send("Runtime.callFunctionOn", { objectId, functionDeclaration: assignFilePayloadsToInputElement.toString(), arguments: [ @@ -141,7 +148,7 @@ export class Locator { }, ], returnByValue: true, - }, + }), ); const ok = Boolean(res.result?.value); @@ -223,32 +230,71 @@ export class Locator { * - Scrolls element into view best-effort. * - Shows a semi-transparent overlay briefly, then hides it. */ - public async highlight(options?: { - durationMs?: number; - borderColor?: { r: number; g: number; b: number; a?: number }; - contentColor?: { r: number; g: number; b: number; a?: number }; - }): Promise { + public async highlight( + options?: { + durationMs?: number; + borderColor?: { r: number; g: number; b: number; a?: number }; + contentColor?: { r: number; g: number; b: number; a?: number }; + }, + progress?: Progress, + ): Promise { const session = this.frame.session; - const { objectId } = await this.resolveNode(); + const { objectId } = await this.resolveNode(progress); const duration = Math.max(0, options?.durationMs ?? 800); - const borderColor = options?.borderColor ?? { r: 255, g: 0, b: 0, a: 0.9 }; const contentColor = options?.contentColor ?? ({ r: 255, g: 200, b: 0, a: 0.2 } as const); + const hide = () => session.send("Overlay.hideHighlight").catch(() => {}); + let completed = false; + const cleanup = async () => { + const removeHighlight = duration > 0 || !completed || progress?.remainingMs() === 0; + const work = () => + Promise.all([ + ...(removeHighlight ? [hide()] : []), + session.send("Runtime.releaseObject", { objectId }).catch(() => {}), + ]); + // A failed action must reach the caller before cleanup can consume its deadline. + if (progress && !completed) void progress.cleanup(work); + else if (progress) await progress.cleanup(work); + else await work(); + try { + progress?.throwIfStopped(); + } catch (error) { + // Expiry during cleanup must also remove a zero-duration highlight. + if (progress && !removeHighlight) void progress.cleanup(hide); + throw error; + } + }; try { - await session.send("Overlay.enable").catch(() => {}); - await session.send("DOM.scrollIntoViewIfNeeded", { objectId }).catch(() => {}); + await runLocatorStep(progress, "enabling overlay", () => + session.send("Overlay.enable").catch((error) => { + progress?.throwIfStopped(); + if (progress && isCdpClosedError(error)) throw error; + }), + ); + await runLocatorStep(progress, "scrolling into view", () => + session.send("DOM.scrollIntoViewIfNeeded", { objectId }).catch((error) => { + progress?.throwIfStopped(); + if (progress && isCdpClosedError(error)) throw error; + }), + ); - // Prefer backendNodeId to keep highlight stable even if objectId is released. - await session.send("DOM.enable").catch(() => {}); + // Prefer backendNodeId to keep a persistent highlight after releasing objectId. + await runLocatorStep(progress, "enabling DOM", () => + session.send("DOM.enable").catch((error) => { + progress?.throwIfStopped(); + if (progress && isCdpClosedError(error)) throw error; + }), + ); let backendNodeId: Protocol.DOM.BackendNodeId | undefined; try { - const { node } = await session.send<{ node: Protocol.DOM.Node }>("DOM.describeNode", { - objectId, - }); + const { node } = await runLocatorStep(progress, "describing element", () => + session.send<{ node: Protocol.DOM.Node }>("DOM.describeNode", { objectId }), + ); backendNodeId = node.backendNodeId as Protocol.DOM.BackendNodeId; - } catch { - backendNodeId = undefined; + } catch (error) { + progress?.throwIfStopped(); + if (progress && isCdpClosedError(error)) throw error; } const highlightConfig: Protocol.Overlay.HighlightConfig = { @@ -259,34 +305,40 @@ export class Locator { borderColor, contentColor, } as Protocol.Overlay.HighlightConfig; + const highlightOnce = () => + runLocatorStep( + progress, + "highlighting element", + () => + session.send("Overlay.highlightNode", { + ...(backendNodeId ? { backendNodeId } : { objectId }), + highlightConfig, + }), + hide, + ); - const highlightOnce = async () => { - await session.send("Overlay.highlightNode", { - ...(backendNodeId ? { backendNodeId } : { objectId }), - highlightConfig, - }); - }; - - // Initial draw await highlightOnce(); - - // Keep alive until duration elapses to resist overlay clears on mouse move/repaints if (duration > 0) { - const start = Date.now(); + const now = () => (progress ? performance.now() : Date.now()); + const end = now() + duration; const tick = Math.min(300, Math.max(100, Math.floor(duration / 50))); - while (Date.now() - start < duration) { - await new Promise((r) => setTimeout(r, tick)); + while (now() < end) { + const delay = Math.max(0, Math.min(tick, end - now())); + if (progress) await progress.delay(delay); + else await new Promise((resolve) => setTimeout(resolve, delay)); + if (now() >= end) break; try { await highlightOnce(); - } catch { - // ignore transient errors + } catch (error) { + progress?.throwIfStopped(); + if (progress && isCdpClosedError(error)) throw error; + // Ordinary refresh failures can retry while time remains. } } - await session.send("Overlay.hideHighlight").catch(() => {}); } + completed = true; } finally { - // Releasing objectId should not affect highlight when using backendNodeId. - await session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + await cleanup(); } } @@ -491,21 +543,28 @@ export class Locator { * value setter (for special input types) or asks us to type text via the CDP * Input domain after focusing/selecting. */ - async fill(value: string): Promise { + async fill(value: string, progress?: Progress): Promise { const session = this.frame.session; - const { objectId } = await this.resolveNode(); + const { objectId } = await this.resolveNode(progress); let releaseNeeded = true; + const release = async () => { + if (!releaseNeeded) return; + releaseNeeded = false; + const work = () => session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + if (progress) await progress.cleanup(work); + else await work(); + progress?.throwIfStopped(); + }; try { - const res = await session.send( - "Runtime.callFunctionOn", - { + const res = await runLocatorStep(progress, "filling element", () => + session.send("Runtime.callFunctionOn", { objectId, functionDeclaration: fillElementValue.toString(), arguments: [{ value }], returnByValue: true, - }, + }), ); if (res.exceptionDetails) { // prefer exception.description over text (eg "Uncaught") @@ -528,56 +587,66 @@ export class Locator { if (status === "needsinput") { // Release the current handle before synthesizing keyboard input to avoid leaking it. - await session.send("Runtime.releaseObject", { objectId }).catch(() => {}); - releaseNeeded = false; + await release(); const valueToType = typeof result?.value === "string" ? result.value : value; let prepared = false; try { - const { objectId: prepObjectId } = await this.resolveNode(); + const { objectId: prepObjectId } = await this.resolveNode(progress); try { - const prepRes = await session.send( - "Runtime.callFunctionOn", - { + const prepRes = await runLocatorStep(progress, "preparing text input", () => + session.send("Runtime.callFunctionOn", { objectId: prepObjectId, functionDeclaration: prepareElementForTyping.toString(), returnByValue: true, - }, + }), ); prepared = Boolean(prepRes.result.value); } finally { - await session - .send("Runtime.releaseObject", { objectId: prepObjectId }) - .catch(() => {}); + const releasePrep = () => + session + .send("Runtime.releaseObject", { objectId: prepObjectId }) + .catch(() => {}); + if (progress) await progress.cleanup(releasePrep); + else await releasePrep(); + progress?.throwIfStopped(); } - } catch { - // Ignore preparation failures; we'll fall back to typing best-effort. + } catch (error) { + progress?.throwIfStopped(); + if (progress && isCdpClosedError(error)) throw error; + // Ordinary preparation failures can still fall back to typing. } if (!prepared && valueToType.length > 0) { - await this.type(valueToType); + await this.type(valueToType, undefined, progress); return; } if (valueToType.length === 0) { // Simulate deleting the currently selected text to clear the field. - await session.send("Input.dispatchKeyEvent", { - type: "keyDown", - key: "Backspace", - code: "Backspace", - windowsVirtualKeyCode: 8, - nativeVirtualKeyCode: 8, - } as Protocol.Input.DispatchKeyEventRequest); - await session.send("Input.dispatchKeyEvent", { - type: "keyUp", - key: "Backspace", - code: "Backspace", - windowsVirtualKeyCode: 8, - nativeVirtualKeyCode: 8, - } as Protocol.Input.DispatchKeyEventRequest); + await runLocatorStep(progress, "dispatching keyboard events", () => + session.send("Input.dispatchKeyEvent", { + type: "keyDown", + key: "Backspace", + code: "Backspace", + windowsVirtualKeyCode: 8, + nativeVirtualKeyCode: 8, + } as Protocol.Input.DispatchKeyEventRequest), + ); + await runLocatorStep(progress, "dispatching keyboard events", () => + session.send("Input.dispatchKeyEvent", { + type: "keyUp", + key: "Backspace", + code: "Backspace", + windowsVirtualKeyCode: 8, + nativeVirtualKeyCode: 8, + } as Protocol.Input.DispatchKeyEventRequest), + ); } else { - await session.send("Input.insertText", { text: valueToType }); + await runLocatorStep(progress, "inserting text", () => + session.send("Input.insertText", { text: valueToType }), + ); } return; @@ -593,12 +662,12 @@ export class Locator { // Backward compatibility: if no status is returned (older bundle), fall back to setter logic. if (!status) { - await this.type(value); + await release(); + await this.type(value, undefined, progress); } } finally { - if (releaseNeeded) { - await session.send("Runtime.releaseObject", { objectId }).catch(() => {}); - } + await release(); + progress?.throwIfStopped(); } } @@ -608,40 +677,52 @@ export class Locator { * - If no delay, uses `Input.insertText` for efficiency. * - With delay, synthesizes `keyDown`/`keyUp` per character. */ - async type(text: string, options?: { delay?: number }): Promise { + async type(text: string, options?: { delay?: number }, progress?: Progress): Promise { const session = this.frame.session; - const { objectId } = await this.resolveNode(); + const { objectId } = await this.resolveNode(progress); try { // Focus using JS (avoids DOM.focus(nodeId)) - await session.send("Runtime.callFunctionOn", { - objectId, - functionDeclaration: focusElement.toString(), - returnByValue: true, - }); + await runLocatorStep(progress, "focusing element", () => + session.send("Runtime.callFunctionOn", { + objectId, + functionDeclaration: focusElement.toString(), + returnByValue: true, + }), + ); if (!options?.delay) { - await session.send("Input.insertText", { text }); + await runLocatorStep(progress, "inserting text", () => + session.send("Input.insertText", { text }), + ); return; } for (const ch of text) { - await session.send("Input.dispatchKeyEvent", { - type: "keyDown", - text: ch, - key: ch, - } as Protocol.Input.DispatchKeyEventRequest); - - await session.send("Input.dispatchKeyEvent", { - type: "keyUp", - text: ch, - key: ch, - } as Protocol.Input.DispatchKeyEventRequest); - - await new Promise((r) => setTimeout(r, options.delay)); + await runLocatorStep(progress, "dispatching keyboard events", () => + session.send("Input.dispatchKeyEvent", { + type: "keyDown", + text: ch, + key: ch, + } as Protocol.Input.DispatchKeyEventRequest), + ); + + await runLocatorStep(progress, "dispatching keyboard events", () => + session.send("Input.dispatchKeyEvent", { + type: "keyUp", + text: ch, + key: ch, + } as Protocol.Input.DispatchKeyEventRequest), + ); + + if (progress) await progress.delay(options.delay); + else await new Promise((r) => setTimeout(r, options.delay)); } } finally { - await session.send("Runtime.releaseObject", { objectId }); + const release = () => session.send("Runtime.releaseObject", { objectId }); + if (progress) await progress.cleanup(release); + else await release(); + progress?.throwIfStopped(); } } diff --git a/packages/extension/understudy/locatorActions.test.ts b/packages/extension/understudy/locatorActions.test.ts index f9688286b..a83456a00 100644 --- a/packages/extension/understudy/locatorActions.test.ts +++ b/packages/extension/understudy/locatorActions.test.ts @@ -1,14 +1,25 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TimeoutError } from "../errors.js"; +import { + assignFilePayloadsToInputElement, + fillElementValue, + prepareElementForTyping, +} from "../dom/locatorScripts/scripts.js"; import type { Frame } from "./frame.js"; import type { Page } from "./page.js"; import { DeepLocatorDelegate } from "./deepLocator.js"; import { frameLocatorFromFrame } from "./frameLocator.js"; import { executionContexts } from "./executionContextRegistry.js"; import { Locator } from "./locator.js"; +import * as fileUploads from "./fileUploadUtils.js"; import { Progress, runWithProgress } from "./progress.js"; +const upload = { name: "test.txt", buffer: "abc", lastModified: 1 }; const actions = [ + ["highlight", [{ durationMs: 0 }]], + ["setInputFiles", [upload]], + ["fill", ["hello"]], + ["type", ["hello", undefined]], ["click", [{ button: "right", clickCount: 2 }]], ["hover", []], ["selectOption", [["first", "second"]]], @@ -27,12 +38,20 @@ const actions = [ type Action = (typeof actions)[number][0]; function createLocator(selector = "button") { - const send = vi.fn(async (method: string, _params?: object): Promise => { + const send = vi.fn(async (method: string, params?: object): Promise => { if (method === "DOM.getBoxModel") return { model: { content: [0, 0, 10, 0, 10, 10, 0, 10] } }; if (method === "DOM.describeNode") return { node: { backendNodeId: 1 } }; if (method === "Runtime.evaluate") return { result: { value: selector.startsWith("text=") ? { count: 2 } : 2 } }; - if (method === "Runtime.callFunctionOn") return { result: { value: "value" } }; + if (method === "Runtime.callFunctionOn") { + const functionDeclaration = (params as { functionDeclaration?: string } | undefined) + ?.functionDeclaration; + return { + result: { + value: functionDeclaration === fillElementValue.toString() ? { status: "done" } : "value", + }, + }; + } return {}; }); const frame = { frameId: "root", session: { send } } as unknown as Frame; @@ -48,12 +67,39 @@ function createLocator(selector = "button") { return { locator, frame, resolveNode, readiness, send }; } +function createFillLocator(legacy = false) { + const fixture = createLocator(); + let nextNode = 0; + fixture.resolveNode.mockImplementation(async () => ({ + objectId: `node-${++nextNode}`, + nodeId: nextNode, + })); + const respond = fixture.send.getMockImplementation()!; + fixture.send.mockImplementation((method, params) => { + const declaration = (params as { functionDeclaration?: string } | undefined) + ?.functionDeclaration; + if (method === "Runtime.callFunctionOn") { + if (declaration === fillElementValue.toString()) + return Promise.resolve({ + result: { value: legacy ? undefined : { status: "needsinput" } }, + }); + if (declaration === prepareElementForTyping.toString()) + return Promise.resolve({ result: { value: true } }); + } + return respond(method, params); + }); + return fixture; +} + afterEach(() => vi.restoreAllMocks()); describe.each(["direct", "deep", "frame"] as const)("%s locator progress forwarding", (kind) => { const supported = actions.filter( ([method]) => - kind !== "frame" || !["sendClickEvent", "centroid", "backendNodeId"].includes(method), + kind !== "frame" || + !["sendClickEvent", "centroid", "backendNodeId", "highlight", "setInputFiles"].includes( + method, + ), ); it.each(supported)("passes the caller's progress through %s", async (method, args) => { @@ -118,12 +164,14 @@ function deferred() { describe("locator action deadlines", () => { const contexts: Progress[] = []; - const createProgress = (timeout = 100) => { - const progress = new Progress("action", timeout); + const createProgress = (timeout = 100, name = "action") => { + const progress = new Progress(name, timeout); contexts.push(progress); return progress; }; - beforeEach(() => vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] })); + beforeEach(() => + vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance", "Date"] }), + ); afterEach(() => { contexts.splice(0).forEach((progress) => progress.dispose()); expect(vi.getTimerCount()).toBe(0); @@ -131,6 +179,14 @@ describe("locator action deadlines", () => { }); const stalledCommands: Partial> = { + highlight: [ + "Overlay.enable", + "DOM.scrollIntoViewIfNeeded", + "DOM.enable", + "DOM.describeNode", + "Overlay.highlightNode", + ], + type: ["Runtime.callFunctionOn", "Input.insertText"], click: ["DOM.scrollIntoViewIfNeeded", "DOM.getBoxModel", "Input.dispatchMouseEvent"], hover: ["DOM.getBoxModel", "Input.dispatchMouseEvent"], centroid: ["DOM.getBoxModel"], @@ -156,12 +212,16 @@ describe("locator action deadlines", () => { const rejected = expect(pending).rejects.toThrow(TimeoutError); await vi.advanceTimersByTimeAsync(100); await rejected; - const sent = send.mock.calls.filter(([name]) => name !== "Runtime.releaseObject").length; + const sent = send.mock.calls.filter( + ([name]) => !["Runtime.releaseObject", "Overlay.hideHighlight"].includes(name), + ).length; gate.resolve(await respond(command)); await vi.advanceTimersByTimeAsync(0); - expect(send.mock.calls.filter(([name]) => name !== "Runtime.releaseObject")).toHaveLength( - sent, - ); + expect( + send.mock.calls.filter( + ([name]) => !["Runtime.releaseObject", "Overlay.hideHighlight"].includes(name), + ), + ).toHaveLength(sent); if (method !== "count") expect(send).toHaveBeenCalledWith("Runtime.releaseObject", { objectId: "node" }); if (command !== "Input.dispatchMouseEvent") @@ -176,7 +236,11 @@ describe("locator action deadlines", () => { const pending = method === "click" ? locator.click(undefined, progress) : locator.count(progress); await expect(pending).rejects.toThrow(TimeoutError); - expect(send.mock.calls.filter(([name]) => name !== "Runtime.releaseObject")).toHaveLength(0); + expect( + send.mock.calls.filter( + ([name]) => !["Runtime.releaseObject", "Overlay.hideHighlight"].includes(name), + ), + ).toHaveLength(0); }); it.each([ @@ -361,4 +425,546 @@ describe("locator action deadlines", () => { gate.resolve({ result: { value: "finished" } }); await expect(second).resolves.toBe("finished"); }); + + it.each(["preparation", "legacy"] as const)( + "shares the remaining fill budget through the %s fallback", + async (path) => { + const { locator, send, resolveNode } = createFillLocator(path === "legacy"); + const progress = createProgress(100, "fill"); + const type = vi.spyOn(locator, "type"); + const gate = deferred(); + const respond = send.getMockImplementation()!; + const delayedHelper = path === "legacy" ? fillElementValue : prepareElementForTyping; + send.mockImplementation(async (method, params) => { + if ( + (params as { functionDeclaration?: string })?.functionDeclaration === + delayedHelper.toString() + ) { + await progress.delay(60); + if (path === "preparation") throw new Error("preparation failed"); + } + return method === "Input.insertText" ? gate.promise : respond(method, params); + }); + const pending = locator.fill("hello", progress); + const rejected = expect(pending).rejects.toThrow("fill timed out after 100ms"); + await vi.advanceTimersByTimeAsync(60); + expect(type).toHaveBeenCalledExactlyOnceWith("hello", undefined, progress); + expect(resolveNode.mock.calls.every(([passed]) => passed === progress)).toBe(true); + expect(progress.remainingMs()).toBe(40); + await vi.advanceTimersByTimeAsync(40); + await rejected; + const calls = send.mock.calls.length; + gate.resolve({}); + await vi.advanceTimersByTimeAsync(0); + expect(send).toHaveBeenCalledTimes(calls); + }, + ); + + it.each([ + { path: "preparation", failure: "expired" }, + { path: "preparation", failure: "closed" }, + { path: "legacy", failure: "expired" }, + { path: "legacy", failure: "closed" }, + ])("prevents $path fallback after $failure", async ({ path, failure }) => { + const { locator, send } = createFillLocator(path === "legacy"); + const type = vi.spyOn(locator, "type"); + const progress = createProgress(); + const respond = send.getMockImplementation()!; + const helper = path === "legacy" ? fillElementValue : prepareElementForTyping; + const closed = new Error("CDP connection closed: gone"); + send.mockImplementation(async (method, params) => { + if ((params as { functionDeclaration?: string })?.functionDeclaration === helper.toString()) { + if (failure === "closed") throw closed; + vi.spyOn(performance, "now").mockReturnValue(101); + if (path === "preparation") throw new Error("preparation failed"); + } + return respond(method, params); + }); + const pending = locator.fill("hello", progress); + if (failure === "closed") await expect(pending).rejects.toBe(closed); + else await expect(pending).rejects.toThrow(TimeoutError); + expect(type).not.toHaveBeenCalled(); + expect(send.mock.calls.some(([method]) => method.startsWith("Input."))).toBe(false); + }); + + it.each(["hello", ""])( + "fills prepared input with %j & releases each handle once", + async (value) => { + const { locator, send } = createFillLocator(); + const type = vi.spyOn(locator, "type"); + await locator.fill(value, createProgress()); + expect(type).not.toHaveBeenCalled(); + expect(send.mock.calls.filter(([method]) => method.startsWith("Input."))).toEqual( + value + ? [["Input.insertText", { text: value }]] + : [ + [ + "Input.dispatchKeyEvent", + { + type: "keyDown", + key: "Backspace", + code: "Backspace", + windowsVirtualKeyCode: 8, + nativeVirtualKeyCode: 8, + }, + ], + [ + "Input.dispatchKeyEvent", + { + type: "keyUp", + key: "Backspace", + code: "Backspace", + windowsVirtualKeyCode: 8, + nativeVirtualKeyCode: 8, + }, + ], + ], + ); + expect(send.mock.calls.filter(([method]) => method === "Runtime.releaseObject")).toEqual([ + ["Runtime.releaseObject", { objectId: "node-1" }], + ["Runtime.releaseObject", { objectId: "node-2" }], + ]); + }, + ); + + it.each([undefined, 0, 100])("includes typing delays in timeout %s", async (timeout) => { + const { locator, send } = createLocator(); + const pending = locator.type( + "abc", + { delay: 60 }, + timeout === undefined ? undefined : createProgress(timeout), + ); + const result = timeout + ? expect(pending).rejects.toThrow(TimeoutError) + : expect(pending).resolves.toBeUndefined(); + await vi.advanceTimersByTimeAsync(100); + if (timeout) await result; + await vi.advanceTimersByTimeAsync(100); + await result; + const events = send.mock.calls.filter(([method]) => method === "Input.dispatchKeyEvent"); + expect(events.map(([, params]) => params)).toEqual( + (timeout ? ["a", "b"] : ["a", "b", "c"]).flatMap((ch) => [ + { type: "keyDown", text: ch, key: ch }, + { type: "keyUp", text: ch, key: ch }, + ]), + ); + }); + + it.each(["keyDown", "keyUp"])( + "bounds stalled typing %s & stops further characters", + async (event) => { + const { locator, send } = createLocator(); + const gate = deferred(); + const respond = send.getMockImplementation()!; + send.mockImplementation((method, params) => + method === "Input.dispatchKeyEvent" && (params as { type: string }).type === event + ? gate.promise + : respond(method, params), + ); + const pending = locator.type("ab", { delay: 20 }, createProgress()); + const rejected = expect(pending).rejects.toThrow(TimeoutError); + await vi.advanceTimersByTimeAsync(100); + await rejected; + const sent = send.mock.calls.length; + gate.resolve({}); + await vi.advanceTimersByTimeAsync(100); + expect(send).toHaveBeenCalledTimes(sent); + }, + ); + + it("does not dispatch keyUp when keyDown finishes after the deadline", async () => { + const { locator, send } = createLocator(); + const respond = send.getMockImplementation()!; + send.mockImplementation(async (method, params) => { + if (method === "Input.dispatchKeyEvent") vi.spyOn(performance, "now").mockReturnValue(101); + return respond(method, params); + }); + await expect(locator.type("ab", { delay: 20 }, createProgress())).rejects.toThrow(TimeoutError); + expect(send.mock.calls.filter(([method]) => method === "Input.dispatchKeyEvent")).toHaveLength( + 1, + ); + }); + + it("does not repeat fill cleanup or prepare input after an early release stalls", async () => { + const { locator, send, resolveNode } = createFillLocator(); + const gate = deferred(); + const respond = send.getMockImplementation()!; + send.mockImplementation((method, params) => + method === "Runtime.releaseObject" ? gate.promise : respond(method, params), + ); + const pending = locator.fill("hello", createProgress()); + const rejected = expect(pending).rejects.toThrow(TimeoutError); + await vi.advanceTimersByTimeAsync(1000); + await rejected; + expect(resolveNode).toHaveBeenCalledTimes(1); + expect(send.mock.calls.filter(([method]) => method === "Runtime.releaseObject")).toEqual([ + ["Runtime.releaseObject", { objectId: "node-1" }], + ]); + expect(send.mock.calls.some(([method]) => method.startsWith("Input."))).toBe(false); + gate.resolve({}); + await vi.advanceTimersByTimeAsync(0); + }); + + it.each([undefined, 0, 100])("includes highlight duration in timeout %s", async (timeout) => { + const { locator, send } = createLocator(); + const pending = locator.highlight( + { durationMs: 250 }, + timeout === undefined ? undefined : createProgress(timeout), + ); + const result = timeout + ? expect(pending).rejects.toThrow(TimeoutError) + : expect(pending).resolves.toBeUndefined(); + await vi.advanceTimersByTimeAsync(100); + if (timeout) await result; + await vi.advanceTimersByTimeAsync(200); + await result; + expect(send.mock.calls.filter(([method]) => method === "Overlay.highlightNode")).toHaveLength( + timeout ? 1 : 3, + ); + expect(send.mock.calls.filter(([method]) => method === "Overlay.hideHighlight")).toHaveLength( + 1, + ); + expect(send.mock.calls.filter(([method]) => method === "Runtime.releaseObject")).toHaveLength( + 1, + ); + }); + + it.each([undefined, 0, 100])( + "keeps a successful zero-duration highlight with timeout %s", + async (timeout) => { + const { locator, send } = createLocator(); + await locator.highlight( + { durationMs: 0 }, + timeout === undefined ? undefined : createProgress(timeout), + ); + expect(send).toHaveBeenCalledWith("Runtime.releaseObject", { objectId: "node" }); + expect(send).not.toHaveBeenCalledWith("Overlay.hideHighlight"); + }, + ); + + it.each(["ordinary", "closed", "expired"])( + "handles a %s highlight node lookup failure", + async (kind) => { + const { locator, send } = createLocator(); + const error = new Error( + kind === "closed" ? "CDP connection closed: gone" : "node lookup failed", + ); + const respond = send.getMockImplementation()!; + send.mockImplementation(async (method, params) => { + if (method === "DOM.describeNode") { + if (kind === "expired") vi.spyOn(performance, "now").mockReturnValue(101); + throw error; + } + return respond(method, params); + }); + const pending = locator.highlight({ durationMs: 0 }, createProgress()); + if (kind === "ordinary") { + await pending; + expect(send).toHaveBeenCalledWith( + "Overlay.highlightNode", + expect.objectContaining({ objectId: "node" }), + ); + } else { + if (kind === "closed") await expect(pending).rejects.toBe(error); + else await expect(pending).rejects.toThrow(TimeoutError); + expect(send).toHaveBeenCalledWith("Overlay.hideHighlight"); + expect(send.mock.calls.some(([method]) => method === "Overlay.highlightNode")).toBe(false); + } + }, + ); + + it.each([false, true])( + "only retries ordinary highlight refresh failures (closed: %s)", + async (closed) => { + const { locator, send } = createLocator(); + const error = new Error( + closed ? "No Page found for target closed before CDP response: gone" : "node moved", + ); + const respond = send.getMockImplementation()!; + let draws = 0; + send.mockImplementation(async (method, params) => { + if (method === "Overlay.highlightNode" && ++draws === 2) throw error; + return respond(method, params); + }); + const pending = locator.highlight({ durationMs: 250 }, createProgress(500)); + const result = closed + ? expect(pending).rejects.toBe(error) + : expect(pending).resolves.toBeUndefined(); + await vi.advanceTimersByTimeAsync(250); + await result; + expect(draws).toBe(closed ? 2 : 3); + expect(send).toHaveBeenCalledWith("Overlay.hideHighlight"); + }, + ); + + it("hides a highlight that finishes drawing after timeout", async () => { + const { locator, send } = createLocator(); + const gate = deferred(); + const respond = send.getMockImplementation()!; + send.mockImplementation((method, params) => + method === "Overlay.highlightNode" ? gate.promise : respond(method, params), + ); + const pending = locator.highlight({ durationMs: 0 }, createProgress()); + const rejected = expect(pending).rejects.toThrow(TimeoutError); + await vi.advanceTimersByTimeAsync(100); + await rejected; + expect(send.mock.calls.filter(([method]) => method === "Overlay.hideHighlight")).toHaveLength( + 1, + ); + gate.resolve({}); + await vi.advanceTimersByTimeAsync(0); + expect(send.mock.calls.filter(([method]) => method === "Overlay.hideHighlight")).toHaveLength( + 2, + ); + expect(send.mock.calls.filter(([method]) => method === "Overlay.highlightNode")).toHaveLength( + 1, + ); + }); + + it.each([false, true])( + "does not stack highlight cleanup waits (zero duration: %s)", + async (zeroDuration) => { + const { locator, send } = createLocator(); + const gate = deferred(); + const respond = send.getMockImplementation()!; + send.mockImplementation((method, params) => + ["Overlay.hideHighlight", "Runtime.releaseObject"].includes(method) + ? gate.promise + : respond(method, params), + ); + const pending = locator.highlight({ durationMs: zeroDuration ? 0 : 50 }, createProgress()); + const rejected = expect(pending).rejects.toThrow(TimeoutError); + await vi.advanceTimersByTimeAsync(50); + if (!zeroDuration) { + expect(send).toHaveBeenCalledWith("Overlay.hideHighlight"); + expect(send).toHaveBeenCalledWith("Runtime.releaseObject", { objectId: "node" }); + } + await vi.advanceTimersByTimeAsync(1000); + await rejected; + expect(send.mock.calls.filter(([method]) => method === "Overlay.hideHighlight")).toHaveLength( + 1, + ); + expect(send.mock.calls.filter(([method]) => method === "Runtime.releaseObject")).toHaveLength( + 1, + ); + gate.resolve({}); + await vi.advanceTimersByTimeAsync(0); + }, + ); + + it.each(["highlight", "upload"] as const)( + "preserves the primary %s error while cleanup is stalled", + async (action) => { + const { locator, send } = createLocator(); + const primary = new Error("action failed"); + const gate = deferred(); + const respond = send.getMockImplementation()!; + send.mockImplementation(async (method, params) => { + if (["Overlay.hideHighlight", "Runtime.releaseObject"].includes(method)) + return gate.promise; + if ( + method === "Overlay.highlightNode" || + (params as { functionDeclaration?: string })?.functionDeclaration === + assignFilePayloadsToInputElement.toString() + ) + throw primary; + return respond(method, params); + }); + const settled = vi.fn(); + const pending = runWithProgress({ name: action, timeout: 100 }, (progress) => + action === "highlight" + ? locator.highlight({ durationMs: 0 }, progress) + : locator.setInputFiles(upload, progress), + ); + void pending.then(settled, settled); + try { + await vi.advanceTimersByTimeAsync(0); + expect(settled).toHaveBeenCalledExactlyOnceWith(primary); + expect(send).toHaveBeenCalledWith("Runtime.releaseObject", { objectId: "node" }); + if (action === "highlight") expect(send).toHaveBeenCalledWith("Overlay.hideHighlight"); + await vi.advanceTimersByTimeAsync(1000); + expect(settled).toHaveBeenCalledExactlyOnceWith(primary); + expect(vi.getTimerCount()).toBe(0); + } finally { + gate.resolve({}); + await vi.advanceTimersByTimeAsync(0); + } + }, + ); + + it.each(["highlight", "upload"] as const)( + "reports an expired %s without waiting for stalled cleanup", + async (action) => { + const { locator, send } = createLocator(); + const work = deferred(); + const cleanup = deferred(); + const respond = send.getMockImplementation()!; + send.mockImplementation((method, params) => { + if (["Overlay.hideHighlight", "Runtime.releaseObject"].includes(method)) + return cleanup.promise; + if ( + method === "Overlay.highlightNode" || + (params as { functionDeclaration?: string })?.functionDeclaration === + assignFilePayloadsToInputElement.toString() + ) + return work.promise; + return respond(method, params); + }); + const progress = createProgress(); + const pending = + action === "highlight" + ? locator.highlight({ durationMs: 0 }, progress) + : locator.setInputFiles(upload, progress); + const settled = vi.fn(); + void pending.then(settled, settled); + try { + await vi.advanceTimersByTimeAsync(100); + expect(settled).toHaveBeenCalledExactlyOnceWith(expect.any(TimeoutError)); + expect(send).toHaveBeenCalledWith("Runtime.releaseObject", { objectId: "node" }); + } finally { + work.resolve({ result: { value: true } }); + cleanup.resolve({}); + await vi.advanceTimersByTimeAsync(0); + } + }, + ); + + it("still reports timeout when successful upload cleanup crosses the deadline", async () => { + const { locator, send } = createLocator(); + const gate = deferred(); + const respond = send.getMockImplementation()!; + send.mockImplementation((method, params) => + method === "Runtime.releaseObject" ? gate.promise : respond(method, params), + ); + const pending = locator.setInputFiles(upload, createProgress()); + const rejected = expect(pending).rejects.toThrow(TimeoutError); + await vi.advanceTimersByTimeAsync(1000); + await rejected; + gate.resolve({}); + await vi.advanceTimersByTimeAsync(0); + }); + + it("preserves a highlight failure when both cleanup commands fail", async () => { + const { locator, send } = createLocator(); + const primary = new Error("drawing failed"); + const respond = send.getMockImplementation()!; + send.mockImplementation(async (method, params) => { + if (method === "Overlay.highlightNode") throw primary; + if (["Overlay.hideHighlight", "Runtime.releaseObject"].includes(method)) + throw new Error("cleanup failed"); + return respond(method, params); + }); + await expect(locator.highlight({ durationMs: 0 }, createProgress())).rejects.toBe(primary); + expect(send).toHaveBeenCalledWith("Overlay.hideHighlight"); + expect(send).toHaveBeenCalledWith("Runtime.releaseObject", { objectId: "node" }); + }); + + it.each(["normalization", "encoding"])( + "does not inject files when %s exhausts the budget", + async (stage) => { + const { locator } = createLocator(); + const inject = vi.spyOn(locator, "assignFilesViaPayloadInjection"); + if (stage === "normalization") { + const normalize = fileUploads.normalizeInputFiles; + vi.spyOn(fileUploads, "normalizeInputFiles").mockImplementation(async (files) => { + const result = await normalize(files); + vi.spyOn(performance, "now").mockReturnValue(101); + return result; + }); + } else { + const encode = fileUploads.bytesToBase64; + vi.spyOn(fileUploads, "bytesToBase64").mockImplementation((bytes) => { + const result = encode(bytes); + vi.spyOn(performance, "now").mockReturnValue(101); + return result; + }); + } + const send = vi.spyOn(locator.frame.session, "send"); + await expect(locator.setInputFiles(upload, createProgress())).rejects.toThrow(TimeoutError); + if (stage === "normalization") expect(inject).not.toHaveBeenCalled(); + expect( + send.mock.calls.some( + ([, params]) => + (params as { functionDeclaration?: string })?.functionDeclaration === + assignFilePayloadsToInputElement.toString(), + ), + ).toBe(false); + }, + ); + + it("bounds stalled upload normalization & ignores its late result", async () => { + const { locator } = createLocator(); + const gate = deferred(); + vi.spyOn(fileUploads, "normalizeInputFiles").mockImplementation(async () => { + await gate.promise; + return []; + }); + const inject = vi.spyOn(locator, "assignFilesViaPayloadInjection"); + const pending = locator.setInputFiles(upload, createProgress()); + const rejected = expect(pending).rejects.toThrow(TimeoutError); + await vi.advanceTimersByTimeAsync(100); + await rejected; + gate.resolve({}); + await vi.advanceTimersByTimeAsync(0); + expect(inject).not.toHaveBeenCalled(); + }); + + it.each([0, 100])("bounds upload injection with timeout %s", async (timeout) => { + const { locator, send } = createLocator(); + const gate = deferred(); + const respond = send.getMockImplementation()!; + send.mockImplementation((method, params) => + (params as { functionDeclaration?: string })?.functionDeclaration === + assignFilePayloadsToInputElement.toString() + ? gate.promise + : respond(method, params), + ); + const pending = locator.setInputFiles(upload, createProgress(timeout)); + const result = timeout + ? expect(pending).rejects.toThrow(TimeoutError) + : expect(pending).resolves.toBeUndefined(); + await vi.advanceTimersByTimeAsync(100); + if (timeout) await result; + gate.resolve({ result: { value: true } }); + await result; + await vi.advanceTimersByTimeAsync(0); + expect(send.mock.calls.filter(([method]) => method === "Runtime.callFunctionOn")).toHaveLength( + 2, + ); + expect(send.mock.calls.filter(([method]) => method === "Runtime.releaseObject")).toHaveLength( + 1, + ); + }); + + it.each([false, true])("passes progress through file injection (clear: %s)", async (clear) => { + const { locator, send } = createLocator(); + const progress = createProgress(); + const inject = vi.spyOn(locator, "assignFilesViaPayloadInjection"); + await locator.setInputFiles(clear ? [] : upload, progress); + expect(inject).toHaveBeenCalledExactlyOnceWith( + "node", + clear ? [] : [expect.objectContaining({ name: "test.txt" })], + progress, + ); + expect(send).toHaveBeenCalledWith( + "Runtime.callFunctionOn", + expect.objectContaining({ + functionDeclaration: assignFilePayloadsToInputElement.toString(), + arguments: [ + { + value: clear + ? [] + : [ + { + name: "test.txt", + mimeType: "application/octet-stream", + lastModified: 1, + base64: "YWJj", + }, + ], + }, + ], + }), + ); + }); }); diff --git a/packages/sdk-go/internal/extensionassets/stagehand-extension.zip b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip index c856e7373..daba6aba4 100644 Binary files a/packages/sdk-go/internal/extensionassets/stagehand-extension.zip and b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip differ