diff --git a/packages/extension/understudy/deepLocator.ts b/packages/extension/understudy/deepLocator.ts index 7476794b7..f09be1b1e 100644 --- a/packages/extension/understudy/deepLocator.ts +++ b/packages/extension/understudy/deepLocator.ts @@ -129,14 +129,14 @@ export class DeepLocatorDelegate { } // Locator API delegates - async click(options?: { button?: MouseButton; clickCount?: number }) { - return (await this.real()).click(options); + async click(options?: { button?: MouseButton; clickCount?: number }, progress?: Progress) { + return (await this.real(progress)).click(options, progress); } - async count() { - return (await this.real()).count(); + async count(progress?: Progress) { + return (await this.real(progress)).count(progress); } - async hover() { - return (await this.real()).hover(); + async hover(progress?: Progress) { + return (await this.real(progress)).hover(progress); } async fill(value: string) { return (await this.real()).fill(value); @@ -144,35 +144,35 @@ export class DeepLocatorDelegate { async type(text: string, options?: { delay?: number }) { return (await this.real()).type(text, options); } - async selectOption(values: string | string[]) { - return (await this.real()).selectOption(values); + async selectOption(values: string | string[], progress?: Progress) { + return (await this.real(progress)).selectOption(values, progress); } - async scrollTo(percent: number | string) { - return (await this.real()).scrollTo(percent); + async scrollTo(percent: number | string, progress?: Progress) { + return (await this.real(progress)).scrollTo(percent, progress); } - async isVisible() { - return (await this.real()).isVisible(); + async isVisible(progress?: Progress) { + return (await this.real(progress)).isVisible(progress); } - async isChecked() { - return (await this.real()).isChecked(); + async isChecked(progress?: Progress) { + return (await this.real(progress)).isChecked(progress); } - async inputValue() { - return (await this.real()).inputValue(); + async inputValue(progress?: Progress) { + return (await this.real(progress)).inputValue(progress); } - async textContent() { - return (await this.real()).textContent(); + async textContent(progress?: Progress) { + return (await this.real(progress)).textContent(progress); } - async innerHtml() { - return (await this.real()).innerHtml(); + async innerHtml(progress?: Progress) { + return (await this.real(progress)).innerHtml(progress); } - async innerText() { - return (await this.real()).innerText(); + async innerText(progress?: Progress) { + return (await this.real(progress)).innerText(progress); } - async centroid() { - return (await this.real()).centroid(); + async centroid(progress?: Progress) { + return (await this.real(progress)).centroid(progress); } - async backendNodeId() { - return (await this.real()).backendNodeId(); + async backendNodeId(progress?: Progress) { + return (await this.real(progress)).backendNodeId(progress); } async highlight(options?: { durationMs?: number; @@ -181,13 +181,16 @@ export class DeepLocatorDelegate { }) { return (await this.real()).highlight(options); } - async sendClickEvent(options?: { - bubbles?: boolean; - cancelable?: boolean; - composed?: boolean; - detail?: number; - }) { - return (await this.real()).sendClickEvent(options); + async sendClickEvent( + options?: { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; + detail?: number; + }, + progress?: Progress, + ) { + return (await this.real(progress)).sendClickEvent(options, progress); } async setInputFiles(files: SetInputFilesArgument) { return (await this.real()).setInputFiles(files); diff --git a/packages/extension/understudy/frameLocator.ts b/packages/extension/understudy/frameLocator.ts index 99431d69d..e3dc40ca5 100644 --- a/packages/extension/understudy/frameLocator.ts +++ b/packages/extension/understudy/frameLocator.ts @@ -123,11 +123,14 @@ class LocatorDelegate { } // Locator API delegates - async click(options?: { button?: "left" | "right" | "middle"; clickCount?: number }) { - return (await this.real()).click(options); + async click( + options?: { button?: "left" | "right" | "middle"; clickCount?: number }, + progress?: Progress, + ) { + return (await this.real(progress)).click(options, progress); } - async hover() { - return (await this.real()).hover(); + async hover(progress?: Progress) { + return (await this.real(progress)).hover(progress); } async fill(value: string) { return (await this.real()).fill(value); @@ -135,32 +138,32 @@ class LocatorDelegate { async type(text: string, options?: { delay?: number }) { return (await this.real()).type(text, options); } - async selectOption(values: string | string[]) { - return (await this.real()).selectOption(values); + async selectOption(values: string | string[], progress?: Progress) { + return (await this.real(progress)).selectOption(values, progress); } - async scrollTo(percent: number | string) { - return (await this.real()).scrollTo(percent); + async scrollTo(percent: number | string, progress?: Progress) { + return (await this.real(progress)).scrollTo(percent, progress); } - async isVisible() { - return (await this.real()).isVisible(); + async isVisible(progress?: Progress) { + return (await this.real(progress)).isVisible(progress); } - async isChecked() { - return (await this.real()).isChecked(); + async isChecked(progress?: Progress) { + return (await this.real(progress)).isChecked(progress); } - async inputValue() { - return (await this.real()).inputValue(); + async inputValue(progress?: Progress) { + return (await this.real(progress)).inputValue(progress); } - async textContent() { - return (await this.real()).textContent(); + async textContent(progress?: Progress) { + return (await this.real(progress)).textContent(progress); } - async innerHtml() { - return (await this.real()).innerHtml(); + async innerHtml(progress?: Progress) { + return (await this.real(progress)).innerHtml(progress); } - async innerText() { - return (await this.real()).innerText(); + async innerText(progress?: Progress) { + return (await this.real(progress)).innerText(progress); } - async count() { - return (await this.real()).count(); + async count(progress?: Progress) { + return (await this.real(progress)).count(progress); } first(): LocatorDelegate { return this.nth(0); diff --git a/packages/extension/understudy/locator.ts b/packages/extension/understudy/locator.ts index 88fede0e0..d57aa77bd 100644 --- a/packages/extension/understudy/locator.ts +++ b/packages/extension/understudy/locator.ts @@ -1,5 +1,6 @@ // lib/v3/understudy/locator.ts import { Protocol } from "devtools-protocol"; +import { isCdpClosedError } from "./cdp.js"; import { assignFilePayloadsToInputElement, dispatchDomClick, @@ -153,45 +154,67 @@ export class Locator { * Return the DOM backendNodeId for this locator's target element. * Useful for identity comparisons without needing element handles. */ - async backendNodeId(): Promise { + async backendNodeId(progress?: Progress): Promise { const session = this.frame.session; - const { objectId } = await this.resolveNode(); + const { objectId } = await this.resolveNode(progress); try { - await session.send("DOM.enable").catch(() => {}); - const { node } = await session.send<{ node: Protocol.DOM.Node }>("DOM.describeNode", { - objectId, - }); + await runLocatorStep(progress, "enabling DOM", () => + session.send("DOM.enable").catch((error) => { + progress?.throwIfStopped(); + if (progress && isCdpClosedError(error)) throw error; + }), + ); + const { node } = await runLocatorStep(progress, "describing element", () => + session.send<{ node: Protocol.DOM.Node }>("DOM.describeNode", { + objectId, + }), + ); return node.backendNodeId as Protocol.DOM.BackendNodeId; } finally { - await session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + const release = () => + session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + if (progress) await progress.cleanup(release); + else await release(); + progress?.throwIfStopped(); } } /** Return how many nodes the current selector resolves to. */ - public async count(): Promise { + public async count(progress?: Progress): Promise { const session = this.frame.session; - await session.send("Runtime.enable"); - await session.send("DOM.enable"); - return this.selectorResolver.count(this.selectorQuery); + await runLocatorStep(progress, "enabling runtime", () => session.send("Runtime.enable")); + await runLocatorStep(progress, "enabling DOM", () => session.send("DOM.enable")); + return this.selectorResolver.count(this.selectorQuery, progress); } /** * Return the center of the element's bounding box in the owning frame's viewport * (CSS pixels), rounded to integers. Scrolls into view best-effort. */ - public async centroid(): Promise<{ x: number; y: number }> { + public async centroid(progress?: Progress): Promise<{ x: number; y: number }> { const session = this.frame.session; - const { objectId } = await this.resolveNode(); + const { objectId } = await this.resolveNode(progress); try { - await session.send("DOM.scrollIntoViewIfNeeded", { objectId }).catch(() => {}); - const box = await session.send("DOM.getBoxModel", { - objectId, - }); + await runLocatorStep(progress, "scrolling into view", () => + session.send("DOM.scrollIntoViewIfNeeded", { objectId }).catch((error) => { + progress?.throwIfStopped(); + if (progress && isCdpClosedError(error)) throw error; + }), + ); + const box = await runLocatorStep(progress, "reading element geometry", () => + session.send("DOM.getBoxModel", { + objectId, + }), + ); if (!box.model) throw new Error(`Element not visible (no box model): ${this.selector}`); const { cx, cy } = this.centerFromBoxContent(box.model.content); return { x: Math.round(cx), y: Math.round(cy) }; } finally { - await session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + const release = () => + session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + if (progress) await progress.cleanup(release); + else await release(); + progress?.throwIfStopped(); } } @@ -271,26 +294,39 @@ export class Locator { * Move the mouse cursor to the element's visual center without clicking. * - Scrolls into view best-effort, resolves geometry, then dispatches a mouse move. */ - async hover(): Promise { + async hover(progress?: Progress): Promise { const session = this.frame.session; - const { objectId } = await this.resolveNode(); + const { objectId } = await this.resolveNode(progress); try { - await session.send("DOM.scrollIntoViewIfNeeded", { objectId }).catch(() => {}); + await runLocatorStep(progress, "scrolling into view", () => + session.send("DOM.scrollIntoViewIfNeeded", { objectId }).catch((error) => { + progress?.throwIfStopped(); + if (progress && isCdpClosedError(error)) throw error; + }), + ); - const box = await session.send("DOM.getBoxModel", { - objectId, - }); + const box = await runLocatorStep(progress, "reading element geometry", () => + session.send("DOM.getBoxModel", { + objectId, + }), + ); if (!box.model) throw new Error(`Element not visible (no box model): ${this.selector}`); const { cx, cy } = this.centerFromBoxContent(box.model.content); - await session.send("Input.dispatchMouseEvent", { - type: "mouseMoved", - x: cx, - y: cy, - button: "none", - } as Protocol.Input.DispatchMouseEventRequest); + await runLocatorStep(progress, "dispatching mouse events", () => + session.send("Input.dispatchMouseEvent", { + type: "mouseMoved", + x: cx, + y: cy, + button: "none", + } as Protocol.Input.DispatchMouseEventRequest), + ); } finally { - await session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + const release = () => + session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + if (progress) await progress.cleanup(release); + else await release(); + progress?.throwIfStopped(); } } @@ -302,21 +338,28 @@ export class Locator { * 3) Read geometry via `DOM.getBoxModel({ objectId })` → compute a center point. * 4) Synthesize mouse press + release via `Input.dispatchMouseEvent`. */ - async click(options?: { button?: MouseButton; clickCount?: number }): Promise { + async click( + options?: { button?: MouseButton; clickCount?: number }, + progress?: Progress, + ): Promise { const session = this.frame.session; - const { objectId } = await this.resolveNode(); + const { objectId } = await this.resolveNode(progress); const button = options?.button ?? "left"; const clickCount = options?.clickCount ?? 1; try { // Scroll into view using objectId (avoids frontend nodeId dependence) - await session.send("DOM.scrollIntoViewIfNeeded", { objectId }); + await runLocatorStep(progress, "scrolling into view", () => + session.send("DOM.scrollIntoViewIfNeeded", { objectId }), + ); // Get geometry using objectId - const box = await session.send("DOM.getBoxModel", { - objectId, - }); + const box = await runLocatorStep(progress, "reading element geometry", () => + session.send("DOM.getBoxModel", { + objectId, + }), + ); if (!box.model) throw new Error(`Element not visible (no box model): ${this.selector}`); const { cx, cy } = this.centerFromBoxContent(box.model.content); @@ -324,43 +367,47 @@ export class Locator { // from network/CPU jitter between round trips. const dispatches: Array> = []; dispatches.push( - session.send("Input.dispatchMouseEvent", { - type: "mouseMoved", - x: cx, - y: cy, - button: "none", - } as Protocol.Input.DispatchMouseEventRequest), - ); - - for (let i = 1; i <= clickCount; i++) { - dispatches.push( + runLocatorStep(progress, "dispatching mouse events", () => session.send("Input.dispatchMouseEvent", { - type: "mousePressed", + type: "mouseMoved", x: cx, y: cy, - button, - clickCount: i, + button: "none", } as Protocol.Input.DispatchMouseEventRequest), + ), + ); + + for (let i = 1; i <= clickCount; i++) { + dispatches.push( + runLocatorStep(progress, "dispatching mouse events", () => + session.send("Input.dispatchMouseEvent", { + type: "mousePressed", + x: cx, + y: cy, + button, + clickCount: i, + } as Protocol.Input.DispatchMouseEventRequest), + ), ); dispatches.push( - session.send("Input.dispatchMouseEvent", { - type: "mouseReleased", - x: cx, - y: cy, - button, - clickCount: i, - } as Protocol.Input.DispatchMouseEventRequest), + runLocatorStep(progress, "dispatching mouse events", () => + session.send("Input.dispatchMouseEvent", { + type: "mouseReleased", + x: cx, + y: cy, + button, + clickCount: i, + } as Protocol.Input.DispatchMouseEventRequest), + ), ); } await Promise.all(dispatches); } finally { - // release the element handle - try { - await session.send("Runtime.releaseObject", { objectId }); - } catch { - // If the context navigated or was destroyed (e.g., link opens new tab), - // releaseObject may fail with -32000. Ignore as best-effort cleanup. - } + const release = () => + session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + if (progress) await progress.cleanup(release); + else await release(); + progress?.throwIfStopped(); } } @@ -369,32 +416,46 @@ export class Locator { * - Does not synthesize real pointer input; directly dispatches an event. * - Useful for elements that rely on click handlers without needing hit-testing. */ - async sendClickEvent(options?: { - bubbles?: boolean; - cancelable?: boolean; - composed?: boolean; - detail?: number; - }): Promise { + async sendClickEvent( + options?: { + bubbles?: boolean; + cancelable?: boolean; + composed?: boolean; + detail?: number; + }, + progress?: Progress, + ): Promise { const session = this.frame.session; - const { objectId } = await this.resolveNode(); + const { objectId } = await this.resolveNode(progress); const bubbles = options?.bubbles ?? true; const cancelable = options?.cancelable ?? true; const composed = options?.composed ?? true; const detail = options?.detail ?? 1; try { - await session.send("DOM.scrollIntoViewIfNeeded", { objectId }).catch(() => {}); - await session.send("Runtime.callFunctionOn", { - objectId, - functionDeclaration: dispatchDomClick.toString(), - arguments: [ - { - value: { bubbles, cancelable, composed, detail }, - }, - ], - returnByValue: true, - }); + await runLocatorStep(progress, "scrolling into view", () => + session.send("DOM.scrollIntoViewIfNeeded", { objectId }).catch((error) => { + progress?.throwIfStopped(); + if (progress && isCdpClosedError(error)) throw error; + }), + ); + await runLocatorStep(progress, "evaluating element", () => + session.send("Runtime.callFunctionOn", { + objectId, + functionDeclaration: dispatchDomClick.toString(), + arguments: [ + { + value: { bubbles, cancelable, composed, detail }, + }, + ], + returnByValue: true, + }), + ); } finally { - await session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + const release = () => + session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + if (progress) await progress.cleanup(release); + else await release(); + progress?.throwIfStopped(); } } @@ -403,18 +464,24 @@ export class Locator { * - If the element is or , scrolls the window/document. * - Otherwise, scrolls the element itself via element.scrollTo. */ - async scrollTo(percent: number | string): Promise { + async scrollTo(percent: number | string, progress?: Progress): Promise { const session = this.frame.session; - const { objectId } = await this.resolveNode(); + const { objectId } = await this.resolveNode(progress); try { - await session.send("Runtime.callFunctionOn", { - objectId, - functionDeclaration: scrollElementToPercent.toString(), - arguments: [{ value: percent as unknown as number }], - returnByValue: true, - }); + await runLocatorStep(progress, "evaluating element", () => + session.send("Runtime.callFunctionOn", { + objectId, + functionDeclaration: scrollElementToPercent.toString(), + arguments: [{ value: percent as unknown as number }], + returnByValue: true, + }), + ); } finally { - await session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + const release = () => + session.send("Runtime.releaseObject", { objectId }).catch(() => {}); + if (progress) await progress.cleanup(release); + else await release(); + progress?.throwIfStopped(); } } @@ -582,46 +649,50 @@ export class Locator { * Select one or more options on a `