diff --git a/packages/extension/controllers/locatorController.ts b/packages/extension/controllers/locatorController.ts index 30171e639..b1a9f2ddf 100644 --- a/packages/extension/controllers/locatorController.ts +++ b/packages/extension/controllers/locatorController.ts @@ -1,6 +1,6 @@ import type { LocatorClickParams, - LocatorDescriptor, + LocatorParams, LocatorFillParams, LocatorHighlightParams, LocatorScrollToParams, @@ -23,42 +23,42 @@ export function createLocatorController(runtime: StagehandRuntime) { return runtime.locatorFill(params); } - async function hover(params: LocatorDescriptor, { logger }: HandlerContext) { + async function hover(params: LocatorParams, { logger }: HandlerContext) { logger.debug("locator.hover", {}); return runtime.locatorHover(params); } - async function count(params: LocatorDescriptor, { logger }: HandlerContext) { + async function count(params: LocatorParams, { logger }: HandlerContext) { logger.debug("locator.count", {}); return runtime.locatorCount(params); } - async function isChecked(params: LocatorDescriptor, { logger }: HandlerContext) { + async function isChecked(params: LocatorParams, { logger }: HandlerContext) { logger.debug("locator.is_checked", {}); return runtime.locatorIsChecked(params); } - async function inputValue(params: LocatorDescriptor, { logger }: HandlerContext) { + async function inputValue(params: LocatorParams, { logger }: HandlerContext) { logger.debug("locator.input_value", {}); return runtime.locatorInputValue(params); } - async function isVisible(params: LocatorDescriptor, { logger }: HandlerContext) { + async function isVisible(params: LocatorParams, { logger }: HandlerContext) { logger.debug("locator.is_visible", {}); return runtime.locatorIsVisible(params); } - async function innerText(params: LocatorDescriptor, { logger }: HandlerContext) { + async function innerText(params: LocatorParams, { logger }: HandlerContext) { logger.debug("locator.inner_text", {}); return runtime.locatorInnerText(params); } - async function innerHtml(params: LocatorDescriptor, { logger }: HandlerContext) { + async function innerHtml(params: LocatorParams, { logger }: HandlerContext) { logger.debug("locator.inner_html", {}); return runtime.locatorInnerHtml(params); } - async function textContent(params: LocatorDescriptor, { logger }: HandlerContext) { + async function textContent(params: LocatorParams, { logger }: HandlerContext) { logger.debug("locator.text_content", {}); return runtime.locatorTextContent(params); } @@ -68,7 +68,7 @@ export function createLocatorController(runtime: StagehandRuntime) { return runtime.locatorScrollTo(params); } - async function centroid(params: LocatorDescriptor, { logger }: HandlerContext) { + async function centroid(params: LocatorParams, { logger }: HandlerContext) { logger.debug("locator.centroid", {}); return runtime.locatorCentroid(params); } diff --git a/packages/extension/runtime.ts b/packages/extension/runtime.ts index 392651e6b..afc708172 100644 --- a/packages/extension/runtime.ts +++ b/packages/extension/runtime.ts @@ -1,3 +1,5 @@ +import { DEFAULT_LOCATOR_TIMEOUT_MS } from "@browserbasehq/stagehand-protocol/schemas"; +import { runWithProgress, type Progress } from "./understudy/progress.js"; import { ShadowRootEvaluationUnavailableError } from "./errors.js"; import type { ClearCookieOptions, @@ -33,6 +35,7 @@ import type { LocatorCentroidResult, LocatorCountResult, LocatorDescriptor, + LocatorParams, LocatorFillParams, LocatorFillResult, LocatorHighlightParams, @@ -220,23 +223,30 @@ export type UnderstudyRuntimeClipboard = { }; export type UnderstudyRuntimeLocator = { - click(options?: LocatorClickParams["options"]): Promise | void; - hover(): Promise | void; - fill(value: string): Promise | void; - count(): Promise; - isChecked(): Promise; - inputValue(): Promise; - isVisible(): Promise; - innerText(): Promise; - innerHtml(): Promise; - textContent(): Promise; - scrollTo(percent: LocatorScrollToParams["percent"]): Promise | void; - centroid(): Promise; - highlight(options?: LocatorHighlightParams["options"]): Promise | void; - sendClickEvent(options?: LocatorSendClickEventParams["options"]): Promise | void; - type(text: string, options?: LocatorTypeParams["options"]): Promise | void; - selectOption(values: LocatorSelectOptionParams["values"]): Promise; - setInputFiles(files: SetInputFilesArgument): Promise; + click(options?: LocatorClickParams["options"], progress?: Progress): Promise | void; + hover(progress?: Progress): Promise | void; + fill(value: string, progress?: Progress): Promise | void; + count(progress?: Progress): Promise; + isChecked(progress?: Progress): Promise; + inputValue(progress?: Progress): Promise; + isVisible(progress?: Progress): Promise; + innerText(progress?: Progress): Promise; + innerHtml(progress?: Progress): Promise; + textContent(progress?: Progress): Promise; + scrollTo(percent: LocatorScrollToParams["percent"], progress?: Progress): Promise | void; + centroid(progress?: Progress): Promise; + highlight(options?: LocatorHighlightParams["options"], progress?: Progress): Promise | void; + sendClickEvent( + options?: LocatorSendClickEventParams["options"], + progress?: Progress, + ): Promise | void; + type( + text: string, + options?: LocatorTypeParams["options"], + progress?: Progress, + ): Promise | void; + selectOption(values: LocatorSelectOptionParams["values"], progress?: Progress): Promise; + setInputFiles(files: SetInputFilesArgument, progress?: Progress): Promise; nth(index: number): UnderstudyRuntimeLocator; }; @@ -823,99 +833,144 @@ export class StagehandRuntime { } async locatorClick(params: LocatorClickParams): Promise { - await this.resolveLocator(params).click(params.options); + await this.runLocator("locator.click", params, (locator, progress) => + locator.click(params.options, progress), + ); return { clicked: true }; } - async locatorHover(params: LocatorDescriptor): Promise { - await this.resolveLocator(params).hover(); + async locatorHover(params: LocatorParams): Promise { + await this.runLocator("locator.hover", params, (locator, progress) => locator.hover(progress)); return { hovered: true }; } async locatorFill(params: LocatorFillParams): Promise { - await this.resolveLocator(params).fill(params.value); + await this.runLocator("locator.fill", params, (locator, progress) => + locator.fill(params.value, progress), + ); return { filled: true }; } - async locatorCount(params: LocatorDescriptor): Promise { - return await this.resolveLocator(params).count(); + async locatorCount(params: LocatorParams): Promise { + return await this.runLocator("locator.count", params, (locator, progress) => + locator.count(progress), + ); } - async locatorIsChecked(params: LocatorDescriptor): Promise { - return await this.resolveLocator(params).isChecked(); + async locatorIsChecked(params: LocatorParams): Promise { + return await this.runLocator("locator.is_checked", params, (locator, progress) => + locator.isChecked(progress), + ); } - async locatorInputValue(params: LocatorDescriptor): Promise { - return await this.resolveLocator(params).inputValue(); + async locatorInputValue(params: LocatorParams): Promise { + return await this.runLocator("locator.input_value", params, (locator, progress) => + locator.inputValue(progress), + ); } - async locatorIsVisible(params: LocatorDescriptor): Promise { - return await this.resolveLocator(params).isVisible(); + async locatorIsVisible(params: LocatorParams): Promise { + return await this.runLocator("locator.is_visible", params, (locator, progress) => + locator.isVisible(progress), + ); } - async locatorInnerText(params: LocatorDescriptor): Promise { - return await this.resolveLocator(params).innerText(); + async locatorInnerText(params: LocatorParams): Promise { + return await this.runLocator("locator.inner_text", params, (locator, progress) => + locator.innerText(progress), + ); } - async locatorInnerHtml(params: LocatorDescriptor): Promise { - return await this.resolveLocator(params).innerHtml(); + async locatorInnerHtml(params: LocatorParams): Promise { + return await this.runLocator("locator.inner_html", params, (locator, progress) => + locator.innerHtml(progress), + ); } - async locatorTextContent(params: LocatorDescriptor): Promise { - return await this.resolveLocator(params).textContent(); + async locatorTextContent(params: LocatorParams): Promise { + return await this.runLocator("locator.text_content", params, (locator, progress) => + locator.textContent(progress), + ); } async locatorScrollTo(params: LocatorScrollToParams): Promise { - await this.resolveLocator(params).scrollTo(params.percent); + await this.runLocator("locator.scroll_to", params, (locator, progress) => + locator.scrollTo(params.percent, progress), + ); return { scrolled: true }; } - async locatorCentroid(params: LocatorDescriptor): Promise { - return await this.resolveLocator(params).centroid(); + async locatorCentroid(params: LocatorParams): Promise { + return await this.runLocator("locator.centroid", params, (locator, progress) => + locator.centroid(progress), + ); } async locatorHighlight(params: LocatorHighlightParams): Promise { - await this.resolveLocator(params).highlight(params.options); + await this.runLocator("locator.highlight", params, (locator, progress) => + locator.highlight(params.options, progress), + ); return { highlighted: true }; } async locatorSendClickEvent( params: LocatorSendClickEventParams, ): Promise { - await this.resolveLocator(params).sendClickEvent(params.options); + await this.runLocator("locator.send_click_event", params, (locator, progress) => + locator.sendClickEvent(params.options, progress), + ); return { clicked: true }; } async locatorType(params: LocatorTypeParams): Promise { - await this.resolveLocator(params).type(params.text, params.options); + await this.runLocator("locator.type", params, (locator, progress) => + locator.type(params.text, params.options, progress), + ); return { typed: true }; } async locatorSelectOption(params: LocatorSelectOptionParams): Promise { - return await this.resolveLocator(params).selectOption(params.values); + return await this.runLocator("locator.select_option", params, (locator, progress) => + locator.selectOption(params.values, progress), + ); } async locatorSetInputFiles( params: LocatorSetInputFilesParams, ): Promise { - await this.resolveLocator(params).setInputFiles( - params.files.map((file) => { - const binary = globalThis.atob(file.data); - const buffer = new Uint8Array(binary.length); - for (let index = 0; index < binary.length; index += 1) { - buffer[index] = binary.charCodeAt(index); - } - return { - name: file.name, - mimeType: file.mimeType, - buffer, - lastModified: file.lastModified, - }; - }), + await this.runLocator("locator.set_input_files", params, (locator, progress) => + locator.setInputFiles( + params.files.map((file) => { + progress.throwIfStopped(); + const binary = globalThis.atob(file.data); + const buffer = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + buffer[index] = binary.charCodeAt(index); + } + return { + name: file.name, + mimeType: file.mimeType, + buffer, + lastModified: file.lastModified, + }; + }), + progress, + ), ); return { set: true }; } + private runLocator( + name: string, + params: LocatorParams, + action: (locator: UnderstudyRuntimeLocator, progress: Progress) => Promise | T, + ): Promise { + return runWithProgress( + { name, timeout: params.options?.timeout ?? DEFAULT_LOCATOR_TIMEOUT_MS }, + async (progress) => action(this.resolveLocator(params), progress), + ); + } + async close(): Promise { await this.enqueueLifecycle(async () => { const session = this.browserSession; diff --git a/packages/extension/tests/runtime-locator-timeouts.test.ts b/packages/extension/tests/runtime-locator-timeouts.test.ts new file mode 100644 index 000000000..a5ecc0752 --- /dev/null +++ b/packages/extension/tests/runtime-locator-timeouts.test.ts @@ -0,0 +1,85 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { StagehandMethods } from "@browserbasehq/stagehand-protocol/schema-registry"; +import { DEFAULT_LOCATOR_TIMEOUT_MS } from "@browserbasehq/stagehand-protocol/schemas"; +import { createStagehandRuntime, type UnderstudyRuntimeLocator } from "../runtime.js"; +import { Progress } from "../understudy/progress.js"; + +const methods = Object.entries(StagehandMethods).filter(([, method]) => + method.name.startsWith("locator."), +); +const fields: Record> = { + locatorFill: { value: "hello" }, + locatorType: { text: "hello" }, + locatorScrollTo: { percent: 50 }, + locatorSelectOption: { values: "a" }, + locatorSetInputFiles: { files: [{ name: "hello.txt", data: "aGVsbG8=" }] }, +}; + +describe("runtime locator deadlines", () => { + beforeEach(() => vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout", "performance"] })); + afterEach(() => { + vi.restoreAllMocks(); + vi.useRealTimers(); + }); + + describe.each(methods)("%s", (key, method) => { + it.each([undefined, {}, { timeout: 50 }, { timeout: 0 }])( + "owns a deadline before resolution with options %j", + async (options) => { + const runtime = createStagehandRuntime(); + let progress: Progress | undefined; + let complete!: () => void; + const work = new Promise((resolve) => { + complete = resolve; + }); + const actionName = key.slice("locator".length); + const action = vi.fn((...args: unknown[]) => { + progress = args.at(-1) as Progress; + expect(progress).toBeInstanceOf(Progress); + expect(progress.name).toBe(method.name); + return work; + }); + const budget = options?.timeout ?? DEFAULT_LOCATOR_TIMEOUT_MS; + vi.spyOn(runtime, "resolveLocator").mockImplementation(() => { + expect(vi.getTimerCount()).toBe(budget === 0 ? 0 : 1); + // Resolution has already spent part of this call's budget. + vi.advanceTimersByTime(10); + return { + [actionName[0]!.toLowerCase() + actionName.slice(1)]: action, + } as unknown as UnderstudyRuntimeLocator; + }); + const invoke = runtime[key as keyof typeof runtime] as ( + params: unknown, + ) => Promise; + const pending = invoke.call(runtime, { + pageId: "page-1", + selector: "iframe >> button", + ...fields[key], + ...(options ? { options } : {}), + }); + const observed = pending.catch((error: unknown) => error); + await vi.advanceTimersByTimeAsync(0); + expect(action).toHaveBeenCalledOnce(); + expect(progress!.remainingMs()).toBe(budget === 0 ? Infinity : budget - 10); + if (budget === 0) { + await vi.advanceTimersByTimeAsync(DEFAULT_LOCATOR_TIMEOUT_MS + 1); + expect(progress!.signal.aborted).toBe(false); + complete(); + await pending; + } else { + await vi.advanceTimersByTimeAsync(budget - 11); + expect(progress!.signal.aborted).toBe(false); + await vi.advanceTimersByTimeAsync(1); + expect(await observed).toMatchObject({ + name: "TimeoutError", + message: expect.stringContaining(method.name), + }); + expect(progress!.signal.aborted).toBe(true); + complete(); + } + await vi.advanceTimersByTimeAsync(0); + expect(vi.getTimerCount()).toBe(0); + }, + ); + }); +}); diff --git a/packages/extension/understudy/locatorActions.test.ts b/packages/extension/understudy/locatorActions.test.ts index a83456a00..3bcaa7f46 100644 --- a/packages/extension/understudy/locatorActions.test.ts +++ b/packages/extension/understudy/locatorActions.test.ts @@ -1,5 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { TimeoutError } from "../errors.js"; +import { createStagehandRuntime } from "../runtime.js"; import { assignFilePayloadsToInputElement, fillElementValue, @@ -67,6 +68,19 @@ function createLocator(selector = "button") { return { locator, frame, resolveNode, readiness, send }; } +function runPublicAction(locator: Locator, action: "highlight" | "upload") { + const runtime = createStagehandRuntime(); + vi.spyOn(runtime, "resolveLocator").mockReturnValue(locator); + const descriptor = { pageId: "page-1", selector: "button" }; + return action === "highlight" + ? runtime.locatorHighlight({ ...descriptor, options: { durationMs: 0, timeout: 100 } }) + : runtime.locatorSetInputFiles({ + ...descriptor, + files: [{ name: "test.txt", data: "YWJj", lastModified: 1 }], + options: { timeout: 100 }, + }); +} + function createFillLocator(legacy = false) { const fixture = createLocator(); let nextNode = 0; @@ -753,7 +767,7 @@ describe("locator action deadlines", () => { ); it.each(["highlight", "upload"] as const)( - "preserves the primary %s error while cleanup is stalled", + "preserves the primary %s error through the runtime while cleanup is stalled", async (action) => { const { locator, send } = createLocator(); const primary = new Error("action failed"); @@ -771,11 +785,7 @@ describe("locator action deadlines", () => { 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), - ); + const pending = runPublicAction(locator, action); void pending.then(settled, settled); try { await vi.advanceTimersByTimeAsync(0); @@ -792,9 +802,14 @@ describe("locator action deadlines", () => { }, ); - it.each(["highlight", "upload"] as const)( - "reports an expired %s without waiting for stalled cleanup", - async (action) => { + it.each([ + ["highlight", false], + ["upload", false], + ["highlight", true], + ["upload", true], + ] as const)( + "reports an expired %s without waiting for stalled cleanup (runtime: %s)", + async (action, throughRuntime) => { const { locator, send } = createLocator(); const work = deferred(); const cleanup = deferred(); @@ -810,11 +825,11 @@ describe("locator action deadlines", () => { return work.promise; return respond(method, params); }); - const progress = createProgress(); - const pending = - action === "highlight" - ? locator.highlight({ durationMs: 0 }, progress) - : locator.setInputFiles(upload, progress); + const pending = throughRuntime + ? runPublicAction(locator, action) + : action === "highlight" + ? locator.highlight({ durationMs: 0 }, createProgress()) + : locator.setInputFiles(upload, createProgress()); const settled = vi.fn(); void pending.then(settled, settled); try { diff --git a/packages/integrations/core/src/facade/runtime.ts b/packages/integrations/core/src/facade/runtime.ts index f4d9faf75..a5311f1ab 100644 --- a/packages/integrations/core/src/facade/runtime.ts +++ b/packages/integrations/core/src/facade/runtime.ts @@ -46,23 +46,29 @@ type QueryStep = type RoleStep = Extract; +type LocatorTimeoutOptions = { timeout?: number }; + type RawLocator = { - click(options?: { button?: "left" | "right" | "middle"; clickCount?: number }): Promise; - hover(): Promise; - fill(value: string): Promise; - type(text: string, options?: { delay?: number }): Promise; - selectOption(values: string | string[]): Promise; - setInputFiles(files: unknown): Promise; - count(): Promise; + click(options?: { + button?: "left" | "right" | "middle"; + clickCount?: number; + timeout?: number; + }): Promise; + hover(options?: LocatorTimeoutOptions): Promise; + fill(value: string, options?: LocatorTimeoutOptions): Promise; + type(text: string, options?: { delay?: number; timeout?: number }): Promise; + selectOption(values: string | string[], options?: LocatorTimeoutOptions): Promise; + setInputFiles(files: unknown, options?: LocatorTimeoutOptions): Promise; + count(options?: LocatorTimeoutOptions): Promise; nth(index: number): RawLocator; - isVisible(): Promise; - isChecked(): Promise; - inputValue(): Promise; - innerText(): Promise; - innerHtml(): Promise; - textContent(): Promise; - scrollTo(percent: number): Promise; - centroid(): Promise<{ x: number; y: number }>; + isVisible(options?: LocatorTimeoutOptions): Promise; + isChecked(options?: LocatorTimeoutOptions): Promise; + inputValue(options?: LocatorTimeoutOptions): Promise; + innerText(options?: LocatorTimeoutOptions): Promise; + innerHtml(options?: LocatorTimeoutOptions): Promise; + textContent(options?: LocatorTimeoutOptions): Promise; + scrollTo(percent: number, options?: LocatorTimeoutOptions): Promise; + centroid(options?: LocatorTimeoutOptions): Promise<{ x: number; y: number }>; }; type CompatSelectOption = @@ -169,6 +175,13 @@ export async function createPlaywrightCompatRuntime( error?: { name: string; message: string; stack?: string }; }; + const locatorTimeoutOptions = (deadline: number, method: string): LocatorTimeoutOptions => { + if (deadline === Infinity) return { timeout: 0 }; + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error(`${method}: timed out`); + return { timeout: remaining }; + }; + const stats: CompatStats = { calls: {}, misses: {} }; const record = (bucket: "calls" | "misses", method: string): void => { stats[bucket][method] = (stats[bucket][method] ?? 0) + 1; @@ -1396,7 +1409,7 @@ export async function createPlaywrightCompatRuntime( private async withTaggedTarget( method: string, - action: (locator: RawLocator) => Promise, + action: (locator: RawLocator, options: LocatorTimeoutOptions) => Promise, options: { timeout?: number } = {}, ): Promise { record("calls", method); @@ -1409,7 +1422,7 @@ export async function createPlaywrightCompatRuntime( const deadline = timeout === 0 ? Infinity : Date.now() + timeout; let result: QueryResult = { count: 0 }; let lastActionError: unknown; - while (Date.now() <= deadline) { + while (Date.now() < deadline) { result = await this.state.execute(this.plan, "inspect"); if (result.count > 1) throw await this.strictModeViolation(method, result.count); if (result.count === 1) { @@ -1421,7 +1434,10 @@ export async function createPlaywrightCompatRuntime( await this.state.execute(this.plan, "tag", { token }); let actionSucceeded = false; try { - await action(this.state.rawPage.locator(`[data-stagehand-pw-compat="${token}"]`)); + await action( + this.state.rawPage.locator(`[data-stagehand-pw-compat="${token}"]`), + locatorTimeoutOptions(deadline, method), + ); actionSucceeded = true; } catch (error) { lastActionError = error; @@ -1481,8 +1497,9 @@ export async function createPlaywrightCompatRuntime( } await this.withTaggedTarget( method, - (locator) => + (locator, timeoutOptions) => locator.click({ + ...timeoutOptions, ...(typeof options.button === "string" ? { button: options.button as "left" | "right" | "middle" } : {}), @@ -1493,17 +1510,21 @@ export async function createPlaywrightCompatRuntime( } async fill(value: string, options: Record = {}): Promise { - await this.withTaggedTarget("locator.fill", (locator) => locator.fill(value), options); + await this.withTaggedTarget( + "locator.fill", + (locator, timeoutOptions) => locator.fill(value, timeoutOptions), + options, + ); } async type(value: string, options: Record = {}): Promise { await this.withTaggedTarget( "locator.type", - (locator) => - locator.type( - value, - typeof options.delay === "number" ? { delay: options.delay } : undefined, - ), + (locator, timeoutOptions) => + locator.type(value, { + ...timeoutOptions, + ...(typeof options.delay === "number" ? { delay: options.delay } : {}), + }), options, ); } @@ -1516,8 +1537,8 @@ export async function createPlaywrightCompatRuntime( async press(key: string, options: Record = {}): Promise { await this.withTaggedTarget( "locator.press", - async (locator) => { - await locator.click(); + async (locator, timeoutOptions) => { + await locator.click(timeoutOptions); await this.state.rawPage.keyPress( key, typeof options.delay === "number" ? { delay: options.delay } : undefined, @@ -1528,7 +1549,11 @@ export async function createPlaywrightCompatRuntime( } async hover(options: Record = {}): Promise { - await this.withTaggedTarget("locator.hover", (locator) => locator.hover(), options); + await this.withTaggedTarget( + "locator.hover", + (locator, timeoutOptions) => locator.hover(timeoutOptions), + options, + ); } async selectOption( @@ -1564,9 +1589,10 @@ export async function createPlaywrightCompatRuntime( let selected: string[] = []; await this.withTaggedTarget( "locator.selectOption", - async (locator) => { + async (locator, timeoutOptions) => { selected = await locator.selectOption( Array.isArray(values) ? normalized : normalized[0]!, + timeoutOptions, ); }, options, @@ -1577,7 +1603,7 @@ export async function createPlaywrightCompatRuntime( async setInputFiles(files: unknown, options: Record = {}): Promise { await this.withTaggedTarget( "locator.setInputFiles", - (locator) => locator.setInputFiles(files), + (locator, timeoutOptions) => locator.setInputFiles(files, timeoutOptions), options, ); } @@ -2032,11 +2058,11 @@ export async function createPlaywrightCompatRuntime( return { xpaths, names }; } - private async resolve(): Promise { + private async resolve(options?: LocatorTimeoutOptions): Promise { const tail = this.selectorTail(); if (tail !== null) { const raw = this.state.rawPage.locator([...this.hops, tail].join(" >> ")); - const count = await raw.count(); + const count = await raw.count(options); if (this.nthIndex === undefined) return { raw, count, candidates: [] }; const index = this.nthIndex < 0 ? count + this.nthIndex : this.nthIndex; const within = index >= 0 && index < count; @@ -2058,14 +2084,14 @@ export async function createPlaywrightCompatRuntime( } private async single(method: string, timeout = 10_000): Promise { - const deadline = Date.now() + timeout; + const deadline = timeout === 0 ? Infinity : Date.now() + timeout; let resolution: FrameResolution = { raw: this.state.rawPage.locator("__none__"), count: 0, candidates: [], }; - while (Date.now() <= deadline) { - resolution = await this.resolve(); + while (Date.now() < deadline) { + resolution = await this.resolve(locatorTimeoutOptions(deadline, method)); if (resolution.count > 1) { throw new Error(strictModeMessage(method, resolution.count, resolution.candidates)); } @@ -2079,17 +2105,17 @@ export async function createPlaywrightCompatRuntime( private async act( method: string, - action: (raw: RawLocator) => Promise, + action: (raw: RawLocator, options: LocatorTimeoutOptions) => Promise, options: Record = {}, ): Promise { record("calls", method); const timeout = typeof options.timeout === "number" ? options.timeout : 10_000; - const deadline = Date.now() + timeout; + const deadline = timeout === 0 ? Infinity : Date.now() + timeout; let lastError: unknown; - while (Date.now() <= deadline) { + while (Date.now() < deadline) { let raw: RawLocator; try { - raw = await this.single(method, Math.max(1, deadline - Date.now())); + raw = await this.single(method, locatorTimeoutOptions(deadline, method).timeout); } catch (resolveError) { // A re-resolve that runs out of the remaining window must not mask // the action error that caused the retry (e.g. a layout failure). @@ -2097,7 +2123,7 @@ export async function createPlaywrightCompatRuntime( throw resolveError; } try { - await action(raw); + await action(raw, locatorTimeoutOptions(deadline, method)); await this.state.refreshUrl(); return; } catch (error) { @@ -2125,8 +2151,9 @@ export async function createPlaywrightCompatRuntime( } await this.act( "frameLocator.locator.click", - (raw) => + (raw, timeoutOptions) => raw.click({ + ...timeoutOptions, ...(typeof options.button === "string" ? { button: options.button as "left" | "right" | "middle" } : {}), @@ -2141,11 +2168,19 @@ export async function createPlaywrightCompatRuntime( } async hover(options: Record = {}): Promise { - await this.act("frameLocator.locator.hover", (raw) => raw.hover(), options); + await this.act( + "frameLocator.locator.hover", + (raw, timeoutOptions) => raw.hover(timeoutOptions), + options, + ); } async fill(value: string, options: Record = {}): Promise { - await this.act("frameLocator.locator.fill", (raw) => raw.fill(value), options); + await this.act( + "frameLocator.locator.fill", + (raw, timeoutOptions) => raw.fill(value, timeoutOptions), + options, + ); } async clear(options: Record = {}): Promise { @@ -2155,8 +2190,11 @@ export async function createPlaywrightCompatRuntime( async type(value: string, options: Record = {}): Promise { await this.act( "frameLocator.locator.type", - (raw) => - raw.type(value, typeof options.delay === "number" ? { delay: options.delay } : undefined), + (raw, timeoutOptions) => + raw.type(value, { + ...timeoutOptions, + ...(typeof options.delay === "number" ? { delay: options.delay } : {}), + }), options, ); } @@ -2168,8 +2206,8 @@ export async function createPlaywrightCompatRuntime( async press(key: string, options: Record = {}): Promise { await this.act( "frameLocator.locator.press", - async (raw) => { - await raw.click(); + async (raw, timeoutOptions) => { + await raw.click(timeoutOptions); await this.state.rawPage.keyPress( key, typeof options.delay === "number" ? { delay: options.delay } : undefined, @@ -2196,8 +2234,11 @@ export async function createPlaywrightCompatRuntime( let selected: string[] = []; await this.act( "frameLocator.locator.selectOption", - async (raw) => { - selected = await raw.selectOption(Array.isArray(values) ? normalized : normalized[0]!); + async (raw, timeoutOptions) => { + selected = await raw.selectOption( + Array.isArray(values) ? normalized : normalized[0]!, + timeoutOptions, + ); }, options, ); @@ -2207,19 +2248,27 @@ export async function createPlaywrightCompatRuntime( async setInputFiles(files: unknown, options: Record = {}): Promise { await this.act( "frameLocator.locator.setInputFiles", - (raw) => raw.setInputFiles(files), + (raw, timeoutOptions) => raw.setInputFiles(files, timeoutOptions), options, ); } async check(options: Record = {}): Promise { if (!(await this.isChecked())) - await this.act("frameLocator.locator.check", (raw) => raw.click(), options); + await this.act( + "frameLocator.locator.check", + (raw, timeoutOptions) => raw.click(timeoutOptions), + options, + ); } async uncheck(options: Record = {}): Promise { if (await this.isChecked()) - await this.act("frameLocator.locator.uncheck", (raw) => raw.click(), options); + await this.act( + "frameLocator.locator.uncheck", + (raw, timeoutOptions) => raw.click(timeoutOptions), + options, + ); } async scrollIntoViewIfNeeded(options: Record = {}): Promise { @@ -2227,8 +2276,8 @@ export async function createPlaywrightCompatRuntime( "frameLocator.locator.scrollIntoViewIfNeeded", // Native centroid resolves in the owning frame and invokes // DOM.scrollIntoViewIfNeeded; it never scrolls the target's own contents. - async (raw) => { - await raw.centroid(); + async (raw, timeoutOptions) => { + await raw.centroid(timeoutOptions); }, options, ); @@ -2237,7 +2286,11 @@ export async function createPlaywrightCompatRuntime( async focus(options: Record = {}): Promise { // Native type() focuses before its character loop. Empty text with a // nonzero delay performs that focus and dispatches no input/key events. - await this.act("frameLocator.locator.focus", (raw) => raw.type("", { delay: 1 }), options); + await this.act( + "frameLocator.locator.focus", + (raw, timeoutOptions) => raw.type("", { delay: 1, ...timeoutOptions }), + options, + ); } async count(): Promise { diff --git a/packages/integrations/core/tests/facade-locator-actions.test.ts b/packages/integrations/core/tests/facade-locator-actions.test.ts index 4c2de7f12..19cfb8458 100644 --- a/packages/integrations/core/tests/facade-locator-actions.test.ts +++ b/packages/integrations/core/tests/facade-locator-actions.test.ts @@ -28,7 +28,7 @@ async function fixture({ return { count: Date.now() >= appearedAt ? 1 : 0, value: checked, visible: true }; }), waitForTimeout: (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)), - locator: () => ({ click }), + locator: () => ({ click, count: async () => (Date.now() >= appearedAt ? 1 : 0) }), }; // Errors and timeout helpers must survive the callback serialization boundary. const create = new Function( @@ -38,8 +38,15 @@ async function fixture({ page: rawPage, context: { pages: async () => [rawPage] }, } as unknown as Parameters[0]); - const page = runtime.page as { locator(selector: string): Locator }; - return { locator: page.locator("input"), click }; + const page = runtime.page as { + locator(selector: string): Locator; + frameLocator(selector: string): { locator(selector: string): Locator }; + }; + return { + locator: page.locator("input"), + frameLocator: page.frameLocator("iframe").locator("input"), + click, + }; } describe("facade locator action deadlines", () => { @@ -49,6 +56,22 @@ describe("facade locator action deadlines", () => { }); afterEach(() => vi.useRealTimers()); + describe.each(["locator", "frameLocator"] as const)("%s forwarding", (kind) => { + it.each([undefined, 0, 75])("forwards timeout %s to the native action", async (timeout) => { + const fixtureResult = await fixture(); + await fixtureResult[kind].click(timeout === undefined ? {} : { timeout }); + expect(fixtureResult.click).toHaveBeenCalledWith({ timeout: timeout ?? 10_000 }); + }); + + it("forwards only the budget left after finding the target", async () => { + const fixtureResult = await fixture({ appearedAt: 100 }); + const pending = fixtureResult[kind].click({ timeout: 150 }); + await vi.advanceTimersByTimeAsync(100); + await pending; + expect(fixtureResult.click).toHaveBeenCalledWith({ timeout: 50 }); + }); + }); + it.each(["check", "uncheck"] as const)("bounds the absent-element %s probe", async (method) => { const { locator, click } = await fixture({ appearedAt: Infinity }); const pending = expect(locator[method]({ timeout: 60 })).rejects.toThrow(/60ms/); diff --git a/packages/integrations/core/tests/facade-tools.test.ts b/packages/integrations/core/tests/facade-tools.test.ts index c5fd011e5..e418ca340 100644 --- a/packages/integrations/core/tests/facade-tools.test.ts +++ b/packages/integrations/core/tests/facade-tools.test.ts @@ -415,7 +415,7 @@ describe("StagehandFacadeTools.run frameLocator", () => { const clicked = world.locators.find((l) => l.selector.endsWith("button.background")); expect(clicked?.click).toHaveBeenCalledTimes(1); const filled = world.locators.find((l) => l.selector.includes("placeholder")); - expect(filled?.fill).toHaveBeenCalledWith("frog"); + expect(filled?.fill).toHaveBeenCalledWith("frog", { timeout: expect.any(Number) }); }); it("chains nested frameLocator hops and descendant selectors", async () => { diff --git a/packages/protocol/schema-registry.ts b/packages/protocol/schema-registry.ts index 6a5588f88..c09dc1d1e 100644 --- a/packages/protocol/schema-registry.ts +++ b/packages/protocol/schema-registry.ts @@ -36,7 +36,7 @@ import { LocatorClickResultSchema, LocatorCentroidResultSchema, LocatorCountResultSchema, - LocatorDescriptorSchema, + LocatorParamsSchema, LocatorFillParamsSchema, LocatorFillResultSchema, LocatorHighlightParamsSchema, @@ -408,42 +408,42 @@ export const StagehandMethods = { }, locatorHover: { name: "locator.hover", - params: LocatorDescriptorSchema, + params: LocatorParamsSchema, result: LocatorHoverResultSchema, }, locatorCount: { name: "locator.count", - params: LocatorDescriptorSchema, + params: LocatorParamsSchema, result: LocatorCountResultSchema, }, locatorIsChecked: { name: "locator.is_checked", - params: LocatorDescriptorSchema, + params: LocatorParamsSchema, result: LocatorIsCheckedResultSchema, }, locatorInputValue: { name: "locator.input_value", - params: LocatorDescriptorSchema, + params: LocatorParamsSchema, result: LocatorInputValueResultSchema, }, locatorIsVisible: { name: "locator.is_visible", - params: LocatorDescriptorSchema, + params: LocatorParamsSchema, result: LocatorIsVisibleResultSchema, }, locatorInnerText: { name: "locator.inner_text", - params: LocatorDescriptorSchema, + params: LocatorParamsSchema, result: LocatorInnerTextResultSchema, }, locatorInnerHtml: { name: "locator.inner_html", - params: LocatorDescriptorSchema, + params: LocatorParamsSchema, result: LocatorInnerHtmlResultSchema, }, locatorTextContent: { name: "locator.text_content", - params: LocatorDescriptorSchema, + params: LocatorParamsSchema, result: LocatorTextContentResultSchema, }, locatorScrollTo: { @@ -453,7 +453,7 @@ export const StagehandMethods = { }, locatorCentroid: { name: "locator.centroid", - params: LocatorDescriptorSchema, + params: LocatorParamsSchema, result: LocatorCentroidResultSchema, }, locatorHighlight: { diff --git a/packages/protocol/schemas.ts b/packages/protocol/schemas.ts index 1681349fa..e2bd3deae 100644 --- a/packages/protocol/schemas.ts +++ b/packages/protocol/schemas.ts @@ -1979,21 +1979,37 @@ export const PageWaitForSelectorParamsSchema = PageIdParamsSchema.extend({ .optional(), }).meta({ id: "PageWaitForSelectorParams" }); -export const LocatorClickParamsSchema = LocatorDescriptorSchema.extend({ - options: z - .strictObject({ - button: MouseButtonSchema.optional(), - clickCount: z.number().int().positive().optional(), - }) +/** Default execution budget for a public locator call, in milliseconds. */ +export const DEFAULT_LOCATOR_TIMEOUT_MS = 20_000; + +export const LocatorOptionsSchema = z + .strictObject({ + timeout: z + .number() + .nonnegative() + .optional() + .describe("Milliseconds for the whole locator call. Zero disables the timeout."), + }) + .meta({ id: "LocatorOptions" }); + +export const LocatorParamsSchema = LocatorDescriptorSchema.extend({ + options: LocatorOptionsSchema.optional(), +}).meta({ id: "LocatorParams" }); + +export const LocatorClickParamsSchema = LocatorParamsSchema.extend({ + options: LocatorOptionsSchema.extend({ + button: MouseButtonSchema.optional(), + clickCount: z.number().int().positive().optional(), + }) .meta({ id: "LocatorClickOptions" }) .optional(), }).meta({ id: "LocatorClickParams" }); -export const LocatorFillParamsSchema = LocatorDescriptorSchema.extend({ +export const LocatorFillParamsSchema = LocatorParamsSchema.extend({ value: z.string(), }).meta({ id: "LocatorFillParams" }); -export const LocatorScrollToParamsSchema = LocatorDescriptorSchema.extend({ +export const LocatorScrollToParamsSchema = LocatorParamsSchema.extend({ percent: z.union([z.number(), z.string()]), }).meta({ id: "LocatorScrollToParams" }); @@ -2006,40 +2022,37 @@ export const RgbaColorSchema = z }) .meta({ id: "RgbaColor" }); -export const LocatorHighlightParamsSchema = LocatorDescriptorSchema.extend({ - options: z - .strictObject({ - durationMs: z.number().int().nonnegative().optional(), - borderColor: RgbaColorSchema.optional(), - contentColor: RgbaColorSchema.optional(), - }) +export const LocatorHighlightParamsSchema = LocatorParamsSchema.extend({ + options: LocatorOptionsSchema.extend({ + durationMs: z.number().int().nonnegative().optional(), + borderColor: RgbaColorSchema.optional(), + contentColor: RgbaColorSchema.optional(), + }) .meta({ id: "LocatorHighlightOptions" }) .optional(), }).meta({ id: "LocatorHighlightParams" }); -export const LocatorSendClickEventParamsSchema = LocatorDescriptorSchema.extend({ - options: z - .strictObject({ - bubbles: z.boolean().optional(), - cancelable: z.boolean().optional(), - composed: z.boolean().optional(), - detail: z.number().optional(), - }) +export const LocatorSendClickEventParamsSchema = LocatorParamsSchema.extend({ + options: LocatorOptionsSchema.extend({ + bubbles: z.boolean().optional(), + cancelable: z.boolean().optional(), + composed: z.boolean().optional(), + detail: z.number().optional(), + }) .meta({ id: "LocatorSendClickEventOptions" }) .optional(), }).meta({ id: "LocatorSendClickEventParams" }); -export const LocatorTypeParamsSchema = LocatorDescriptorSchema.extend({ +export const LocatorTypeParamsSchema = LocatorParamsSchema.extend({ text: z.string(), - options: z - .strictObject({ - delay: z.number().nonnegative().optional(), - }) + options: LocatorOptionsSchema.extend({ + delay: z.number().nonnegative().optional(), + }) .meta({ id: "LocatorTypeOptions" }) .optional(), }).meta({ id: "LocatorTypeParams" }); -export const LocatorSelectOptionParamsSchema = LocatorDescriptorSchema.extend({ +export const LocatorSelectOptionParamsSchema = LocatorParamsSchema.extend({ values: z.union([z.string(), z.array(z.string())]), }).meta({ id: "LocatorSelectOptionParams" }); @@ -2066,7 +2079,7 @@ export const InputFilePayloadSchema = z }) .meta({ id: "InputFilePayload" }); -export const LocatorSetInputFilesParamsSchema = LocatorDescriptorSchema.extend({ +export const LocatorSetInputFilesParamsSchema = LocatorParamsSchema.extend({ files: z.array(InputFilePayloadSchema), }).meta({ id: "LocatorSetInputFilesParams" }); diff --git a/packages/protocol/stagehand.v4.json b/packages/protocol/stagehand.v4.json index 44459cd8f..0cb59c485 100644 --- a/packages/protocol/stagehand.v4.json +++ b/packages/protocol/stagehand.v4.json @@ -725,7 +725,7 @@ "type": "object", "properties": { "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "result": { "$ref": "#/$defs/LocatorHoverResult" @@ -738,7 +738,7 @@ "type": "object", "properties": { "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "result": { "$ref": "#/$defs/LocatorCountResult" @@ -751,7 +751,7 @@ "type": "object", "properties": { "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "result": { "$ref": "#/$defs/LocatorIsCheckedResult" @@ -764,7 +764,7 @@ "type": "object", "properties": { "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "result": { "$ref": "#/$defs/LocatorInputValueResult" @@ -777,7 +777,7 @@ "type": "object", "properties": { "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "result": { "$ref": "#/$defs/LocatorIsVisibleResult" @@ -790,7 +790,7 @@ "type": "object", "properties": { "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "result": { "$ref": "#/$defs/LocatorInnerTextResult" @@ -803,7 +803,7 @@ "type": "object", "properties": { "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "result": { "$ref": "#/$defs/LocatorInnerHtmlResult" @@ -816,7 +816,7 @@ "type": "object", "properties": { "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "result": { "$ref": "#/$defs/LocatorTextContentResult" @@ -842,7 +842,7 @@ "type": "object", "properties": { "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "result": { "$ref": "#/$defs/LocatorCentroidResult" @@ -4237,6 +4237,11 @@ "LocatorClickOptions": { "type": "object", "properties": { + "timeout": { + "description": "Milliseconds for the whole locator call. Zero disables the timeout.", + "type": "number", + "minimum": 0 + }, "button": { "$ref": "#/$defs/MouseButton" }, @@ -4274,6 +4279,9 @@ "minimum": 0, "maximum": 9007199254740991 }, + "options": { + "$ref": "#/$defs/LocatorOptions" + }, "value": { "type": "string" } @@ -4281,6 +4289,17 @@ "required": ["page_id", "selector", "value"], "additionalProperties": false }, + "LocatorOptions": { + "type": "object", + "properties": { + "timeout": { + "description": "Milliseconds for the whole locator call. Zero disables the timeout.", + "type": "number", + "minimum": 0 + } + }, + "additionalProperties": false + }, "LocatorFillResult": { "type": "object", "properties": { @@ -4292,6 +4311,28 @@ "required": ["filled"], "additionalProperties": false }, + "LocatorParams": { + "type": "object", + "properties": { + "page_id": { + "type": "string" + }, + "selector": { + "type": "string", + "minLength": 1 + }, + "nth": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + }, + "options": { + "$ref": "#/$defs/LocatorOptions" + } + }, + "required": ["page_id", "selector"], + "additionalProperties": false + }, "LocatorHoverResult": { "type": "object", "properties": { @@ -4341,6 +4382,9 @@ "minimum": 0, "maximum": 9007199254740991 }, + "options": { + "$ref": "#/$defs/LocatorOptions" + }, "percent": { "anyOf": [ { @@ -4404,6 +4448,11 @@ "LocatorHighlightOptions": { "type": "object", "properties": { + "timeout": { + "description": "Milliseconds for the whole locator call. Zero disables the timeout.", + "type": "number", + "minimum": 0 + }, "duration_ms": { "type": "integer", "minimum": 0, @@ -4473,6 +4522,11 @@ "LocatorSendClickEventOptions": { "type": "object", "properties": { + "timeout": { + "description": "Milliseconds for the whole locator call. Zero disables the timeout.", + "type": "number", + "minimum": 0 + }, "bubbles": { "type": "boolean" }, @@ -4514,11 +4568,11 @@ "minimum": 0, "maximum": 9007199254740991 }, - "text": { - "type": "string" - }, "options": { "$ref": "#/$defs/LocatorTypeOptions" + }, + "text": { + "type": "string" } }, "required": ["page_id", "selector", "text"], @@ -4527,6 +4581,11 @@ "LocatorTypeOptions": { "type": "object", "properties": { + "timeout": { + "description": "Milliseconds for the whole locator call. Zero disables the timeout.", + "type": "number", + "minimum": 0 + }, "delay": { "type": "number", "minimum": 0 @@ -4560,6 +4619,9 @@ "minimum": 0, "maximum": 9007199254740991 }, + "options": { + "$ref": "#/$defs/LocatorOptions" + }, "values": { "anyOf": [ { @@ -4598,6 +4660,9 @@ "minimum": 0, "maximum": 9007199254740991 }, + "options": { + "$ref": "#/$defs/LocatorOptions" + }, "files": { "type": "array", "items": { @@ -6785,7 +6850,7 @@ "const": "locator.hover" }, "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "traceparent": { "type": "string" @@ -6812,7 +6877,7 @@ "const": "locator.count" }, "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "traceparent": { "type": "string" @@ -6839,7 +6904,7 @@ "const": "locator.is_checked" }, "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "traceparent": { "type": "string" @@ -6866,7 +6931,7 @@ "const": "locator.input_value" }, "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "traceparent": { "type": "string" @@ -6893,7 +6958,7 @@ "const": "locator.is_visible" }, "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "traceparent": { "type": "string" @@ -6920,7 +6985,7 @@ "const": "locator.inner_text" }, "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "traceparent": { "type": "string" @@ -6947,7 +7012,7 @@ "const": "locator.inner_html" }, "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "traceparent": { "type": "string" @@ -6974,7 +7039,7 @@ "const": "locator.text_content" }, "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "traceparent": { "type": "string" @@ -7028,7 +7093,7 @@ "const": "locator.centroid" }, "params": { - "$ref": "#/$defs/LocatorDescriptor" + "$ref": "#/$defs/LocatorParams" }, "traceparent": { "type": "string" diff --git a/packages/protocol/tests/protocol/locator-timeouts.test.ts b/packages/protocol/tests/protocol/locator-timeouts.test.ts new file mode 100644 index 000000000..838953570 --- /dev/null +++ b/packages/protocol/tests/protocol/locator-timeouts.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from "vitest"; +import { StagehandMethods } from "../../schema-registry.js"; +import { LocatorDescriptorSchema } from "../../schemas.js"; + +const descriptor = { pageId: "page-1", selector: "button", nth: 0 }; +const requiredFields: Record> = { + "locator.fill": { value: "hello" }, + "locator.scroll_to": { percent: "bottom" }, + "locator.type": { text: "hello" }, + "locator.select_option": { values: ["a", "b"] }, + "locator.set_input_files": { files: [{ name: "hello.txt", data: "aGVsbG8=" }] }, +}; +const locatorMethods = Object.values(StagehandMethods).filter((method) => + method.name.startsWith("locator."), +); + +describe.each(locatorMethods)("$name timeout options", ({ name, params }) => { + const input = { ...descriptor, ...requiredFields[name] }; + + it("preserves omitted timeout settings without inserting a default", () => { + expect(params.parse(input)).toStrictEqual(input); + expect(params.parse({ ...input, options: {} })).toStrictEqual({ ...input, options: {} }); + }); + + it.each([0, 0.5, 5_000, 20_000])("preserves timeout %s", (timeout) => { + const request = { ...input, options: { timeout } }; + expect(params.parse(request)).toStrictEqual(request); + }); + + it.each([-1, NaN, Infinity, -Infinity, "5000", null])("rejects invalid timeout %s", (timeout) => { + expect(params.safeParse({ ...input, options: { timeout } }).success).toBe(false); + }); +}); + +describe("locator timeout compatibility", () => { + it.each([ + { method: StagehandMethods.locatorClick, options: { button: "right", clickCount: 2 } }, + { method: StagehandMethods.locatorType, options: { delay: 25 } }, + { + method: StagehandMethods.locatorHighlight, + options: { + durationMs: 0, + borderColor: { r: 255, g: 0, b: 0, a: 0.5 }, + contentColor: { r: 0, g: 255, b: 0 }, + }, + }, + { + method: StagehandMethods.locatorSendClickEvent, + options: { bubbles: true, cancelable: false, composed: true, detail: 2 }, + }, + ])("preserves existing $method.name options alongside timeout", ({ method, options }) => { + const request = { + ...descriptor, + ...requiredFields[method.name], + options: { ...options, timeout: 0 }, + }; + expect(method.params.parse(request)).toStrictEqual(request); + }); + + it("keeps timeout settings out of locator identity", () => { + expect(LocatorDescriptorSchema.parse(descriptor)).toStrictEqual(descriptor); + expect( + LocatorDescriptorSchema.safeParse({ ...descriptor, options: { timeout: 0 } }).success, + ).toBe(false); + expect(LocatorDescriptorSchema.safeParse({ ...descriptor, timeout: 0 }).success).toBe(false); + }); +}); diff --git a/packages/protocol/tests/protocol/schema-registry.test-d.ts b/packages/protocol/tests/protocol/schema-registry.test-d.ts index 63e40b60c..b1eade1b1 100644 --- a/packages/protocol/tests/protocol/schema-registry.test-d.ts +++ b/packages/protocol/tests/protocol/schema-registry.test-d.ts @@ -103,6 +103,7 @@ expectTypeOf>().toEq selector: string; nth?: number; values: string | string[]; + options?: { timeout?: number }; }>(); expectTypeOf>().toEqualTypeOf< string[] @@ -112,6 +113,7 @@ expectTypeOf>().toE pageId: string; selector: string; nth?: number; + options?: { timeout?: number }; files: Array<{ name: string; mimeType?: string; diff --git a/packages/protocol/types.ts b/packages/protocol/types.ts index dc7778fb4..f6df266c2 100644 --- a/packages/protocol/types.ts +++ b/packages/protocol/types.ts @@ -81,6 +81,8 @@ import type { LocatorCentroidResultSchema, LocatorCountResultSchema, LocatorDescriptorSchema, + LocatorOptionsSchema, + LocatorParamsSchema, LocatorFillParamsSchema, LocatorFillResultSchema, LocatorHighlightParamsSchema, @@ -398,6 +400,8 @@ export type PageWebMCPInvocationResultParams = z.infer< export type PageWebMCPCancelInvocationParams = z.infer< typeof PageWebMCPCancelInvocationParamsSchema >; +export type LocatorOptions = z.infer; +export type LocatorParams = z.infer; export type LocatorClickParams = z.infer; export type LocatorFillParams = z.infer; export type LocatorScrollToParams = z.infer; diff --git a/packages/sdk-go/internal/extensionassets/stagehand-extension.zip b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip index daba6aba4..cc8924908 100644 Binary files a/packages/sdk-go/internal/extensionassets/stagehand-extension.zip and b/packages/sdk-go/internal/extensionassets/stagehand-extension.zip differ diff --git a/packages/sdk-go/models.gen.go b/packages/sdk-go/models.gen.go index 29512fa11..c88777945 100644 --- a/packages/sdk-go/models.gen.go +++ b/packages/sdk-go/models.gen.go @@ -863,6 +863,9 @@ type LocatorClickOptions struct { // ClickCount corresponds to the JSON schema field "click_count". ClickCount *int `json:"click_count,omitempty,omitzero"` + + // Milliseconds for the whole locator call. Zero disables the timeout. + Timeout *float64 `json:"timeout,omitempty,omitzero"` } type LocatorClickParams struct { @@ -901,6 +904,9 @@ type LocatorFillParams struct { // Nth corresponds to the JSON schema field "nth". Nth *int `json:"nth,omitempty,omitzero"` + // Options corresponds to the JSON schema field "options". + Options *LocatorOptions `json:"options,omitempty,omitzero"` + // PageID corresponds to the JSON schema field "page_id". PageID string `json:"page_id"` @@ -925,6 +931,9 @@ type LocatorHighlightOptions struct { // DurationMs corresponds to the JSON schema field "duration_ms". DurationMs *int `json:"duration_ms,omitempty,omitzero"` + + // Milliseconds for the whole locator call. Zero disables the timeout. + Timeout *float64 `json:"timeout,omitempty,omitzero"` } type LocatorHighlightParams struct { @@ -961,10 +970,32 @@ type LocatorIsCheckedResult bool type LocatorIsVisibleResult bool +type LocatorOptions struct { + // Milliseconds for the whole locator call. Zero disables the timeout. + Timeout *float64 `json:"timeout,omitempty,omitzero"` +} + +type LocatorParams struct { + // Nth corresponds to the JSON schema field "nth". + Nth *int `json:"nth,omitempty,omitzero"` + + // Options corresponds to the JSON schema field "options". + Options *LocatorOptions `json:"options,omitempty,omitzero"` + + // PageID corresponds to the JSON schema field "page_id". + PageID string `json:"page_id"` + + // Selector corresponds to the JSON schema field "selector". + Selector string `json:"selector"` +} + type LocatorScrollToParams struct { // Nth corresponds to the JSON schema field "nth". Nth *int `json:"nth,omitempty,omitzero"` + // Options corresponds to the JSON schema field "options". + Options *LocatorOptions `json:"options,omitempty,omitzero"` + // PageID corresponds to the JSON schema field "page_id". PageID string `json:"page_id"` @@ -984,6 +1015,9 @@ type LocatorSelectOptionParams struct { // Nth corresponds to the JSON schema field "nth". Nth *int `json:"nth,omitempty,omitzero"` + // Options corresponds to the JSON schema field "options". + Options *LocatorOptions `json:"options,omitempty,omitzero"` + // PageID corresponds to the JSON schema field "page_id". PageID string `json:"page_id"` @@ -1008,6 +1042,9 @@ type LocatorSendClickEventOptions struct { // Detail corresponds to the JSON schema field "detail". Detail *float64 `json:"detail,omitempty,omitzero"` + + // Milliseconds for the whole locator call. Zero disables the timeout. + Timeout *float64 `json:"timeout,omitempty,omitzero"` } type LocatorSendClickEventParams struct { @@ -1036,6 +1073,9 @@ type LocatorSetInputFilesParams struct { // Nth corresponds to the JSON schema field "nth". Nth *int `json:"nth,omitempty,omitzero"` + // Options corresponds to the JSON schema field "options". + Options *LocatorOptions `json:"options,omitempty,omitzero"` + // PageID corresponds to the JSON schema field "page_id". PageID string `json:"page_id"` @@ -1053,6 +1093,9 @@ type LocatorTextContentResult string type LocatorTypeOptions struct { // Delay corresponds to the JSON schema field "delay". Delay *float64 `json:"delay,omitempty,omitzero"` + + // Milliseconds for the whole locator call. Zero disables the timeout. + Timeout *float64 `json:"timeout,omitempty,omitzero"` } type LocatorTypeParams struct { @@ -2505,6 +2548,12 @@ type generatedModelCatalog struct { // "LocatorIsVisibleResult". LocatorIsVisibleResult *LocatorIsVisibleResult `json:"LocatorIsVisibleResult,omitempty,omitzero"` + // LocatorOptions corresponds to the JSON schema field "LocatorOptions". + LocatorOptions *LocatorOptions `json:"LocatorOptions,omitempty,omitzero"` + + // LocatorParams corresponds to the JSON schema field "LocatorParams". + LocatorParams *LocatorParams `json:"LocatorParams,omitempty,omitzero"` + // LocatorScrollToParams corresponds to the JSON schema field // "LocatorScrollToParams". LocatorScrollToParams *LocatorScrollToParams `json:"LocatorScrollToParams,omitempty,omitzero"` diff --git a/packages/sdk-python/src/stagehand/_generated/input_types.py b/packages/sdk-python/src/stagehand/_generated/input_types.py index c15b4918c..afec7d776 100644 --- a/packages/sdk-python/src/stagehand/_generated/input_types.py +++ b/packages/sdk-python/src/stagehand/_generated/input_types.py @@ -496,13 +496,6 @@ class LocatorDescriptor(TypedDict): nth: NotRequired[int] -class LocatorFillParams(TypedDict): - page_id: str - selector: str - nth: NotRequired[int] - value: str - - class LocatorFillResult(TypedDict): filled: Literal[True] @@ -530,10 +523,30 @@ class LocatorHoverResult(TypedDict): LocatorIsVisibleResult: TypeAlias = bool +class LocatorOptions(TypedDict): + timeout: NotRequired[float] + + +class LocatorFillParams(TypedDict): + page_id: str + selector: str + nth: NotRequired[int] + options: NotRequired[LocatorOptions] + value: str + + +class LocatorParams(TypedDict): + page_id: str + selector: str + nth: NotRequired[int] + options: NotRequired[LocatorOptions] + + class LocatorScrollToParams(TypedDict): page_id: str selector: str nth: NotRequired[int] + options: NotRequired[LocatorOptions] percent: float | str @@ -545,6 +558,7 @@ class LocatorSelectOptionParams(TypedDict): page_id: str selector: str nth: NotRequired[int] + options: NotRequired[LocatorOptions] values: str | list[str] @@ -552,6 +566,7 @@ class LocatorSelectOptionParams(TypedDict): class LocatorSendClickEventOptions(TypedDict): + timeout: NotRequired[float] bubbles: NotRequired[bool] cancelable: NotRequired[bool] composed: NotRequired[bool] @@ -573,6 +588,7 @@ class LocatorSetInputFilesParams(TypedDict): page_id: str selector: str nth: NotRequired[int] + options: NotRequired[LocatorOptions] files: list[InputFilePayload] @@ -584,6 +600,7 @@ class LocatorSetInputFilesResult(TypedDict): class LocatorTypeOptions(TypedDict): + timeout: NotRequired[float] delay: NotRequired[float] @@ -591,8 +608,8 @@ class LocatorTypeParams(TypedDict): page_id: str selector: str nth: NotRequired[int] - text: str options: NotRequired[LocatorTypeOptions] + text: str class LocatorTypeResult(TypedDict): @@ -603,6 +620,7 @@ class LocatorTypeResult(TypedDict): class LocatorClickOptions(TypedDict): + timeout: NotRequired[float] button: NotRequired[MouseButton] click_count: NotRequired[int] @@ -985,6 +1003,7 @@ class RgbaColor(TypedDict): class LocatorHighlightOptions(TypedDict): + timeout: NotRequired[float] duration_ms: NotRequired[int] border_color: NotRequired[RgbaColor] content_color: NotRequired[RgbaColor] diff --git a/packages/sdk-python/src/stagehand/_generated/models.py b/packages/sdk-python/src/stagehand/_generated/models.py index 53d954258..c473b2eff 100644 --- a/packages/sdk-python/src/stagehand/_generated/models.py +++ b/packages/sdk-python/src/stagehand/_generated/models.py @@ -1061,6 +1061,8 @@ class LocatorClickOptions(WireModel): extra="forbid", validate_by_name=True, ) + timeout: Annotated[Optional[StrictFloat], Field(ge=0.0)] = None + """Milliseconds for the whole locator call. Zero disables the timeout.""" button: Optional[MouseButton] = None click_count: Annotated[Optional[StrictInt], Field(gt=0, le=9007199254740991)] = None @@ -1106,6 +1108,7 @@ class LocatorFillParams(WireModel): page_id: StrictStr selector: Annotated[StrictStr, Field(min_length=1)] nth: Annotated[Optional[StrictInt], Field(ge=0, le=9007199254740991)] = None + options: Optional[LocatorOptions] = None value: StrictStr @@ -1122,6 +1125,8 @@ class LocatorHighlightOptions(WireModel): extra="forbid", validate_by_name=True, ) + timeout: Annotated[Optional[StrictFloat], Field(ge=0.0)] = None + """Milliseconds for the whole locator call. Zero disables the timeout.""" duration_ms: Annotated[Optional[StrictInt], Field(ge=0, le=9007199254740991)] = None border_color: Optional[RgbaColor] = None content_color: Optional[RgbaColor] = None @@ -1174,6 +1179,26 @@ class LocatorIsVisibleResult(RootModel[StrictBool]): root: StrictBool +class LocatorOptions(WireModel): + model_config = ConfigDict( + extra="forbid", + validate_by_name=True, + ) + timeout: Annotated[Optional[StrictFloat], Field(ge=0.0)] = None + """Milliseconds for the whole locator call. Zero disables the timeout.""" + + +class LocatorParams(WireModel): + model_config = ConfigDict( + extra="forbid", + validate_by_name=True, + ) + page_id: StrictStr + selector: Annotated[StrictStr, Field(min_length=1)] + nth: Annotated[Optional[StrictInt], Field(ge=0, le=9007199254740991)] = None + options: Optional[LocatorOptions] = None + + class LocatorScrollToParams(WireModel): model_config = ConfigDict( extra="forbid", @@ -1182,6 +1207,7 @@ class LocatorScrollToParams(WireModel): page_id: StrictStr selector: Annotated[StrictStr, Field(min_length=1)] nth: Annotated[Optional[StrictInt], Field(ge=0, le=9007199254740991)] = None + options: Optional[LocatorOptions] = None percent: Union[StrictFloat, StrictStr] @@ -1201,6 +1227,7 @@ class LocatorSelectOptionParams(WireModel): page_id: StrictStr selector: Annotated[StrictStr, Field(min_length=1)] nth: Annotated[Optional[StrictInt], Field(ge=0, le=9007199254740991)] = None + options: Optional[LocatorOptions] = None values: Union[StrictStr, list[StrictStr]] @@ -1213,6 +1240,8 @@ class LocatorSendClickEventOptions(WireModel): extra="forbid", validate_by_name=True, ) + timeout: Annotated[Optional[StrictFloat], Field(ge=0.0)] = None + """Milliseconds for the whole locator call. Zero disables the timeout.""" bubbles: Optional[StrictBool] = None cancelable: Optional[StrictBool] = None composed: Optional[StrictBool] = None @@ -1246,6 +1275,7 @@ class LocatorSetInputFilesParams(WireModel): page_id: StrictStr selector: Annotated[StrictStr, Field(min_length=1)] nth: Annotated[Optional[StrictInt], Field(ge=0, le=9007199254740991)] = None + options: Optional[LocatorOptions] = None files: list[InputFilePayload] @@ -1266,6 +1296,8 @@ class LocatorTypeOptions(WireModel): extra="forbid", validate_by_name=True, ) + timeout: Annotated[Optional[StrictFloat], Field(ge=0.0)] = None + """Milliseconds for the whole locator call. Zero disables the timeout.""" delay: Annotated[Optional[StrictFloat], Field(ge=0.0)] = None @@ -1277,8 +1309,8 @@ class LocatorTypeParams(WireModel): page_id: StrictStr selector: Annotated[StrictStr, Field(min_length=1)] nth: Annotated[Optional[StrictInt], Field(ge=0, le=9007199254740991)] = None - text: StrictStr options: Optional[LocatorTypeOptions] = None + text: StrictStr class LocatorTypeResult(WireModel): diff --git a/packages/sdk-ts/src/index.ts b/packages/sdk-ts/src/index.ts index 93729ed49..d625b5822 100644 --- a/packages/sdk-ts/src/index.ts +++ b/packages/sdk-ts/src/index.ts @@ -12,6 +12,7 @@ export { } from "./browserClipboard.js"; export { Locator, + type LocatorOptions, type LocatorClickOptions, type LocatorHighlightOptions, type LocatorSendClickEventOptions, diff --git a/packages/sdk-ts/src/locator.ts b/packages/sdk-ts/src/locator.ts index b91973332..38192fc2c 100644 --- a/packages/sdk-ts/src/locator.ts +++ b/packages/sdk-ts/src/locator.ts @@ -4,6 +4,7 @@ import type { LocatorClickParams, LocatorCentroidResult, LocatorDescriptor, + LocatorOptions, LocatorHighlightParams, LocatorScrollToParams, LocatorSelectOptionParams, @@ -13,6 +14,8 @@ import type { import type { StagehandCommandClient } from "./commandClient.js"; import { normalizeFileInput, type FileInput } from "./fileUpload.js"; +export type { LocatorOptions } from "@browserbasehq/stagehand-protocol/types"; + export type LocatorClickOptions = NonNullable; export type LocatorHighlightOptions = NonNullable; export type LocatorSendClickEventOptions = NonNullable; @@ -31,54 +34,86 @@ export class Locator { }); } - async hover(): Promise { - await this.rpcClient.send(StagehandMethods.locatorHover, this.descriptor); + async hover(options?: LocatorOptions): Promise { + await this.rpcClient.send(StagehandMethods.locatorHover, { + ...this.descriptor, + ...(options ? { options } : {}), + }); } - async fill(value: string): Promise { + async fill(value: string, options?: LocatorOptions): Promise { await this.rpcClient.send(StagehandMethods.locatorFill, { ...this.descriptor, value, + ...(options ? { options } : {}), }); } - async count(): Promise { - return await this.rpcClient.send(StagehandMethods.locatorCount, this.descriptor); + async count(options?: LocatorOptions): Promise { + return await this.rpcClient.send(StagehandMethods.locatorCount, { + ...this.descriptor, + ...(options ? { options } : {}), + }); } - async isChecked(): Promise { - return await this.rpcClient.send(StagehandMethods.locatorIsChecked, this.descriptor); + async isChecked(options?: LocatorOptions): Promise { + return await this.rpcClient.send(StagehandMethods.locatorIsChecked, { + ...this.descriptor, + ...(options ? { options } : {}), + }); } - async inputValue(): Promise { - return await this.rpcClient.send(StagehandMethods.locatorInputValue, this.descriptor); + async inputValue(options?: LocatorOptions): Promise { + return await this.rpcClient.send(StagehandMethods.locatorInputValue, { + ...this.descriptor, + ...(options ? { options } : {}), + }); } - async isVisible(): Promise { - return await this.rpcClient.send(StagehandMethods.locatorIsVisible, this.descriptor); + async isVisible(options?: LocatorOptions): Promise { + return await this.rpcClient.send(StagehandMethods.locatorIsVisible, { + ...this.descriptor, + ...(options ? { options } : {}), + }); } - async innerText(): Promise { - return await this.rpcClient.send(StagehandMethods.locatorInnerText, this.descriptor); + async innerText(options?: LocatorOptions): Promise { + return await this.rpcClient.send(StagehandMethods.locatorInnerText, { + ...this.descriptor, + ...(options ? { options } : {}), + }); } - async innerHtml(): Promise { - return await this.rpcClient.send(StagehandMethods.locatorInnerHtml, this.descriptor); + async innerHtml(options?: LocatorOptions): Promise { + return await this.rpcClient.send(StagehandMethods.locatorInnerHtml, { + ...this.descriptor, + ...(options ? { options } : {}), + }); } - async textContent(): Promise { - return await this.rpcClient.send(StagehandMethods.locatorTextContent, this.descriptor); + async textContent(options?: LocatorOptions): Promise { + return await this.rpcClient.send(StagehandMethods.locatorTextContent, { + ...this.descriptor, + ...(options ? { options } : {}), + }); } - async scrollTo(percent: LocatorScrollToParams["percent"]): Promise { + async scrollTo( + percent: LocatorScrollToParams["percent"], + options?: LocatorOptions, + ): Promise { await this.rpcClient.send(StagehandMethods.locatorScrollTo, { ...this.descriptor, percent, + ...(options ? { options } : {}), }); } - async centroid(): Promise { - return this.rpcClient.send(StagehandMethods.locatorCentroid, this.descriptor); + async centroid(options?: LocatorOptions): Promise { + return this.rpcClient.send(StagehandMethods.locatorCentroid, { + ...this.descriptor, + ...(options ? { options } : {}), + }); } async highlight(options?: LocatorHighlightOptions): Promise { @@ -103,17 +138,22 @@ export class Locator { }); } - async selectOption(values: LocatorSelectOptionParams["values"]): Promise { + async selectOption( + values: LocatorSelectOptionParams["values"], + options?: LocatorOptions, + ): Promise { return await this.rpcClient.send(StagehandMethods.locatorSelectOption, { ...this.descriptor, values, + ...(options ? { options } : {}), }); } - async setInputFiles(files: FileInput): Promise { + async setInputFiles(files: FileInput, options?: LocatorOptions): Promise { await this.rpcClient.send(StagehandMethods.locatorSetInputFiles, { ...this.descriptor, files: await normalizeFileInput(files), + ...(options ? { options } : {}), }); } diff --git a/packages/sdk-ts/src/rpcClient.ts b/packages/sdk-ts/src/rpcClient.ts index dee85b525..fb487ff73 100644 --- a/packages/sdk-ts/src/rpcClient.ts +++ b/packages/sdk-ts/src/rpcClient.ts @@ -1,3 +1,4 @@ +import { DEFAULT_LOCATOR_TIMEOUT_MS } from "@browserbasehq/stagehand-protocol/schemas"; import { ROOT_CONTEXT, context, @@ -197,16 +198,23 @@ export class RPCClient { options.signal && timeoutController ? AbortSignal.any([options.signal, timeoutController.signal]) : (options.signal ?? timeoutController?.signal); - const timeoutId = - timeoutController && responseTimeoutMs !== undefined - ? setTimeout(() => { - timeoutController.abort( - new Error(`RPC response timed out: ${method.name}`, { - cause: { method: method.name, timeoutMs: responseTimeoutMs }, - }), - ); - }, responseTimeoutMs) - : undefined; + let timeoutId: ReturnType | undefined; + if (timeoutController && responseTimeoutMs !== undefined) { + const deadline = performance.now() + responseTimeoutMs; + const tick = () => { + const remaining = deadline - performance.now(); + if (remaining > 0) { + timeoutId = setTimeout(tick, Math.min(2_147_483_647, Math.ceil(remaining))); + } else { + timeoutController.abort( + new Error(`RPC response timed out: ${method.name}`, { + cause: { method: method.name, timeoutMs: responseTimeoutMs }, + }), + ); + } + }; + tick(); + } try { const response = this.waitForResponse(request.id, method, signal); @@ -424,7 +432,11 @@ export class RPCClient { pending.removeAbortListener?.(); if ("error" in response) { - pending.reject(new Error(response.error.message, { cause: response.error })); + const error = new Error(response.error.message, { cause: response.error }); + if (recordProperty(response.error.data, "name") === "TimeoutError") { + error.name = "TimeoutError"; + } + pending.reject(error); return; } @@ -510,6 +522,12 @@ function asError(error: unknown): Error { } export function rpcResponseTimeoutMs(method: string, params: unknown): number | undefined { + if (method.startsWith("locator.")) { + const timeout = + numericProperty(recordProperty(params, "options"), "timeout") ?? DEFAULT_LOCATOR_TIMEOUT_MS; + return timeout === 0 ? undefined : timeout + RPC_RESPONSE_GRACE_MS; + } + let operationTimeoutMs: number | undefined; switch (method) { case StagehandMethods.stagehandAct.name: @@ -545,7 +563,7 @@ export function rpcResponseTimeoutMs(method: string, params: unknown): number | // These operations had no v3 deadline. Keep the server as the owner of their // lifetime instead of turning the transport grace period into a 10s ceiling. - if (UNBOUNDED_BY_DEFAULT_METHODS.has(method) || method.startsWith("locator.")) { + if (UNBOUNDED_BY_DEFAULT_METHODS.has(method)) { return undefined; } diff --git a/packages/sdk-ts/tests/integration/iframeLocatorReadiness.test.ts b/packages/sdk-ts/tests/integration/iframeLocatorReadiness.test.ts index 86cd0e3e3..8571bcd60 100644 --- a/packages/sdk-ts/tests/integration/iframeLocatorReadiness.test.ts +++ b/packages/sdk-ts/tests/integration/iframeLocatorReadiness.test.ts @@ -35,26 +35,32 @@ async function createDelayedIframeFixture(options: { childSrc: (child: FixtureServer) => string; childDelayMs: number; childResponseGate?: Promise; + nested?: boolean; /** URL passed to page.goto (may use a mapped hostname). */ parentGotoUrl: (parent: FixtureServer) => string; }): Promise { let clickCount = 0; let childServed = 0; + const leafHtml = ` +
+ + +
+`; const child = await startFixtureServer({ "/child": async () => { await options.childResponseGate; await new Promise((resolve) => setTimeout(resolve, options.childDelayMs)); childServed += 1; return { - body: ` -
- -
-`, + body: options.nested + ? '' + : leafHtml, }; }, + "/leaf": leafHtml, "/clicked": () => { clickCount += 1; return { headers: { "content-type": "text/plain" }, body: "ok" }; @@ -85,8 +91,10 @@ async function waitForIframeElement(page: Awaited>) describe("iframe locator readiness", () => { const stagehands: Stagehand[] = []; const fixtures: IframeFixture[] = []; + const gates: Array> = []; afterEach(async () => { + gates.splice(0).forEach((gate) => gate.release()); await Promise.all(stagehands.splice(0).map((stagehand) => closeStagehand(stagehand))); await Promise.all( fixtures.splice(0).map(async (fixture) => { @@ -95,6 +103,106 @@ describe("iframe locator readiness", () => { ); }); + async function gatedPage(crossProcess: boolean, nested = false) { + const gate = createChildResponseGate(); + gates.push(gate); + const fixture = await createDelayedIframeFixture({ + childDelayMs: 0, + childResponseGate: gate.ready, + nested, + childSrc: (child) => + crossProcess + ? `http://child.test:${new URL(child.url).port}/child` + : new URL("/child", child.url).href, + parentGotoUrl: (parent) => + crossProcess ? `http://parent.test:${new URL(parent.url).port}/` : parent.url, + }); + fixtures.push(fixture); + const stagehand = await createStagehand( + crossProcess + ? { + browser: { + args: [ + "--host-resolver-rules=MAP parent.test 127.0.0.1,MAP child.test 127.0.0.1", + "--site-per-process", + ], + }, + } + : undefined, + ); + stagehands.push(stagehand); + const page = await firstPage(stagehand); + await page.goto(fixture.parentGotoUrl, { waitUntil: "domcontentloaded" }); + await waitForIframeElement(page); + expect(fixture.childServed()).toBe(0); + return { page, fixture, gate }; + } + + describe.each([ + { name: "same-process", crossProcess: false }, + { name: "OOPIF", crossProcess: true }, + ])("$name timeout budgets", ({ crossProcess }) => { + it("does not click after readiness expires, even when the child later loads", async () => { + const { page, fixture, gate } = await gatedPage(crossProcess); + const button = page.locator(XPATH_INNER); + await expect(button.click({ timeout: 250 })).rejects.toMatchObject({ + name: "TimeoutError", + message: expect.stringContaining("locator.click"), + }); + expect(fixture.childServed()).toBe(0); + gate.release(); + await expect.poll(() => button.count({ timeout: 5_000 }), { timeout: 6_000 }).toBe(1); + await new Promise((resolve) => setTimeout(resolve, 350)); + expect(fixture.clickCount()).toBe(0); + await button.click({ timeout: 5_000 }); + await expect.poll(() => fixture.clickCount()).toBe(1); + }); + + it.each([0, 4_000])("waits beyond the old readiness cap with timeout %s", async (timeout) => { + const { page, fixture, gate } = await gatedPage(crossProcess); + let settled = false; + const click = page.locator(XPATH_INNER).click({ timeout }); + const checked = expect(click).resolves.toBeUndefined(); + void click.then( + () => { + settled = true; + }, + () => { + settled = true; + }, + ); + // The previous frame readiness cap was 1,200 ms. + await new Promise((resolve) => setTimeout(resolve, 1_600)); + const settledBeforeRelease = settled; + gate.release(); + await checked; + expect(settledBeforeRelease).toBe(false); + await expect.poll(() => fixture.clickCount()).toBe(1); + }); + + it("shares one budget across nested frame readiness and typing delays", async () => { + const { page, gate } = await gatedPage(crossProcess, true); + const input = page.locator("iframe >> iframe >> #t"); + const started = performance.now(); + const typed = input.type("abcdefghij", { timeout: 1_800, delay: 200 }); + const rejected = expect(typed).rejects.toMatchObject({ + name: "TimeoutError", + message: expect.stringContaining("locator.type"), + }); + await new Promise((resolve) => setTimeout(resolve, 900)); + gate.release(); + await rejected; + // A fresh action budget after readiness would allow about 2,700 ms. + expect(performance.now() - started).toBeLessThan(2_400); + const value = await input.inputValue({ timeout: 5_000 }); + expect(value.length).toBeGreaterThan(0); + expect(value.length).toBeLessThan(10); + expect("abcdefghij".startsWith(value)).toBe(true); + await new Promise((resolve) => setTimeout(resolve, 400)); + expect(await input.inputValue()).toBe(value); + }); + }); + it("same-process trailing iframe XPath clicks without waiting for the child document", async () => { const fixture = await createDelayedIframeFixture({ childDelayMs: HOST_CHILD_DELAY_MS, diff --git a/packages/sdk-ts/tests/objectWrapper.test.ts b/packages/sdk-ts/tests/objectWrapper.test.ts index dd875a0e2..164ca65a7 100644 --- a/packages/sdk-ts/tests/objectWrapper.test.ts +++ b/packages/sdk-ts/tests/objectWrapper.test.ts @@ -1695,6 +1695,48 @@ describe("Stagehand TS object wrapper", () => { expect(page).not.toHaveProperty("extract"); }); + it.each([undefined, {}, { timeout: 0 }, { timeout: 75 }])( + "forwards timeout options %j for every terminal locator method", + async (options) => { + const client = new FakeProtocolClient(); + const send = vi.spyOn(client, "send").mockResolvedValue(undefined); + const locator = new Page(client, { pageId: "page-1" }).locator("button").nth(2); + await locator.click(options); + await locator.hover(options); + await locator.fill("hello", options); + await locator.count(options); + await locator.isChecked(options); + await locator.inputValue(options); + await locator.isVisible(options); + await locator.innerText(options); + await locator.innerHtml(options); + await locator.textContent(options); + await locator.scrollTo(50, options); + await locator.centroid(options); + await locator.highlight(options); + await locator.sendClickEvent(options); + await locator.type("hello", options); + await locator.selectOption("a", options); + await locator.setInputFiles([], options); + + const registered = Object.values(StagehandMethods).filter(({ name }) => + name.startsWith("locator."), + ); + expect(send.mock.calls.map(([method]) => method.name).sort()).toEqual( + registered.map(({ name }) => name).sort(), + ); + for (const [method, params] of send.mock.calls) { + expect(method.params.parse(params)).toMatchObject({ + pageId: "page-1", + selector: "button", + nth: 2, + }); + if (options === undefined) expect(params).not.toHaveProperty("options"); + else expect(params).toHaveProperty("options", options); + } + }, + ); + it("creates descriptor-backed locators without sending protocol calls", () => { const client = new FakeProtocolClient(); const page = new Page(client, { pageId: "page-1" }); diff --git a/packages/sdk-ts/tests/packageContract.test.ts b/packages/sdk-ts/tests/packageContract.test.ts index 58f5e1228..b1060d1f7 100644 --- a/packages/sdk-ts/tests/packageContract.test.ts +++ b/packages/sdk-ts/tests/packageContract.test.ts @@ -96,6 +96,7 @@ describe("published TypeScript SDK", () => { LoadState, LocatorCentroidResult, LocatorClickOptions, + LocatorOptions, LocatorHighlightOptions, LocatorSendClickEventOptions, LocatorTypeOptions, @@ -142,7 +143,8 @@ describe("published TypeScript SDK", () => { const pageSnapshot: PageSnapshotOptions = { includeIframes: true }; const pageType: PageTypeOptions = { delay: 0, withMistakes: false }; const pageWait: PageWaitForSelectorOptions = { state: "visible", timeout: 1_000 }; - const locatorClick: LocatorClickOptions = pageClick; + const locatorOptions: LocatorOptions = { timeout: 0 }; + const locatorClick: LocatorClickOptions = { ...pageClick, timeout: 50 }; const locatorHighlight: LocatorHighlightOptions = { borderColor: color }; const locatorSendClick: LocatorSendClickEventOptions = { bubbles: true }; const locatorType: LocatorTypeOptions = { delay: 0 }; @@ -165,6 +167,7 @@ describe("published TypeScript SDK", () => { pageSnapshot, pageType, pageWait, + locatorOptions, locatorClick, locatorHighlight, locatorSendClick, diff --git a/packages/sdk-ts/tests/rpcClient.test.ts b/packages/sdk-ts/tests/rpcClient.test.ts index 3f17cdddd..d1a3a557a 100644 --- a/packages/sdk-ts/tests/rpcClient.test.ts +++ b/packages/sdk-ts/tests/rpcClient.test.ts @@ -419,6 +419,72 @@ describe("RPCClient", () => { expect(rpcResponseTimeoutMs(method, {})).toBe(timeout); }); + it.each(Object.values(StagehandMethods).filter(({ name }) => name.startsWith("locator.")))( + "uses the effective locator timeout plus delivery grace for $name", + ({ name }) => { + expect(rpcResponseTimeoutMs(name, {})).toBe(30_000); + expect(rpcResponseTimeoutMs(name, { options: {} })).toBe(30_000); + expect(rpcResponseTimeoutMs(name, { options: { timeout: 50 } })).toBe(10_050); + expect(rpcResponseTimeoutMs(name, { options: { timeout: 0 } })).toBeUndefined(); + }, + ); + + it("preserves a locator timeout's name, message, and error envelope", async () => { + const cdp = new ManualCDPTransport(); + const client = new RPCClient(cdp); + const pending = client.send(StagehandMethods.locatorCount, { + pageId: "page-1", + selector: "button", + }); + const error = { + code: JSONRPCErrorCodes.internalError, + message: "locator.count timed out after 20000ms while resolving frame", + data: { name: "TimeoutError" }, + }; + const rejected = expect(pending).rejects.toMatchObject({ + name: "TimeoutError", + message: error.message, + cause: error, + }); + await cdp.receive({ jsonrpc: "2.0", id: 1, error }); + await rejected; + expect(client.pending.size).toBe(0); + client.close(); + }); + + it.each([undefined, 50, 0, 2_147_483_648])( + "waits for a locator response with timeout %s", + async (timeout) => { + vi.useFakeTimers(); + const cdp = new ManualCDPTransport(); + const client = new RPCClient(cdp); + try { + const pending = client.send(StagehandMethods.locatorCount, { + pageId: "page-1", + selector: "button", + ...(timeout === undefined ? {} : { options: { timeout } }), + }); + if (timeout === 0) { + await vi.advanceTimersByTimeAsync(60_000); + expect(client.pending.size).toBe(1); + await cdp.receive({ jsonrpc: "2.0", id: 1, result: 2 }); + await expect(pending).resolves.toBe(2); + } else { + const rejected = expect(pending).rejects.toThrow("RPC response timed out: locator.count"); + await vi.advanceTimersByTimeAsync((timeout ?? 20_000) + 10_000 - 1); + expect(client.pending.size).toBe(1); + await vi.advanceTimersByTimeAsync(1); + await rejected; + } + expect(client.pending.size).toBe(0); + expect(vi.getTimerCount()).toBe(0); + } finally { + client.close(); + vi.useRealTimers(); + } + }, + ); + it("does not impose response deadlines on operations that were unbounded in v3", () => { const methods = [ StagehandMethods.stagehandInit.name, @@ -445,9 +511,6 @@ describe("RPCClient", () => { StagehandMethods.pageScreenshot.name, StagehandMethods.pageSnapshot.name, StagehandMethods.pageWebMCPInvocationResult.name, - ...Object.values(StagehandMethods) - .map(({ name }) => name) - .filter((name) => name.startsWith("locator.")), ]; for (const method of methods) {