From b4516746281c7337cd2dbffd75e4c7b24bf23ba1 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Mon, 31 Aug 2026 21:30:16 +0000 Subject: [PATCH] refactor(cli): restore V4 command parity --- .../cli/src/lib/driver/commands/elements.ts | 72 ++- .../cli/src/lib/driver/commands/keyboard.ts | 21 +- packages/cli/src/lib/driver/commands/mouse.ts | 86 +++- .../cli/src/lib/driver/commands/navigation.ts | 25 +- .../cli/src/lib/driver/commands/page-info.ts | 58 ++- .../cli/src/lib/driver/commands/runtime.ts | 45 +- packages/cli/src/lib/driver/commands/tabs.ts | 118 ++++- .../src/lib/driver/commands/unavailable.ts | 13 +- packages/cli/tests/driver-commands.test.ts | 409 ++++++++++++++++-- 9 files changed, 771 insertions(+), 76 deletions(-) diff --git a/packages/cli/src/lib/driver/commands/elements.ts b/packages/cli/src/lib/driver/commands/elements.ts index ce2d759812..1bbe0f0719 100644 --- a/packages/cli/src/lib/driver/commands/elements.ts +++ b/packages/cli/src/lib/driver/commands/elements.ts @@ -1,10 +1,70 @@ +import { z } from "zod"; + import type { DriverCommandHandlers } from "./types.js"; -import { unavailableV4Command } from "./unavailable.js"; export const elementsHandlers: DriverCommandHandlers = { - click: unavailableV4Command("click"), - fill: unavailableV4Command("fill"), - highlight: unavailableV4Command("highlight"), - select: unavailableV4Command("select"), - upload: unavailableV4Command("upload"), + async click(manager, params) { + const { selector } = z + .object({ selector: z.string().min(1) }) + .parse(params); + const page = await manager.activePage(); + await page.locator(manager.resolveSelector(selector)).click(); + return { clicked: true }; + }, + + async fill(manager, params) { + const { pressEnter, selector, value } = z + .object({ + pressEnter: z.boolean().optional(), + selector: z.string().min(1), + value: z.string(), + }) + .parse(params); + const page = await manager.activePage(); + await page.locator(manager.resolveSelector(selector)).fill(value); + if (pressEnter) { + await page.keyPress("Enter"); + } + return { filled: true, pressedEnter: pressEnter ?? false }; + }, + + async select(manager, params) { + const { selector, values } = z + .object({ + selector: z.string().min(1), + values: z.array(z.string()).min(1), + }) + .parse(params); + const page = await manager.activePage(); + const selected = await page + .locator(manager.resolveSelector(selector)) + .selectOption(values); + return { selected }; + }, + + async upload(manager, params) { + const { files, selector } = z + .object({ + files: z.array(z.string()).min(1), + selector: z.string().min(1), + }) + .parse(params); + const page = await manager.activePage(); + await page.locator(manager.resolveSelector(selector)).setInputFiles(files); + return { files, uploaded: true }; + }, + + async highlight(manager, params) { + const { durationMs, selector } = z + .object({ + durationMs: z.number().int().positive().optional(), + selector: z.string().min(1), + }) + .parse(params); + const page = await manager.activePage(); + await page + .locator(manager.resolveSelector(selector)) + .highlight({ durationMs: durationMs ?? 2000 }); + return { highlighted: true }; + }, }; diff --git a/packages/cli/src/lib/driver/commands/keyboard.ts b/packages/cli/src/lib/driver/commands/keyboard.ts index 4229442fa4..bbf949d7d8 100644 --- a/packages/cli/src/lib/driver/commands/keyboard.ts +++ b/packages/cli/src/lib/driver/commands/keyboard.ts @@ -1,10 +1,27 @@ import { z } from "zod"; import type { DriverCommandHandlers } from "./types.js"; -import { unavailableV4Command } from "./unavailable.js"; export const keyboardHandlers: DriverCommandHandlers = { - type: unavailableV4Command("type"), + async type(manager, params) { + const { delay, mistakes, text } = z + .object({ + delay: z.number().int().nonnegative().optional(), + mistakes: z.boolean().optional(), + text: z.string(), + }) + .parse(params); + const page = await manager.activePage(); + const options = { + ...(delay === undefined ? {} : { delay }), + ...(mistakes === undefined ? {} : { withMistakes: mistakes }), + }; + await page.type( + text, + Object.keys(options).length === 0 ? undefined : options, + ); + return { typed: true }; + }, async key(manager, params) { const { key } = z.object({ key: z.string().min(1) }).parse(params); diff --git a/packages/cli/src/lib/driver/commands/mouse.ts b/packages/cli/src/lib/driver/commands/mouse.ts index 926f7bb8e6..62268b172f 100644 --- a/packages/cli/src/lib/driver/commands/mouse.ts +++ b/packages/cli/src/lib/driver/commands/mouse.ts @@ -1,9 +1,85 @@ +import { z } from "zod"; + import type { DriverCommandHandlers } from "./types.js"; -import { unavailableV4Command } from "./unavailable.js"; + +const ButtonSchema = z.enum(["left", "right", "middle"]).optional(); export const mouseHandlers: DriverCommandHandlers = { - "mouse.click": unavailableV4Command("mouse.click"), - "mouse.drag": unavailableV4Command("mouse.drag"), - "mouse.hover": unavailableV4Command("mouse.hover"), - "mouse.scroll": unavailableV4Command("mouse.scroll"), + async "mouse.click"(manager, params) { + const { button, clickCount, returnXPath, x, y } = z + .object({ + button: ButtonSchema, + clickCount: z.number().int().positive().optional(), + returnXPath: z.boolean().optional(), + x: z.number(), + y: z.number(), + }) + .parse(params); + assertXPathUnavailable(returnXPath); + const page = await manager.activePage(); + await page.click(x, y, { + ...(button === undefined ? {} : { button }), + ...(clickCount === undefined ? {} : { clickCount }), + }); + return { clicked: true }; + }, + + async "mouse.hover"(manager, params) { + const { returnXPath, x, y } = z + .object({ + returnXPath: z.boolean().optional(), + x: z.number(), + y: z.number(), + }) + .parse(params); + assertXPathUnavailable(returnXPath); + const page = await manager.activePage(); + await page.hover(x, y); + return { hovered: true }; + }, + + async "mouse.scroll"(manager, params) { + const { deltaX, deltaY, returnXPath, x, y } = z + .object({ + deltaX: z.number(), + deltaY: z.number(), + returnXPath: z.boolean().optional(), + x: z.number(), + y: z.number(), + }) + .parse(params); + assertXPathUnavailable(returnXPath); + const page = await manager.activePage(); + await page.scroll(x, y, deltaX, deltaY); + return { scrolled: true }; + }, + + async "mouse.drag"(manager, params) { + const { button, delay, fromX, fromY, returnXPath, steps, toX, toY } = z + .object({ + button: ButtonSchema, + delay: z.number().int().nonnegative().optional(), + fromX: z.number(), + fromY: z.number(), + returnXPath: z.boolean().optional(), + steps: z.number().int().positive().optional(), + toX: z.number(), + toY: z.number(), + }) + .parse(params); + assertXPathUnavailable(returnXPath); + const page = await manager.activePage(); + await page.dragAndDrop(fromX, fromY, toX, toY, { + ...(button === undefined ? {} : { button }), + ...(delay === undefined ? {} : { delay }), + ...(steps === undefined ? {} : { steps }), + }); + return { dragged: true }; + }, }; + +function assertXPathUnavailable(returnXPath: boolean | undefined): void { + if (returnXPath) { + throw new Error("Coordinate XPath lookup is not exposed by Stagehand V4"); + } +} diff --git a/packages/cli/src/lib/driver/commands/navigation.ts b/packages/cli/src/lib/driver/commands/navigation.ts index 2d6f108fbf..af52951384 100644 --- a/packages/cli/src/lib/driver/commands/navigation.ts +++ b/packages/cli/src/lib/driver/commands/navigation.ts @@ -1,7 +1,6 @@ import { z } from "zod"; import type { DriverCommandHandlers } from "./types.js"; -import { unavailableV4Command } from "./unavailable.js"; const LoadStateSchema = z .enum(["load", "domcontentloaded", "networkidle"]) @@ -22,9 +21,27 @@ export const navigationHandlers: DriverCommandHandlers = { await page.goto(url, pageNavigationOptions({ timeoutMs, waitUntil })); return manager.openResult(page); }, - back: unavailableV4Command("back"), - forward: unavailableV4Command("forward"), - reload: unavailableV4Command("reload"), + + async reload(manager, params) { + const options = NavigationOptionsSchema.parse(params); + const page = await manager.activePage(); + await page.reload(pageNavigationOptions(options)); + return manager.openResult(page); + }, + + async back(manager, params) { + const options = NavigationOptionsSchema.parse(params); + const page = await manager.activePage(); + await page.goBack(pageNavigationOptions(options)); + return manager.openResult(page); + }, + + async forward(manager, params) { + const options = NavigationOptionsSchema.parse(params); + const page = await manager.activePage(); + await page.goForward(pageNavigationOptions(options)); + return manager.openResult(page); + }, }; function pageNavigationOptions({ diff --git a/packages/cli/src/lib/driver/commands/page-info.ts b/packages/cli/src/lib/driver/commands/page-info.ts index 3631638163..eea0b88936 100644 --- a/packages/cli/src/lib/driver/commands/page-info.ts +++ b/packages/cli/src/lib/driver/commands/page-info.ts @@ -1,11 +1,63 @@ +import { NodeHtmlMarkdown } from "node-html-markdown"; import { z } from "zod"; import type { DriverCommandHandlers } from "./types.js"; -import { unavailableV4Command } from "./unavailable.js"; + +const GetWhatSchema = z.enum([ + "box", + "checked", + "html", + "markdown", + "text", + "title", + "url", + "value", + "visible", +]); export const pageInfoHandlers: DriverCommandHandlers = { - get: unavailableV4Command("get"), - is: unavailableV4Command("is"), + async get(manager, params) { + const { selector, what } = z + .object({ + selector: z.string().optional(), + what: GetWhatSchema, + }) + .parse(params); + const page = await manager.activePage(); + + if (what === "url") return { url: await page.url() }; + if (what === "title") return { title: await page.title() }; + + const target = manager.resolveSelector(selector ?? "body"); + const locator = page.locator(target); + + if (what === "text") return { text: await locator.textContent() }; + if (what === "html") return { html: await locator.innerHtml() }; + if (what === "value") return { value: await locator.inputValue() }; + if (what === "visible") return { visible: await locator.isVisible() }; + if (what === "checked") return { checked: await locator.isChecked() }; + if (what === "markdown") + return { + markdown: NodeHtmlMarkdown.translate(await locator.innerHtml()), + }; + + const { x, y } = await locator.centroid(); + return { x: Math.round(x), y: Math.round(y) }; + }, + + async is(manager, params) { + const { check, selector } = z + .object({ + check: z.enum(["checked", "visible"]), + selector: z.string().min(1), + }) + .parse(params); + const page = await manager.activePage(); + const locator = page.locator(manager.resolveSelector(selector)); + return check === "visible" + ? { visible: await locator.isVisible() } + : { checked: await locator.isChecked() }; + }, async eval(manager, params) { const { expression } = z diff --git a/packages/cli/src/lib/driver/commands/runtime.ts b/packages/cli/src/lib/driver/commands/runtime.ts index 392b2eee2a..9f06c0fdd9 100644 --- a/packages/cli/src/lib/driver/commands/runtime.ts +++ b/packages/cli/src/lib/driver/commands/runtime.ts @@ -1,13 +1,48 @@ +import { promises as fs } from "node:fs"; + import { z } from "zod"; import type { DriverCommandHandlers } from "./types.js"; -import { - unavailableCursorOverlay, - unavailableV4Command, -} from "./unavailable.js"; +import { unavailableCursorOverlay } from "./unavailable.js"; export const runtimeHandlers: DriverCommandHandlers = { - screenshot: unavailableV4Command("screenshot"), + async screenshot(manager, params) { + const options = z + .object({ + animations: z.enum(["allow", "disabled"]).optional(), + caret: z.enum(["hide", "initial"]).optional(), + clip: z + .object({ + height: z.number().positive(), + width: z.number().positive(), + x: z.number(), + y: z.number(), + }) + .optional(), + fullPage: z.boolean().optional(), + path: z.string().optional(), + quality: z.number().int().min(0).max(100).optional(), + type: z.enum(["jpeg", "png"]).optional(), + }) + .parse(params); + const page = await manager.activePage(); + const buffer = await page.screenshot({ + ...(options.animations === undefined + ? {} + : { animations: options.animations }), + ...(options.caret === undefined ? {} : { caret: options.caret }), + ...(options.clip === undefined ? {} : { clip: options.clip }), + ...(options.fullPage === undefined ? {} : { fullPage: options.fullPage }), + ...(options.quality === undefined ? {} : { quality: options.quality }), + timeout: 10_000, + ...(options.type === undefined ? {} : { type: options.type }), + }); + if (options.path) { + await fs.writeFile(options.path, buffer); + return { saved: options.path }; + } + return { base64: Buffer.from(buffer).toString("base64") }; + }, async viewport(manager, params) { const { height, scale, width } = z diff --git a/packages/cli/src/lib/driver/commands/tabs.ts b/packages/cli/src/lib/driver/commands/tabs.ts index 8b4793a446..8692bb112e 100644 --- a/packages/cli/src/lib/driver/commands/tabs.ts +++ b/packages/cli/src/lib/driver/commands/tabs.ts @@ -1,12 +1,122 @@ +import { z } from "zod"; + +import type { DriverPage } from "../session-manager.js"; import type { DriverCommandHandlers } from "./types.js"; -import { unavailableV4Command } from "./unavailable.js"; export const tabHandlers: DriverCommandHandlers = { async "tab.list"(manager) { return { tabs: await manager.pageSummaries() }; }, - "tab.close": unavailableV4Command("tab.close"), - "tab.new": unavailableV4Command("tab.new"), - "tab.switch": unavailableV4Command("tab.switch"), + async "tab.new"(manager, params) { + const { url } = z.object({ url: z.string().optional() }).parse(params); + const context = await manager.browserContext(); + const page = await context.newPage(url); + await context.setActivePage(page); + const pages = await context.pages(); + return { + active: true, + index: pages.findIndex( + (candidate: DriverPage) => candidate.pageId === page.pageId, + ), + targetId: page.pageId, + title: await manager.safeTitle(page), + url: await page.url(), + }; + }, + + async "tab.switch"(manager, params) { + const { tab } = z.object({ tab: z.string().min(1) }).parse(params); + const { index, page } = await resolveTab(manager, tab); + const context = await manager.browserContext(); + await context.setActivePage(page); + return { + index, + switched: true, + targetId: page.pageId, + title: await manager.safeTitle(page), + url: await page.url(), + }; + }, + + async "tab.close"(manager, params) { + const { tab } = z.object({ tab: z.string().optional() }).parse(params); + const context = await manager.browserContext(); + const pages = await context.pages(); + if (pages.length === 1) { + throw new Error("Cannot close the last tab."); + } + + const active = await context.activePage(); + const resolved = tab + ? await resolveTab(manager, tab) + : resolveActiveTab(pages, active ?? null); + const closedTargetId = resolved.page.pageId; + const activeTargetId = active?.pageId; + await resolved.page.close(); + const remainingPages = (await context.pages()).filter( + (page) => page.pageId !== closedTargetId, + ); + let selectedPage = activeTargetId + ? remainingPages.find((page) => page.pageId === activeTargetId) + : undefined; + + if (!selectedPage) { + selectedPage = + remainingPages[Math.min(resolved.index, remainingPages.length - 1)] ?? + remainingPages[0]; + if (selectedPage) { + await context.setActivePage(selectedPage); + } + } + + return { + closed: true, + index: resolved.index, + selectedTargetId: selectedPage?.pageId, + targetId: closedTargetId, + }; + }, }; + +async function resolveTab( + manager: { + browserContext: () => Promise<{ pages: () => Promise }>; + }, + tab: string, +): Promise<{ index: number; page: DriverPage }> { + const context = await manager.browserContext(); + const pages = await context.pages(); + const index = Number.parseInt(tab, 10); + if (/^\d+$/.test(tab)) { + const page = pages[index]; + if (!page) + throw new Error( + `Tab index ${index} out of range (0-${pages.length - 1}).`, + ); + return { index, page }; + } + + const targetIndex = pages.findIndex( + (page: DriverPage) => page.pageId === tab, + ); + if (targetIndex === -1) { + throw new Error( + `Tab targetId ${tab} was not found. Run browse tab list for current tabs.`, + ); + } + return { index: targetIndex, page: pages[targetIndex]! }; +} + +function resolveActiveTab( + pages: DriverPage[], + active: DriverPage | null, +): { index: number; page: DriverPage } { + const activeTargetId = active?.pageId; + const index = activeTargetId + ? pages.findIndex((page) => page.pageId === activeTargetId) + : 0; + const page = pages[index] ?? pages[0]; + if (!page) throw new Error("No active tab."); + return { index: index >= 0 ? index : 0, page }; +} diff --git a/packages/cli/src/lib/driver/commands/unavailable.ts b/packages/cli/src/lib/driver/commands/unavailable.ts index 9bf3f4c53c..6f1f3d86e0 100644 --- a/packages/cli/src/lib/driver/commands/unavailable.ts +++ b/packages/cli/src/lib/driver/commands/unavailable.ts @@ -1,16 +1,5 @@ import { DriverError } from "../errors.js"; -import type { DriverCommandHandler, DriverCommandName } from "./types.js"; - -export function unavailableV4Command( - command: DriverCommandName, -): DriverCommandHandler { - return async () => { - throw new DriverError( - `The \`${command}\` command has not been migrated to Stagehand V4 in this stack layer.`, - { code: "v4_command_unavailable" }, - ); - }; -} +import type { DriverCommandHandler } from "./types.js"; export const unavailableCursorOverlay: DriverCommandHandler = async () => { throw new DriverError( diff --git a/packages/cli/tests/driver-commands.test.ts b/packages/cli/tests/driver-commands.test.ts index 1b053324df..58b9d6e55b 100644 --- a/packages/cli/tests/driver-commands.test.ts +++ b/packages/cli/tests/driver-commands.test.ts @@ -9,7 +9,7 @@ import { elementsHandlers } from "../src/lib/driver/commands/elements.js"; import { keyboardHandlers } from "../src/lib/driver/commands/keyboard.js"; import { mouseHandlers } from "../src/lib/driver/commands/mouse.js"; import { navigationHandlers } from "../src/lib/driver/commands/navigation.js"; -import { pageInfoHandlers } from "../src/lib/driver/commands/page-info.js"; +import { networkHandlers } from "../src/lib/driver/commands/network.js"; import { resolveSelector } from "../src/lib/driver/commands/selectors.js"; import { formatSnapshotTree } from "../src/lib/driver/commands/snapshot-format.js"; import { snapshotHandlers } from "../src/lib/driver/commands/snapshot.js"; @@ -19,7 +19,6 @@ import { DRIVER_COMMAND_NAMES } from "../src/lib/driver/commands/types.js"; import { hasExplicitDriverTarget } from "../src/lib/driver/command-cli.js"; import { getSocketPath } from "../src/lib/driver/daemon/paths.js"; import { parseRequest } from "../src/lib/driver/daemon/protocol.js"; -import { NetworkCapture } from "../src/lib/driver/network-capture.js"; import { runCli } from "./helpers/run-cli.js"; describe("driver commands", () => { @@ -108,7 +107,9 @@ describe("driver commands", () => { }); await expect( resolveTargetForCommand("reuse-local", { remote: true }), - ).resolves.toEqual({ kind: "remote" }); + ).resolves.toEqual({ + kind: "remote", + }); } finally { vi.doUnmock("../src/lib/driver/daemon/client.js"); vi.resetModules(); @@ -152,7 +153,9 @@ describe("driver commands", () => { expect(runDriverCommandViaDaemon).toHaveBeenCalledWith( "cdp-state", "snapshot", - { compact: true }, + { + compact: true, + }, ); expect(openViaDaemon).not.toHaveBeenCalled(); } finally { @@ -174,57 +177,310 @@ describe("driver commands", () => { ).toThrow(); }); - it("keeps open available on the V4 foundation", async () => { - const page = { goto: vi.fn() }; + it("maps the CLI navigation timeout contract to the V4 page option", async () => { + const page = { goto: vi.fn().mockResolvedValue(undefined) }; const manager = { - openResult: vi.fn(async () => ({ url: "https://example.com" })), - pageForOpen: vi.fn(async () => page), + openResult: vi.fn().mockResolvedValue({ url: "https://example.com" }), + pageForOpen: vi.fn().mockResolvedValue(page), } as unknown as Parameters< NonNullable<(typeof navigationHandlers)["open"]> >[0]; await expect( navigationHandlers.open!(manager, { - timeoutMs: 1_234, + timeoutMs: 5_000, url: "https://example.com", - waitUntil: "networkidle", + waitUntil: "load", }), ).resolves.toEqual({ url: "https://example.com" }); expect(page.goto).toHaveBeenCalledWith("https://example.com", { - timeout: 1_234, - waitUntil: "networkidle", + timeout: 5_000, + waitUntil: "load", }); }); - it("fails deferred standard commands with one stable result code", async () => { - const deferred = [ - elementsHandlers.click, - elementsHandlers.fill, - elementsHandlers.select, - keyboardHandlers.type, - mouseHandlers["mouse.click"], - mouseHandlers["mouse.drag"], - navigationHandlers.back, - navigationHandlers.reload, - pageInfoHandlers.get, - pageInfoHandlers.is, - runtimeHandlers.screenshot, - tabHandlers["tab.close"], - tabHandlers["tab.new"], - ]; + it("routes selector click and fill through V4 locators", async () => { + const locator = { + click: vi.fn(), + fill: vi.fn(), + }; + const page = { + keyPress: vi.fn(), + locator: vi.fn(() => locator), + }; + const manager = { + activePage: vi.fn(async () => page), + resolveSelector: vi.fn((selector: string) => + selector === "@0-1" ? "/html/body/button" : selector, + ), + } as unknown as Parameters< + NonNullable<(typeof elementsHandlers)["click"]> + >[0]; - for (const handler of deferred) { - await expect(handler!({} as never, {})).rejects.toMatchObject({ - code: "v4_command_unavailable", - }); + await expect( + elementsHandlers.click!(manager, { selector: "@0-1" }), + ).resolves.toEqual({ + clicked: true, + }); + await expect( + elementsHandlers.fill!(manager, { + pressEnter: true, + selector: "#email", + value: "user@example.com", + }), + ).resolves.toEqual({ filled: true, pressedEnter: true }); + + expect(page.locator).toHaveBeenNthCalledWith(1, "/html/body/button"); + expect(page.locator).toHaveBeenNthCalledWith(2, "#email"); + expect(locator.click).toHaveBeenCalledOnce(); + expect(locator.fill).toHaveBeenCalledWith("user@example.com"); + expect(page.keyPress).toHaveBeenCalledWith("Enter"); + }); + + it("keeps select and highlight on V4 locators", async () => { + const locator = { + highlight: vi.fn(), + selectOption: vi.fn().mockResolvedValue(["green", "blue"]), + }; + const page = { locator: vi.fn(() => locator) }; + const manager = { + activePage: vi.fn(async () => page), + resolveSelector: vi.fn((selector: string) => selector), + } as unknown as Parameters< + NonNullable<(typeof elementsHandlers)["select"]> + >[0]; + + await expect( + elementsHandlers.select!(manager, { + selector: "#colors", + values: ["green", "blue"], + }), + ).resolves.toEqual({ selected: ["green", "blue"] }); + await expect( + elementsHandlers.highlight!(manager, { + durationMs: 750, + selector: "#colors", + }), + ).resolves.toEqual({ highlighted: true }); + + expect(page.locator).toHaveBeenNthCalledWith(1, "#colors"); + expect(page.locator).toHaveBeenNthCalledWith(2, "#colors"); + expect(locator.selectOption).toHaveBeenCalledWith(["green", "blue"]); + expect(locator.highlight).toHaveBeenCalledWith({ durationMs: 750 }); + }); + + it("uploads files through the V4 locator API", async () => { + const setInputFiles = vi.fn(); + const manager = { + activePage: vi.fn(async () => ({ + locator: vi.fn(() => ({ setInputFiles })), + })), + resolveSelector: vi.fn((selector: string) => selector), + } as unknown as Parameters< + NonNullable<(typeof elementsHandlers)["upload"]> + >[0]; + + await expect( + elementsHandlers.upload!(manager, { + files: ["/tmp/file.txt"], + selector: "input[type=file]", + }), + ).resolves.toEqual({ files: ["/tmp/file.txt"], uploaded: true }); + expect(setInputFiles).toHaveBeenCalledWith(["/tmp/file.txt"]); + }); + + it("omits undefined typing options from V4 requests", async () => { + const page = { type: vi.fn() }; + const manager = { + activePage: vi.fn(async () => page), + } as unknown as Parameters< + NonNullable<(typeof keyboardHandlers)["type"]> + >[0]; + + await expect( + keyboardHandlers.type!(manager, { text: "plain" }), + ).resolves.toEqual({ + typed: true, + }); + await expect( + keyboardHandlers.type!(manager, { + delay: 25, + mistakes: true, + text: "human", + }), + ).resolves.toEqual({ typed: true }); + + expect(page.type).toHaveBeenNthCalledWith(1, "plain", undefined); + expect(page.type).toHaveBeenNthCalledWith(2, "human", { + delay: 25, + withMistakes: true, + }); + }); + + it("preserves supported coordinate mouse arguments on V4 pages", async () => { + const page = { + click: vi.fn(), + dragAndDrop: vi.fn(), + hover: vi.fn(), + scroll: vi.fn(), + }; + const manager = { + activePage: vi.fn(async () => page), + } as unknown as Parameters< + NonNullable<(typeof mouseHandlers)["mouse.click"]> + >[0]; + + await expect( + mouseHandlers["mouse.click"]!(manager, { + button: "right", + clickCount: 2, + x: 10, + y: 20, + }), + ).resolves.toEqual({ clicked: true }); + await expect( + mouseHandlers["mouse.hover"]!(manager, { x: 30, y: 40 }), + ).resolves.toEqual({ + hovered: true, + }); + await expect( + mouseHandlers["mouse.scroll"]!(manager, { + deltaX: 5, + deltaY: 500, + x: 50, + y: 60, + }), + ).resolves.toEqual({ scrolled: true }); + await expect( + mouseHandlers["mouse.drag"]!(manager, { + button: "left", + delay: 25, + fromX: 70, + fromY: 80, + steps: 4, + toX: 90, + toY: 100, + }), + ).resolves.toEqual({ dragged: true }); + + expect(page.click).toHaveBeenCalledWith(10, 20, { + button: "right", + clickCount: 2, + }); + expect(page.hover).toHaveBeenCalledWith(30, 40); + expect(page.scroll).toHaveBeenCalledWith(50, 60, 5, 500); + expect(page.dragAndDrop).toHaveBeenCalledWith(70, 80, 90, 100, { + button: "left", + delay: 25, + steps: 4, + }); + }); + + it("omits undefined coordinate options from V4 requests", async () => { + const page = { + click: vi.fn(), + dragAndDrop: vi.fn(), + hover: vi.fn(), + scroll: vi.fn(), + }; + const manager = { + activePage: vi.fn(async () => page), + } as unknown as Parameters< + NonNullable<(typeof mouseHandlers)["mouse.click"]> + >[0]; + + await mouseHandlers["mouse.click"]!(manager, { x: 10, y: 20 }); + await mouseHandlers["mouse.hover"]!(manager, { x: 30, y: 40 }); + await mouseHandlers["mouse.scroll"]!(manager, { + deltaX: 5, + deltaY: 500, + x: 50, + y: 60, + }); + await mouseHandlers["mouse.drag"]!(manager, { + fromX: 70, + fromY: 80, + toX: 90, + toY: 100, + }); + + expect(page.click).toHaveBeenCalledWith(10, 20, {}); + expect(page.hover).toHaveBeenCalledWith(30, 40); + expect(page.scroll).toHaveBeenCalledWith(50, 60, 5, 500); + expect(page.dragAndDrop).toHaveBeenCalledWith(70, 80, 90, 100, {}); + }); + + it("fails explicitly for the V4 coordinate XPath capability", async () => { + const manager = {} as Parameters< + NonNullable<(typeof mouseHandlers)["mouse.click"]> + >[0]; + + for (const [command, params] of [ + ["mouse.click", { returnXPath: true, x: 1, y: 2 }], + ["mouse.hover", { returnXPath: true, x: 1, y: 2 }], + ["mouse.scroll", { deltaX: 0, deltaY: 1, returnXPath: true, x: 1, y: 2 }], + ["mouse.drag", { fromX: 1, fromY: 2, returnXPath: true, toX: 3, toY: 4 }], + ] as const) { + await expect(mouseHandlers[command]!(manager, params)).rejects.toThrow( + "Coordinate XPath lookup is not exposed by Stagehand V4", + ); } + }); + it("reports the isolated V4 network-capture gap", async () => { + const page = {}; + const network = { + enable: vi.fn(async () => { + throw new Error("Network capture is not available"); + }), + }; + const manager = { + activePage: vi.fn(async () => page), + network, + } as unknown as Parameters< + NonNullable<(typeof networkHandlers)["network.on"]> + >[0]; + + await expect(networkHandlers["network.on"]!(manager, {})).rejects.toThrow( + "Network capture is not available", + ); + expect(network.enable).toHaveBeenCalledWith(page); + }); + + it("keeps cursor as an explicit capability gap", async () => { await expect( runtimeHandlers.cursor!({} as never, {}), ).rejects.toMatchObject({ code: "cursor_overlay_unavailable" }); - await expect(new NetworkCapture("gap").enable({})).rejects.toMatchObject({ - code: "network_capture_unavailable", + }); + + it("selects a remaining tab after closing the active tab", async () => { + const tabs = createFakeTabManager(["tab-1", "tab-2", "tab-3"], 1); + + await expect(tabHandlers["tab.close"]!(tabs.manager, {})).resolves.toEqual({ + closed: true, + index: 1, + selectedTargetId: "tab-3", + targetId: "tab-2", }); + expect(tabs.pages[1]!.close).toHaveBeenCalledOnce(); + expect(tabs.context.setActivePage).toHaveBeenCalledWith(tabs.pages[2]); + expect(tabs.active).toBe(tabs.pages[2]); + }); + + it("preserves the active tab after closing a non-active tab", async () => { + const tabs = createFakeTabManager(["tab-1", "tab-2", "tab-3"], 0); + + await expect( + tabHandlers["tab.close"]!(tabs.manager, { tab: "tab-2" }), + ).resolves.toEqual({ + closed: true, + index: 1, + selectedTargetId: "tab-1", + targetId: "tab-2", + }); + expect(tabs.pages[1]!.close).toHaveBeenCalledOnce(); + expect(tabs.context.setActivePage).not.toHaveBeenCalled(); + expect(tabs.active).toBe(tabs.pages[0]); }); it("rejects invalid wait timeout values before calling the page", async () => { @@ -245,10 +501,46 @@ describe("driver commands", () => { await expect( runtimeHandlers.wait!(manager, { arg: "100", type: "timeout" }), - ).resolves.toEqual({ waited: true }); + ).resolves.toEqual({ + waited: true, + }); expect(page.waitForTimeout).toHaveBeenCalledWith(100); }); + it("omits undefined screenshot options from V4 wire requests", async () => { + const page = { + screenshot: vi.fn().mockResolvedValue(Buffer.from("image")), + }; + const manager = { + activePage: async () => page, + } as unknown as Parameters< + NonNullable<(typeof runtimeHandlers)["screenshot"]> + >[0]; + + await expect(runtimeHandlers.screenshot!(manager, {})).resolves.toEqual({ + base64: Buffer.from("image").toString("base64"), + }); + expect(page.screenshot).toHaveBeenCalledWith({ timeout: 10_000 }); + + await runtimeHandlers.screenshot!(manager, { + animations: "disabled", + caret: "hide", + clip: { height: 200, width: 300, x: 10, y: 20 }, + fullPage: true, + quality: 80, + type: "jpeg", + }); + expect(page.screenshot).toHaveBeenLastCalledWith({ + animations: "disabled", + caret: "hide", + clip: { height: 200, width: 300, x: 10, y: 20 }, + fullPage: true, + quality: 80, + timeout: 10_000, + type: "jpeg", + }); + }); + it("accepts fractional viewport scale values", async () => { const daemonDir = await fs.mkdtemp( join(tmpdir(), "browse-viewport-scale-"), @@ -423,6 +715,53 @@ describe("driver commands", () => { }); }); +type FakeTabPage = { + close: ReturnType; + pageId: string; + title: () => Promise; + url: () => Promise; +}; + +function createFakeTabManager(targetIds: string[], activeIndex: number) { + let pages: FakeTabPage[] = []; + let active: FakeTabPage | null = null; + const makePage = (targetId: string): FakeTabPage => { + const page: FakeTabPage = { + close: vi.fn(async () => { + pages = pages.filter((candidate) => candidate !== page); + }), + pageId: targetId, + title: async () => targetId, + url: async () => `https://example.com/${targetId}`, + }; + return page; + }; + + pages = targetIds.map(makePage); + active = pages[activeIndex] ?? null; + const context = { + activePage: async () => active, + pages: async () => pages, + setActivePage: vi.fn(async (page: FakeTabPage) => { + active = page; + }), + }; + + return { + get active() { + return active; + }, + context, + manager: { + browserContext: async () => context, + safeTitle: async (page: FakeTabPage) => page.title(), + } as unknown as Parameters< + NonNullable<(typeof tabHandlers)["tab.close"]> + >[0], + pages, + }; +} + function restoreEnv(key: string, value: string | undefined): void { if (value === undefined) { delete process.env[key];