diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 990cf0a925..6099d99ec7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,8 @@ jobs: runs-on: ubuntu-latest outputs: extension: ${{ steps.filter.outputs.extension }} + integrations: ${{ steps.filter.outputs.integrations }} + is_internal_head: ${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }} sdk-ts: ${{ steps.filter.outputs.sdk-ts }} sdk-python: ${{ steps.filter.outputs.sdk-python }} sdk-go: ${{ steps.filter.outputs.sdk-go }} @@ -42,6 +44,8 @@ jobs: - 'pnpm-lock.yaml' - 'turbo.json' - '.github/**' + integrations: + - 'packages/integrations/**' sdk-python: - 'packages/sdk-python/**' - 'packages/extension/**' @@ -167,6 +171,7 @@ jobs: packages/extension/dist/** packages/extension/artifacts/** packages/sdk-ts/dist/** + packages/integrations/dist/** packages/evals/dist/** retention-days: 1 @@ -191,6 +196,21 @@ jobs: env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} + integrations: + name: TypeScript integrations + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [build, determine-changes] + if: needs.determine-changes.outputs.integrations == 'true' + steps: + - uses: actions/checkout@d23441a48e516b6c34aea4fa41551a30e30af803 # v6.1.0 + + - uses: ./.github/actions/setup-node-pnpm + with: + use-prebuilt-artifacts: "true" + + - run: pnpm exec turbo run test:unit --filter @browserbasehq/stagehand-integrations + browser-ts: name: TypeScript browser runs-on: ubuntu-latest @@ -211,6 +231,7 @@ jobs: env: CHROME_PATH: ${{ steps.setup-chrome.outputs.chrome-path }} - name: Browserbase smoke test + if: needs.determine-changes.outputs.is_internal_head == 'true' run: >- pnpm exec vitest run --root . packages/sdk-ts/tests/browser-runtime/stagehandBrowserbaseSmoke.test.ts diff --git a/packages/integrations/README.md b/packages/integrations/README.md new file mode 100644 index 0000000000..3a5df2478a --- /dev/null +++ b/packages/integrations/README.md @@ -0,0 +1,71 @@ +# Stagehand integrations + +This package contains shared integration surfaces for Stagehand V4. It is private while the public API and packaging contract are validated. + +## Code mode + +The `./codemode` export gives an agent one `code_execute` tool backed by a persistent Stagehand browser. Frameworks can either launch the thin local MCP server or wrap `StagehandCodeExecutor` as a native tool. + +```ts +import { + StagehandCodeExecutor, + stagehandCodeConfigFromEnv, +} from "@browserbasehq/stagehand-integrations/codemode"; + +const executor = new StagehandCodeExecutor(stagehandCodeConfigFromEnv()); + +try { + const result = await executor.execute({ + code: ` + await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); + return { title: await page.title(), url: await page.url() }; + `, + }); + console.log(result); +} finally { + await executor.close(); +} +``` + +The executor initializes the browser on the first valid call, serializes calls, and preserves pages, cookies, and navigation state until its owner closes it. + +### Low-level eval integration + +Eval harnesses that already own Stagehand and browser initialization should call `executeStagehandSnippet` directly. This reuses the exact generated-code semantics without replacing the eval harness's startup, cleanup, task bindings, or metrics collection. + +### Local MCP integration + +The `./codemode/stdio-server` export is an internal process entrypoint. It is not a command-line interface and accepts no arguments. The owning framework launches one process per agent run and selects local or Browserbase startup through its environment: + +```text +STAGEHAND_BROWSER=local +STAGEHAND_BROWSER=browserbase +``` + +The process stays alive across calls and closes when its input stream ends. `SIGINT` and `SIGTERM` perform bounded graceful cleanup and preserve signal-style exit codes. If generated JavaScript blocks the JavaScript event loop, the server cannot run its cleanup handlers. The owner must terminate the entire process tree, escalate to `SIGKILL` after its own deadline, and start a new process before accepting more work. Killing only the Node process can leave its local browser child alive. + +### Configuration + +`stagehandCodeConfigFromEnv()` recognizes: + +| Variable | Purpose | +| ------------------------------------------------------------------ | ---------------------------------------------------------------- | +| `STAGEHAND_BROWSER` | Optional `local` or `browserbase` override | +| `BROWSERBASE_API_KEY` | Selects and authenticates Browserbase when present | +| `BROWSERBASE_PROJECT_ID` | Optional Browserbase project forwarded when creating a session | +| `STAGEHAND_MODEL_NAME` | Optional Stagehand model name | +| `STAGEHAND_MODEL_API_KEY` | Optional explicit model-provider key | +| Provider API keys | Supplies the key for a matching explicit model provider | +| `GEMINI_API_KEY`, `GOOGLE_GENERATIVE_AI_API_KEY`, `GOOGLE_API_KEY` | Selects `google/gemini-2.5-flash-lite` when no model is explicit | + +Without a browser override, the helper selects Browserbase when `BROWSERBASE_API_KEY` exists and a headless local browser otherwise. + +Native callers run generated JavaScript in their own process. An `AbortSignal` can cancel queued work before the snippet begins, but it cannot safely preempt arbitrary JavaScript already running in the same process. Native integrations that require hard time limits should put the executor behind a child-process boundary, as the stdio MCP integration does. + +### Skill and reference + +`codemode/SKILL.md` is the concise agent guide. `codemode/REFERENCE.md` is the longer API lookup. Both are exported as raw package assets and as bundle-safe JavaScript strings. + +### Security boundary + +The code-mode executor does not provide a sandbox. Generated JavaScript runs in the host process and inherits that process's filesystem, network, and environment access. A framework may place the tool inside its own sandbox, container, or other isolation boundary. diff --git a/packages/integrations/codemode/REFERENCE.md b/packages/integrations/codemode/REFERENCE.md new file mode 100644 index 0000000000..ed16d398d0 --- /dev/null +++ b/packages/integrations/codemode/REFERENCE.md @@ -0,0 +1,211 @@ +# Stagehand V4 code-mode reference + +This reference describes the Stagehand objects injected into `code_execute`. `SKILL.md` contains the +short operational guide intended for every agent context; this file is the longer lookup reference. + +## Function body contract + +The tool builds an async function from the submitted body. These values are injected: + +```ts +page: Page; +context: BrowserContext; +stagehand: Stagehand; +z: typeof import("zod/v4"); +console: Pick; +``` + +Eval harnesses may add JSON-safe bindings such as `startUrl` and `task`. The deterministic eval arm +intentionally omits `stagehand` and `z`. + +## Page + +Navigation and page state: + +```ts +page.goto(url, { waitUntil?, timeout? }): Promise +page.reload(options?): Promise +page.goBack(options?): Promise +page.goForward(options?): Promise +page.url(): Promise +page.title(): Promise +page.pageId: string +page.close(): Promise +``` + +Input and pointer operations: + +```ts +page.click(x, y, options?): Promise +page.hover(x, y): Promise +page.scroll(x, y, deltaX, deltaY): Promise +page.dragAndDrop(fromX, fromY, toX, toY, options?): Promise +page.type(text, options?): Promise +page.keyPress(key, options?): Promise +``` + +DOM and setup operations: + +```ts +page.evaluate(expressionOrFunction, arg?): Promise +page.addInitScript(script, arg?): Promise +page.setExtraHTTPHeaders(headers): Promise +page.setViewportSize(width, height, options?): Promise +page.waitForLoadState('load' | 'domcontentloaded' | 'networkidle', timeout?): Promise +page.waitForTimeout(milliseconds): Promise +page.waitForSelector(selector, options?): Promise +page.screenshot(options?): Promise +page.snapshot({ includeIframes? }): Promise<{ formattedTree; xpathMap; urlMap }> +page.locator(selector): Locator +``` + +`page.evaluate` accepts a function and one serializable argument. Prefer one object when several +values are needed: + +```js +return await page.evaluate( + ({ selector, limit }) => + Array.from(document.querySelectorAll(selector)) + .slice(0, limit) + .map((node) => node.textContent?.trim() ?? ""), + { selector: "article h2", limit: 20 }, +); +``` + +## Locator + +```ts +locator.click(options?): Promise +locator.hover(): Promise +locator.fill(value): Promise +locator.count(): Promise +locator.isChecked(): Promise +locator.inputValue(): Promise +locator.isVisible(): Promise +locator.innerText(): Promise +locator.innerHtml(): Promise +locator.textContent(): Promise +locator.scrollTo(percent): Promise +locator.centroid(): Promise<{ x: number; y: number }> +locator.highlight(options?): Promise +locator.sendClickEvent(options?): Promise +locator.type(text, options?): Promise +locator.selectOption(values): Promise +locator.setInputFiles(files): Promise +locator.first(): Locator +locator.nth(index): Locator +``` + +Stagehand locators do not implement Playwright's collection/filter/frame helpers. Use `count` with +`nth`, or use `page.evaluate` for bulk DOM reads. + +## BrowserContext + +```ts +context.pages(): Promise +context.newPage(options?): Promise +context.activePage(): Promise +context.setActivePage(page): Promise +context.setExtraHTTPHeaders(headers): Promise +context.getDomainPolicy(): Promise +context.setDomainPolicy(policy: DomainPolicy | null): Promise +context.cookies(urls?): Promise +context.addCookies(cookies): Promise +context.clearCookies(options?): Promise +``` + +The tool owner closes the context and browser. Generated code should not call `context.close()`, +`stagehand.close()`, or `stagehand.browser.close()`. + +## Stagehand AI methods + +```ts +stagehand.act(instruction, options?): Promise<{ + data: { success; message; actionDescription; actions }; + metadata: StagehandResultMetadata; +}> + +stagehand.observe(instruction?, options?): Promise<{ + data: Action[]; + metadata: StagehandResultMetadata; +}> + +stagehand.extract(instruction, schema?, options?): Promise<{ + data: unknown; + metadata: StagehandResultMetadata; +}> +``` + +The useful operation result is under `.data`. The `.metadata` object contains cache and model-usage +information for that operation. + +To target a non-active page: + +```js +const pages = await context.pages(); +const result = await stagehand.extract("Extract the heading", z.object({ heading: z.string() }), { + page: pages[1], +}); +return result.data; +``` + +## Multiple pages + +Page arrays are snapshots. Await `context.pages()` again after an interaction that may open or +close a tab. Use `context.setActivePage(page)` when later operations should target that tab. + +```js +const before = await context.pages(); +const beforePageIds = new Set(before.map((page) => page.pageId)); +await before[0].locator('a[target="_blank"]').first().click(); +await before[0].waitForTimeout(500); +const after = await context.pages(); +const opened = after.find((candidate) => !beforePageIds.has(candidate.pageId)); +if (!opened) throw new Error("Expected a new page"); +await context.setActivePage(opened); +return await opened.url(); +``` + +## Pagination pattern + +```js +const records = new Map(); +const seenPages = new Set(); + +for (let pageIndex = 0; pageIndex < 50; pageIndex++) { + const rows = await page.evaluate(() => + Array.from(document.querySelectorAll("table tbody tr")).map((row) => + Array.from(row.querySelectorAll("td")).map((cell) => cell.textContent?.trim() ?? ""), + ), + ); + const signature = JSON.stringify(rows); + if (seenPages.has(signature)) break; + seenPages.add(signature); + for (const row of rows) records.set(JSON.stringify(row), row); + + const next = page.locator(".paginate_button.next").first(); + if (!(await next.isVisible())) break; + await next.click(); + await page.waitForTimeout(300); +} + +return Array.from(records.values()); +``` + +## Common incompatibilities + +| Incorrect assumption | Stagehand V4 form | +| ------------------------------ | ----------------------------------------------------------- | +| `page.url` is a string | `await page.url()` | +| `page.content()` | `await page.locator('body').innerHtml()` or `page.evaluate` | +| `locator.innerHTML()` | `await locator.innerHtml()` | +| `locator.all()` | `count()` plus `nth(index)` | +| `locator.evaluate()` | `page.evaluate()` | +| `page.frameLocator()` | `page.snapshot({ includeIframes: true })` plus `xpathMap` | +| `context.waitForEvent('page')` | Compare `await context.pages()` before and after | +| `result.success` from `act` | `result.data.success` | +| Raw array from `observe` | `result.data` | +| Raw object from `extract` | `result.data` | + +When this reference and the installed SDK disagree, the installed `@browserbasehq/stagehand` +TypeScript declaration files are authoritative. diff --git a/packages/integrations/codemode/SKILL.md b/packages/integrations/codemode/SKILL.md new file mode 100644 index 0000000000..9449a8373d --- /dev/null +++ b/packages/integrations/codemode/SKILL.md @@ -0,0 +1,168 @@ +# Stagehand V4 code-mode syntax + +You have one code execution tool. Its `code` argument is the body of an async JavaScript function, +not a complete program. Write direct `await` statements and finish with a JSON-serializable return +value. Use the tool name supplied by the host framework. + +The following objects are always in scope: + +- `page`: the active Stagehand `Page`. +- `context`: the Stagehand `BrowserContext` shared across calls. In host code, this is + `stagehand.browser.context`, not `stagehand.context`. +- `console`: captured `log`, `warn`, and `error` methods. + +AI-enabled surfaces also inject: + +- `stagehand`: the Stagehand AI methods `act`, `observe`, and `extract`. +- `z`: Zod V4 for `stagehand.extract` schemas. + +If the host labels the surface deterministic, `stagehand` and `z` are intentionally unavailable. + +Do not import packages, read environment variables, construct Stagehand, or close Stagehand or the +browser. The tool owner manages initialization and cleanup. + +## Hard rules + +1. Use deterministic page and locator methods first when the page structure is known or inspectable. +2. Treat the method lists below as allow-lists. Stagehand V4 is not Playwright; do not guess methods. +3. Await `page.url()`, `page.title()`, and every `context` method. +4. Stop when the requested evidence is complete. Do not re-fetch exact evidence with an AI method. +5. During pagination, stop on the first repeated row signature and return deduplicated records. + +## Deterministic browser syntax + +```js +await page.goto("https://example.com", { waitUntil: "domcontentloaded" }); +const heading = await page.locator("h1").innerText(); +return { heading, url: await page.url(), title: await page.title() }; +``` + +Supported page methods include `goto`, `reload`, `goBack`, `goForward`, `click`, `hover`, `scroll`, +`dragAndDrop`, `type`, `keyPress`, `evaluate`, `addInitScript`, `setExtraHTTPHeaders`, +`setViewportSize`, `waitForLoadState`, `waitForTimeout`, `waitForSelector`, `screenshot`, `snapshot`, +`tools`, `url`, `title`, and `locator`. + +Supported locator methods include `click`, `hover`, `fill`, `count`, `isChecked`, `inputValue`, +`isVisible`, `innerText`, `innerHtml`, `textContent`, `scrollTo`, `centroid`, `highlight`, +`sendClickEvent`, `type`, `selectOption`, `setInputFiles`, `first`, and `nth`. + +Supported context methods include `pages`, `newPage`, `activePage`, `setActivePage`, +`setExtraHTTPHeaders`, `getDomainPolicy`, `setDomainPolicy`, `cookies`, `addCookies`, and +`clearCookies`. + +To submit a filled field with Enter, call `page.keyPress` with the key first. It does not take a +selector, and V4 does not expose `page.keyboard`: + +```js +await page.locator('input[name="q"]').fill("vegetarian lasagna"); +await page.keyPress("Enter"); +``` + +Do not use Playwright-only methods such as locator `all`, `allTextContents`, `evaluate`, +`evaluateAll`, `filter`, `getAttribute`, `contentFrame`, or `innerHTML`; page `content`, +`frameLocator`, `frames`, or `keyboard`; or context `waitForEvent`. Use `innerHtml` with a lowercase +`l`. + +For DOM collection reads, attributes, or custom traversal, use `page.evaluate`: + +```js +const links = await page.evaluate(() => + Array.from(document.querySelectorAll("a")).map((a) => ({ + text: a.textContent?.trim() ?? "", + href: a.getAttribute("href"), + })), +); +return links; +``` + +## Stagehand AI syntax + +Stagehand V4 AI methods return `{ data, metadata }`. Read the useful result from `.data`. + +```js +const result = await stagehand.act("Click the sign-in button"); +if (!result.data.success) throw new Error(result.data.message); +return result.data; +``` + +```js +const result = await stagehand.observe("Find the checkout button"); +return result.data; +``` + +```js +const result = await stagehand.extract( + "Extract the product name and price", + z.object({ name: z.string(), price: z.string() }), +); +return result.data; +``` + +Keep every `z.object` property required. Strict structured-output providers reject extraction +schemas containing `.optional()`. When the page may omit a value, keep the key required and make +its value nullable: + +```js +z.object({ title: z.string(), rating: z.string().nullable() }); +``` + +Pass `{ page: anotherPage }` as the final options object when an AI method should target a page +other than the active page. + +## Pages and state across calls + +```js +const before = await context.pages(); +const beforePageIds = new Set(before.map((page) => page.pageId)); +const current = before[before.length - 1]; +await current.locator("button").first().click(); +await current.waitForTimeout(500); +const after = await context.pages(); +const opened = after.find((candidate) => !beforePageIds.has(candidate.pageId)); +if (!opened) throw new Error("Expected a new page"); +await context.setActivePage(opened); +return { pageCount: after.length, activeUrl: await opened.url() }; +``` + +There is no `context.waitForEvent`. `context.pages()` returns fresh Page wrappers, so detect a newly +opened tab by comparing the stable `page.pageId` values before and after the click. A click does not +necessarily make the new tab active; call `context.setActivePage(opened)` explicitly. + +The same browser, pages, cookies, and navigation state persist across successful tool calls. Local +JavaScript variables do not persist, so rediscover pages and elements each call. If a call stops +responding, the owning framework should terminate and restart the tool process; the restarted +process begins with a new browser. + +## Cross-origin iframes + +Do not use `page.frameLocator()` or locator `contentFrame()`. Call `page.snapshot({ includeIframes: +true })`, locate the relevant accessibility reference in `formattedTree`, look up its frame-piercing +XPath in `xpathMap`, and use the XPath with the normal locator methods: + +```js +const snapshot = await page.snapshot({ includeIframes: true }); +const ref = "0-2"; // Discover the task-specific ref from formattedTree. +const xpath = snapshot.xpathMap[ref]; +if (!xpath) throw new Error(`No XPath for accessibility ref ${ref}`); +const field = page.locator(`xpath=${xpath}`); +await field.fill("value"); +return { value: await field.inputValue() }; +``` + +## Efficient recovery + +- Batch related deterministic operations when later steps depend on earlier state. +- If navigation or `setActivePage` times out, inspect current pages and URLs before repeating it. +- A failed `act`, `observe`, or `extract` does not destroy the browser. Continue with deterministic + methods when possible. +- Pagination controls may be visually enabled without changing the rendered rows. Deduplicate rows + and stop on the first repeated page signature. + +## Return discipline + +Return only compact evidence needed by the agent. Prefer strings, numbers, booleans, arrays, and +plain objects. Do not return Page, Locator, BrowserContext, Stagehand, or Zod objects. Await +asynchronous methods before returning. + +For the newest exact declarations, inspect the installed `@browserbasehq/stagehand` TypeScript +declarations and this package's `codemode/REFERENCE.md` when filesystem access is available. diff --git a/packages/integrations/package.json b/packages/integrations/package.json new file mode 100644 index 0000000000..0e19a3fd3c --- /dev/null +++ b/packages/integrations/package.json @@ -0,0 +1,45 @@ +{ + "name": "@browserbasehq/stagehand-integrations", + "version": "4.0.0", + "private": true, + "description": "Shared integration surfaces for Stagehand V4", + "files": [ + "dist", + "codemode/SKILL.md", + "codemode/REFERENCE.md" + ], + "type": "module", + "exports": { + "./codemode": { + "types": "./dist/codemode/index.d.mts", + "import": "./dist/codemode/index.mjs" + }, + "./codemode/stdio-server": { + "import": "./dist/codemode/stdio-server.mjs" + }, + "./codemode/SKILL.md": "./codemode/SKILL.md", + "./codemode/REFERENCE.md": "./codemode/REFERENCE.md" + }, + "scripts": { + "build": "pnpm run check:generated && tsdown", + "check:generated": "node scripts/generate-codemode-content.mjs --check", + "generate": "node scripts/generate-codemode-content.mjs", + "test": "pnpm run build && vitest run --root ../.. packages/integrations/tests", + "test:unit": "vitest run --root ../.. packages/integrations/tests", + "typecheck": "pnpm run check:generated && tsc --noEmit -p tsconfig.json" + }, + "dependencies": { + "@browserbasehq/stagehand": "workspace:*", + "@modelcontextprotocol/sdk": "catalog:", + "zod": "catalog:" + }, + "devDependencies": { + "@types/node": "catalog:", + "tsdown": "catalog:", + "typescript": "catalog:", + "vitest": "catalog:" + }, + "engines": { + "node": ">=22.18.0" + } +} diff --git a/packages/integrations/scripts/generate-codemode-content.mjs b/packages/integrations/scripts/generate-codemode-content.mjs new file mode 100644 index 0000000000..ed0048969d --- /dev/null +++ b/packages/integrations/scripts/generate-codemode-content.mjs @@ -0,0 +1,37 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { fileURLToPath } from "node:url"; + +const packageRoot = fileURLToPath(new URL("..", import.meta.url)); +const outputUrl = new URL("../src/codemode/generated-content.ts", import.meta.url); +const skill = readFileSync(new URL("../codemode/SKILL.md", import.meta.url), "utf8").trim(); +const reference = readFileSync(new URL("../codemode/REFERENCE.md", import.meta.url), "utf8").trim(); +const output = [ + "// Generated by scripts/generate-codemode-content.mjs. Do not edit directly.", + "export const STAGEHAND_CODEMODE_SKILL =", + ` ${singleQuotedString(skill)};`, + "export const STAGEHAND_CODEMODE_REFERENCE =", + ` ${singleQuotedString(reference)};`, + "", +].join("\n"); + +if (process.argv.includes("--check")) { + const generated = readFileSync(outputUrl, "utf8"); + if (generated !== output) { + throw new Error(`Generated code-mode content is stale. Run "pnpm generate" in ${packageRoot}.`); + } + process.stdout.write("Generated code-mode content is current\n"); +} else { + writeFileSync(outputUrl, output); + process.stdout.write(`Generated code-mode content in ${packageRoot}\n`); +} + +function singleQuotedString(value) { + return `'${value + .replaceAll("\\", "\\\\") + .replaceAll("'", "\\'") + .replaceAll("\r", "\\r") + .replaceAll("\n", "\\n") + .replaceAll("\t", "\\t") + .replaceAll("\u2028", "\\u2028") + .replaceAll("\u2029", "\\u2029")}'`; +} diff --git a/packages/integrations/src/codemode/config.ts b/packages/integrations/src/codemode/config.ts new file mode 100644 index 0000000000..6301845a71 --- /dev/null +++ b/packages/integrations/src/codemode/config.ts @@ -0,0 +1,109 @@ +import { StagehandClientCreateConfigSchema } from "@browserbasehq/stagehand"; +import type { StagehandCodeConfig } from "./types.js"; + +const ANTHROPIC_DIRECT_BROWSER_ACCESS_HEADER = "anthropic-dangerous-direct-browser-access"; + +class StagehandCodeConfigError extends Error { + override readonly name = "StagehandCodeConfigError"; +} + +export function stagehandCodeConfigFromEnv( + env: NodeJS.ProcessEnv = process.env, +): StagehandCodeConfig { + const requestedBrowser = nonEmpty(env.STAGEHAND_BROWSER)?.toLowerCase(); + if ( + requestedBrowser !== undefined && + requestedBrowser !== "local" && + requestedBrowser !== "browserbase" + ) { + throw new StagehandCodeConfigError( + 'STAGEHAND_BROWSER must be either "local" or "browserbase".', + ); + } + + const browserbaseApiKey = nonEmpty(env.BROWSERBASE_API_KEY); + const browserbaseProjectId = nonEmpty(env.BROWSERBASE_PROJECT_ID); + const browserType = requestedBrowser ?? (browserbaseApiKey ? "browserbase" : "local"); + if (browserType === "browserbase" && !browserbaseApiKey) { + throw new StagehandCodeConfigError( + 'BROWSERBASE_API_KEY is required when STAGEHAND_BROWSER="browserbase".', + ); + } + + const explicitModelName = nonEmpty(env.STAGEHAND_MODEL_NAME); + const explicitModelApiKey = nonEmpty(env.STAGEHAND_MODEL_API_KEY); + if (!explicitModelName && explicitModelApiKey) { + throw new StagehandCodeConfigError( + "STAGEHAND_MODEL_NAME is required when STAGEHAND_MODEL_API_KEY is set.", + ); + } + + const inferredGoogleKey = providerApiKey("google", env); + const modelName = + explicitModelName ?? (inferredGoogleKey ? "google/gemini-2.5-flash-lite" : undefined); + const modelProvider = modelName ? providerName(modelName) : undefined; + const modelApiKey = explicitModelApiKey ?? providerApiKey(modelProvider, env); + + const stagehand = StagehandClientCreateConfigSchema.parse({ + logging: { level: "off" }, + ...(modelName + ? { + model: { + modelName, + ...(modelApiKey ? { apiKey: modelApiKey } : {}), + ...(modelProvider === "anthropic" + ? { headers: { [ANTHROPIC_DIRECT_BROWSER_ACCESS_HEADER]: "true" } } + : {}), + }, + } + : {}), + }); + + return { + browser: + browserType === "browserbase" + ? { + type: "browserbase", + launchOptions: { + apiKey: browserbaseApiKey, + ...(browserbaseProjectId ? { projectId: browserbaseProjectId } : {}), + }, + } + : { + type: "local", + launchOptions: { headless: true }, + }, + stagehand, + }; +} + +function providerName(modelName: string): string | undefined { + const separator = modelName.indexOf("/"); + return separator === -1 ? undefined : modelName.slice(0, separator).toLowerCase(); +} + +function providerApiKey(provider: string | undefined, env: NodeJS.ProcessEnv): string | undefined { + switch (provider) { + case "openai": + return nonEmpty(env.OPENAI_API_KEY); + case "anthropic": + return nonEmpty(env.ANTHROPIC_API_KEY); + case "google": + return ( + nonEmpty(env.GOOGLE_GENERATIVE_AI_API_KEY) ?? + nonEmpty(env.GEMINI_API_KEY) ?? + nonEmpty(env.GOOGLE_API_KEY) + ); + case "groq": + return nonEmpty(env.GROQ_API_KEY); + case "cerebras": + return nonEmpty(env.CEREBRAS_API_KEY); + default: + return undefined; + } +} + +function nonEmpty(value: string | undefined): string | undefined { + const trimmed = value?.trim(); + return trimmed ? trimmed : undefined; +} diff --git a/packages/integrations/src/codemode/executor.ts b/packages/integrations/src/codemode/executor.ts new file mode 100644 index 0000000000..413b1230c7 --- /dev/null +++ b/packages/integrations/src/codemode/executor.ts @@ -0,0 +1,342 @@ +import { + browserbase, + localBrowser, + Stagehand, + type Page, + type StagehandBrowser, + type StagehandMetrics, +} from "@browserbasehq/stagehand"; +import { MAX_CODE_BYTES } from "./limits.js"; +import { executeStagehandSnippet } from "./snippet.js"; +import type { + CodeExecuteFailure, + CodeExecuteInput, + CodeExecuteResult, + CodeLogEntry, + CodePageState, + StagehandCodeConfig, +} from "./types.js"; + +export type StagehandCodeExecutorOptions = StagehandCodeConfig; + +const MAX_LOG_BYTES = 64 * 1024; +const MAX_RESULT_BYTES = 256 * 1024; +const MAX_ERROR_MESSAGE_LENGTH = 4_000; +const MIN_SENSITIVE_VALUE_LENGTH = 8; +const SECRET_FIELD = /(?:api.?key|authorization|cookie|password|secret|token)/i; +const URL = /\b(?:https?|wss?):\/\/[^\s"'<>]+/gi; +const CREDENTIAL = + /\b(authorization|api[_-]?key|password|secret|token)\s*[:=]\s*(?:bearer\s+)?[^\s,;]+/gi; +const BEARER_TOKEN = /\bbearer\s+[^\s,;]+/gi; + +class StagehandCodeCloseError extends Error { + override readonly name = "StagehandCodeCloseError"; + + constructor() { + super("Failed to close Stagehand code mode."); + } +} + +class StagehandCodeInitializationError extends Error { + override readonly name = "StagehandCodeInitializationError"; + + constructor() { + super("Stagehand code mode initialization and browser cleanup both failed."); + } +} + +export class StagehandCodeExecutor { + private stagehand?: Stagehand; + private browser?: StagehandBrowser; + private queue = Promise.resolve(); + private closed = false; + private closePromise?: Promise; + private readonly sensitiveValues: string[]; + + constructor(private readonly options: StagehandCodeExecutorOptions) { + this.sensitiveValues = collectSensitiveValues(options); + } + + execute(input: CodeExecuteInput, signal?: AbortSignal): Promise { + const validation = validate(input); + if (validation) return Promise.resolve(validation); + + const operation = this.queue.then(() => this.executeQueued(input, signal)); + this.queue = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + metrics(): Promise { + const operation = this.queue.then(() => this.stagehand?.metrics()); + this.queue = operation.then( + () => undefined, + () => undefined, + ); + return operation; + } + + close(): Promise { + this.closed = true; + this.closePromise ??= this.queue.then(async () => { + const stagehand = this.stagehand; + const browser = this.browser; + this.stagehand = undefined; + this.browser = undefined; + + let failed = false; + if (stagehand) { + await stagehand.close().catch(() => { + failed = true; + }); + } + if (browser) { + await browser.close().catch(() => { + failed = true; + }); + } + if (failed) throw new StagehandCodeCloseError(); + }); + return this.closePromise; + } + + private async executeQueued( + input: CodeExecuteInput, + signal?: AbortSignal, + ): Promise { + if (this.closed) { + return failure("closed", "Code executor is closed."); + } + if (signal?.aborted) { + return failure("aborted", "Code execution was aborted before it began."); + } + + const logs: CodeLogEntry[] = []; + let page: Page | undefined; + try { + const stagehand = await this.ensureStagehand(); + const context = stagehand.browser.context; + page = + (await context.activePage()) ?? (await context.pages())[0] ?? (await context.newPage()); + + if (signal?.aborted) { + return failure("aborted", "Code execution was aborted before it began.", "CodeModeError", { + page: await readPageState(page), + }); + } + + const value = await executeStagehandSnippet({ + code: input.code, + page, + context, + stagehand, + console: createCodeConsole(logs), + }); + const currentPage = (await context.activePage()) ?? page; + + return { + ok: true, + page: await readPageState(currentPage), + ...(value === undefined ? {} : { value: jsonSafe(value) }), + ...(logs.length === 0 ? {} : { logs }), + }; + } catch (error) { + const normalized = normalizeError(error, this.sensitiveValues); + const currentPage = (await this.activePage().catch(() => undefined)) ?? page; + return failure("runtime", normalized.message, normalized.name, { + ...(currentPage ? { page: await readPageState(currentPage).catch(() => undefined) } : {}), + ...(logs.length === 0 ? {} : { logs }), + }); + } + } + + private async ensureStagehand(): Promise { + if (this.stagehand) return this.stagehand; + + const browserConfig = this.options.browser; + const browser = + browserConfig.type === "browserbase" + ? await browserbase.launch(browserConfig.launchOptions) + : await localBrowser.launch(browserConfig.launchOptions); + + try { + const stagehand = await Stagehand.create({ + browser, + logging: { level: "off" }, + ...this.options.stagehand, + }); + this.browser = browser; + this.stagehand = stagehand; + return stagehand; + } catch (error) { + try { + await browser.close(); + } catch { + throw new StagehandCodeInitializationError(); + } + throw error; + } + } + + private async activePage(): Promise { + if (!this.stagehand) return undefined; + return ( + (await this.stagehand.browser.context.activePage()) ?? + (await this.stagehand.browser.context.pages())[0] + ); + } +} + +function validate(input: CodeExecuteInput): CodeExecuteFailure | undefined { + if (!input || typeof input.code !== "string" || input.code.trim().length === 0) { + return failure("validation", "code must be a non-empty JavaScript function body."); + } + if (Buffer.byteLength(input.code) > MAX_CODE_BYTES) { + return failure("validation", `code must be at most ${MAX_CODE_BYTES} UTF-8 bytes.`); + } + return undefined; +} + +function createCodeConsole(logs: CodeLogEntry[]) { + let logBytes = 0; + const append = (level: CodeLogEntry["level"], values: unknown[]) => { + if (logBytes >= MAX_LOG_BYTES) return; + const text = formatLog(values); + const remaining = MAX_LOG_BYTES - logBytes; + const bounded = truncateUtf8(text, remaining); + if (bounded.length === 0) { + if (text.length > 0) logBytes = MAX_LOG_BYTES; + return; + } + logBytes += Buffer.byteLength(bounded); + logs.push({ level, text: bounded }); + }; + return Object.freeze({ + log: (...values: unknown[]) => append("log", values), + warn: (...values: unknown[]) => append("warn", values), + error: (...values: unknown[]) => append("error", values), + }); +} + +async function readPageState(page: Page): Promise { + const [url, title] = await Promise.all([page.url(), page.title()]); + return { url, title }; +} + +function jsonSafe(value: unknown): unknown { + if (value === undefined) return undefined; + const serialized = JSON.stringify(value, (_key, nested) => { + if (typeof nested === "bigint") return nested.toString(); + if (nested instanceof Uint8Array) { + return { + type: "bytes", + encoding: "base64", + data: Buffer.from(nested).toString("base64"), + }; + } + return nested; + }); + if (serialized === undefined) return undefined; + const bytes = Buffer.byteLength(serialized); + if (bytes <= MAX_RESULT_BYTES) return JSON.parse(serialized); + return { + truncated: true, + original_bytes: bytes, + preview: truncateUtf8(serialized, MAX_RESULT_BYTES), + }; +} + +function truncateUtf8(value: string, maxBytes: number): string { + if (maxBytes <= 0) return ""; + if (Buffer.byteLength(value) <= maxBytes) return value; + + const characters: string[] = []; + let bytes = 0; + for (const character of value) { + const characterBytes = Buffer.byteLength(character); + if (bytes + characterBytes > maxBytes) break; + characters.push(character); + bytes += characterBytes; + } + return characters.join(""); +} + +function formatLog(values: unknown[]): string { + return values + .map((value) => { + if (typeof value === "string") return value; + try { + const safe = jsonSafe(value); + return safe === undefined ? String(value) : JSON.stringify(safe); + } catch { + return "[Unserializable value]"; + } + }) + .join(" "); +} + +function normalizeError( + error: unknown, + sensitiveValues: string[], +): { name: string; message: string } { + if (!(error instanceof Error)) { + return { name: "Error", message: "Code execution failed with a non-Error value." }; + } + + const safeName = /^[A-Za-z_$][A-Za-z0-9_$.-]{0,99}$/.test(error.name) ? error.name : "Error"; + return { + name: safeName, + message: sanitizeErrorMessage(error.message, sensitiveValues), + }; +} + +function sanitizeErrorMessage(message: string, sensitiveValues: string[]): string { + let sanitized = message; + // Remove complete configured secrets before pattern redaction and final truncation. + for (const sensitiveValue of sensitiveValues) { + sanitized = sanitized.replaceAll(sensitiveValue, "[REDACTED]"); + } + sanitized = sanitized + .replace(URL, "[REDACTED_URL]") + .replace(CREDENTIAL, "$1=[REDACTED]") + .replace(BEARER_TOKEN, "Bearer [REDACTED]"); + return sanitized.slice(0, MAX_ERROR_MESSAGE_LENGTH) || "Code execution failed."; +} + +function collectSensitiveValues(value: unknown): string[] { + const values = new Set(); + const seen = new WeakMap(); + + const visit = (current: unknown, key = "", parentIsSensitive = false) => { + const isSensitive = parentIsSensitive || SECRET_FIELD.test(key); + if (typeof current === "string") { + if (isSensitive && current.length >= MIN_SENSITIVE_VALUE_LENGTH) values.add(current); + return; + } + if (!current || typeof current !== "object") return; + const previousSensitivity = seen.get(current); + if (previousSensitivity === true || (previousSensitivity === false && !isSensitive)) return; + seen.set(current, isSensitive); + for (const [nestedKey, nestedValue] of Object.entries(current)) { + visit(nestedValue, nestedKey, isSensitive); + } + }; + + visit(value); + return [...values].sort((left, right) => right.length - left.length); +} + +function failure( + kind: CodeExecuteFailure["error"]["kind"], + message: string, + name = "CodeModeError", + evidence: Pick = {}, +): CodeExecuteFailure { + return { + ok: false, + ...evidence, + error: { kind, name, message }, + }; +} diff --git a/packages/integrations/src/codemode/generated-content.ts b/packages/integrations/src/codemode/generated-content.ts new file mode 100644 index 0000000000..697ceecca0 --- /dev/null +++ b/packages/integrations/src/codemode/generated-content.ts @@ -0,0 +1,5 @@ +// Generated by scripts/generate-codemode-content.mjs. Do not edit directly. +export const STAGEHAND_CODEMODE_SKILL = + '# Stagehand V4 code-mode syntax\n\nYou have one code execution tool. Its `code` argument is the body of an async JavaScript function,\nnot a complete program. Write direct `await` statements and finish with a JSON-serializable return\nvalue. Use the tool name supplied by the host framework.\n\nThe following objects are always in scope:\n\n- `page`: the active Stagehand `Page`.\n- `context`: the Stagehand `BrowserContext` shared across calls. In host code, this is\n `stagehand.browser.context`, not `stagehand.context`.\n- `console`: captured `log`, `warn`, and `error` methods.\n\nAI-enabled surfaces also inject:\n\n- `stagehand`: the Stagehand AI methods `act`, `observe`, and `extract`.\n- `z`: Zod V4 for `stagehand.extract` schemas.\n\nIf the host labels the surface deterministic, `stagehand` and `z` are intentionally unavailable.\n\nDo not import packages, read environment variables, construct Stagehand, or close Stagehand or the\nbrowser. The tool owner manages initialization and cleanup.\n\n## Hard rules\n\n1. Use deterministic page and locator methods first when the page structure is known or inspectable.\n2. Treat the method lists below as allow-lists. Stagehand V4 is not Playwright; do not guess methods.\n3. Await `page.url()`, `page.title()`, and every `context` method.\n4. Stop when the requested evidence is complete. Do not re-fetch exact evidence with an AI method.\n5. During pagination, stop on the first repeated row signature and return deduplicated records.\n\n## Deterministic browser syntax\n\n```js\nawait page.goto("https://example.com", { waitUntil: "domcontentloaded" });\nconst heading = await page.locator("h1").innerText();\nreturn { heading, url: await page.url(), title: await page.title() };\n```\n\nSupported page methods include `goto`, `reload`, `goBack`, `goForward`, `click`, `hover`, `scroll`,\n`dragAndDrop`, `type`, `keyPress`, `evaluate`, `addInitScript`, `setExtraHTTPHeaders`,\n`setViewportSize`, `waitForLoadState`, `waitForTimeout`, `waitForSelector`, `screenshot`, `snapshot`,\n`tools`, `url`, `title`, and `locator`.\n\nSupported locator methods include `click`, `hover`, `fill`, `count`, `isChecked`, `inputValue`,\n`isVisible`, `innerText`, `innerHtml`, `textContent`, `scrollTo`, `centroid`, `highlight`,\n`sendClickEvent`, `type`, `selectOption`, `setInputFiles`, `first`, and `nth`.\n\nSupported context methods include `pages`, `newPage`, `activePage`, `setActivePage`,\n`setExtraHTTPHeaders`, `getDomainPolicy`, `setDomainPolicy`, `cookies`, `addCookies`, and\n`clearCookies`.\n\nTo submit a filled field with Enter, call `page.keyPress` with the key first. It does not take a\nselector, and V4 does not expose `page.keyboard`:\n\n```js\nawait page.locator(\'input[name="q"]\').fill("vegetarian lasagna");\nawait page.keyPress("Enter");\n```\n\nDo not use Playwright-only methods such as locator `all`, `allTextContents`, `evaluate`,\n`evaluateAll`, `filter`, `getAttribute`, `contentFrame`, or `innerHTML`; page `content`,\n`frameLocator`, `frames`, or `keyboard`; or context `waitForEvent`. Use `innerHtml` with a lowercase\n`l`.\n\nFor DOM collection reads, attributes, or custom traversal, use `page.evaluate`:\n\n```js\nconst links = await page.evaluate(() =>\n Array.from(document.querySelectorAll("a")).map((a) => ({\n text: a.textContent?.trim() ?? "",\n href: a.getAttribute("href"),\n })),\n);\nreturn links;\n```\n\n## Stagehand AI syntax\n\nStagehand V4 AI methods return `{ data, metadata }`. Read the useful result from `.data`.\n\n```js\nconst result = await stagehand.act("Click the sign-in button");\nif (!result.data.success) throw new Error(result.data.message);\nreturn result.data;\n```\n\n```js\nconst result = await stagehand.observe("Find the checkout button");\nreturn result.data;\n```\n\n```js\nconst result = await stagehand.extract(\n "Extract the product name and price",\n z.object({ name: z.string(), price: z.string() }),\n);\nreturn result.data;\n```\n\nKeep every `z.object` property required. Strict structured-output providers reject extraction\nschemas containing `.optional()`. When the page may omit a value, keep the key required and make\nits value nullable:\n\n```js\nz.object({ title: z.string(), rating: z.string().nullable() });\n```\n\nPass `{ page: anotherPage }` as the final options object when an AI method should target a page\nother than the active page.\n\n## Pages and state across calls\n\n```js\nconst before = await context.pages();\nconst beforePageIds = new Set(before.map((page) => page.pageId));\nconst current = before[before.length - 1];\nawait current.locator("button").first().click();\nawait current.waitForTimeout(500);\nconst after = await context.pages();\nconst opened = after.find((candidate) => !beforePageIds.has(candidate.pageId));\nif (!opened) throw new Error("Expected a new page");\nawait context.setActivePage(opened);\nreturn { pageCount: after.length, activeUrl: await opened.url() };\n```\n\nThere is no `context.waitForEvent`. `context.pages()` returns fresh Page wrappers, so detect a newly\nopened tab by comparing the stable `page.pageId` values before and after the click. A click does not\nnecessarily make the new tab active; call `context.setActivePage(opened)` explicitly.\n\nThe same browser, pages, cookies, and navigation state persist across successful tool calls. Local\nJavaScript variables do not persist, so rediscover pages and elements each call. If a call stops\nresponding, the owning framework should terminate and restart the tool process; the restarted\nprocess begins with a new browser.\n\n## Cross-origin iframes\n\nDo not use `page.frameLocator()` or locator `contentFrame()`. Call `page.snapshot({ includeIframes:\ntrue })`, locate the relevant accessibility reference in `formattedTree`, look up its frame-piercing\nXPath in `xpathMap`, and use the XPath with the normal locator methods:\n\n```js\nconst snapshot = await page.snapshot({ includeIframes: true });\nconst ref = "0-2"; // Discover the task-specific ref from formattedTree.\nconst xpath = snapshot.xpathMap[ref];\nif (!xpath) throw new Error(`No XPath for accessibility ref ${ref}`);\nconst field = page.locator(`xpath=${xpath}`);\nawait field.fill("value");\nreturn { value: await field.inputValue() };\n```\n\n## Efficient recovery\n\n- Batch related deterministic operations when later steps depend on earlier state.\n- If navigation or `setActivePage` times out, inspect current pages and URLs before repeating it.\n- A failed `act`, `observe`, or `extract` does not destroy the browser. Continue with deterministic\n methods when possible.\n- Pagination controls may be visually enabled without changing the rendered rows. Deduplicate rows\n and stop on the first repeated page signature.\n\n## Return discipline\n\nReturn only compact evidence needed by the agent. Prefer strings, numbers, booleans, arrays, and\nplain objects. Do not return Page, Locator, BrowserContext, Stagehand, or Zod objects. Await\nasynchronous methods before returning.\n\nFor the newest exact declarations, inspect the installed `@browserbasehq/stagehand` TypeScript\ndeclarations and this package\'s `codemode/REFERENCE.md` when filesystem access is available.'; +export const STAGEHAND_CODEMODE_REFERENCE = + '# Stagehand V4 code-mode reference\n\nThis reference describes the Stagehand objects injected into `code_execute`. `SKILL.md` contains the\nshort operational guide intended for every agent context; this file is the longer lookup reference.\n\n## Function body contract\n\nThe tool builds an async function from the submitted body. These values are injected:\n\n```ts\npage: Page;\ncontext: BrowserContext;\nstagehand: Stagehand;\nz: typeof import("zod/v4");\nconsole: Pick;\n```\n\nEval harnesses may add JSON-safe bindings such as `startUrl` and `task`. The deterministic eval arm\nintentionally omits `stagehand` and `z`.\n\n## Page\n\nNavigation and page state:\n\n```ts\npage.goto(url, { waitUntil?, timeout? }): Promise\npage.reload(options?): Promise\npage.goBack(options?): Promise\npage.goForward(options?): Promise\npage.url(): Promise\npage.title(): Promise\npage.pageId: string\npage.close(): Promise\n```\n\nInput and pointer operations:\n\n```ts\npage.click(x, y, options?): Promise\npage.hover(x, y): Promise\npage.scroll(x, y, deltaX, deltaY): Promise\npage.dragAndDrop(fromX, fromY, toX, toY, options?): Promise\npage.type(text, options?): Promise\npage.keyPress(key, options?): Promise\n```\n\nDOM and setup operations:\n\n```ts\npage.evaluate(expressionOrFunction, arg?): Promise\npage.addInitScript(script, arg?): Promise\npage.setExtraHTTPHeaders(headers): Promise\npage.setViewportSize(width, height, options?): Promise\npage.waitForLoadState(\'load\' | \'domcontentloaded\' | \'networkidle\', timeout?): Promise\npage.waitForTimeout(milliseconds): Promise\npage.waitForSelector(selector, options?): Promise\npage.screenshot(options?): Promise\npage.snapshot({ includeIframes? }): Promise<{ formattedTree; xpathMap; urlMap }>\npage.locator(selector): Locator\n```\n\n`page.evaluate` accepts a function and one serializable argument. Prefer one object when several\nvalues are needed:\n\n```js\nreturn await page.evaluate(\n ({ selector, limit }) =>\n Array.from(document.querySelectorAll(selector))\n .slice(0, limit)\n .map((node) => node.textContent?.trim() ?? ""),\n { selector: "article h2", limit: 20 },\n);\n```\n\n## Locator\n\n```ts\nlocator.click(options?): Promise\nlocator.hover(): Promise\nlocator.fill(value): Promise\nlocator.count(): Promise\nlocator.isChecked(): Promise\nlocator.inputValue(): Promise\nlocator.isVisible(): Promise\nlocator.innerText(): Promise\nlocator.innerHtml(): Promise\nlocator.textContent(): Promise\nlocator.scrollTo(percent): Promise\nlocator.centroid(): Promise<{ x: number; y: number }>\nlocator.highlight(options?): Promise\nlocator.sendClickEvent(options?): Promise\nlocator.type(text, options?): Promise\nlocator.selectOption(values): Promise\nlocator.setInputFiles(files): Promise\nlocator.first(): Locator\nlocator.nth(index): Locator\n```\n\nStagehand locators do not implement Playwright\'s collection/filter/frame helpers. Use `count` with\n`nth`, or use `page.evaluate` for bulk DOM reads.\n\n## BrowserContext\n\n```ts\ncontext.pages(): Promise\ncontext.newPage(options?): Promise\ncontext.activePage(): Promise\ncontext.setActivePage(page): Promise\ncontext.setExtraHTTPHeaders(headers): Promise\ncontext.getDomainPolicy(): Promise\ncontext.setDomainPolicy(policy: DomainPolicy | null): Promise\ncontext.cookies(urls?): Promise\ncontext.addCookies(cookies): Promise\ncontext.clearCookies(options?): Promise\n```\n\nThe tool owner closes the context and browser. Generated code should not call `context.close()`,\n`stagehand.close()`, or `stagehand.browser.close()`.\n\n## Stagehand AI methods\n\n```ts\nstagehand.act(instruction, options?): Promise<{\n data: { success; message; actionDescription; actions };\n metadata: StagehandResultMetadata;\n}>\n\nstagehand.observe(instruction?, options?): Promise<{\n data: Action[];\n metadata: StagehandResultMetadata;\n}>\n\nstagehand.extract(instruction, schema?, options?): Promise<{\n data: unknown;\n metadata: StagehandResultMetadata;\n}>\n```\n\nThe useful operation result is under `.data`. The `.metadata` object contains cache and model-usage\ninformation for that operation.\n\nTo target a non-active page:\n\n```js\nconst pages = await context.pages();\nconst result = await stagehand.extract("Extract the heading", z.object({ heading: z.string() }), {\n page: pages[1],\n});\nreturn result.data;\n```\n\n## Multiple pages\n\nPage arrays are snapshots. Await `context.pages()` again after an interaction that may open or\nclose a tab. Use `context.setActivePage(page)` when later operations should target that tab.\n\n```js\nconst before = await context.pages();\nconst beforePageIds = new Set(before.map((page) => page.pageId));\nawait before[0].locator(\'a[target="_blank"]\').first().click();\nawait before[0].waitForTimeout(500);\nconst after = await context.pages();\nconst opened = after.find((candidate) => !beforePageIds.has(candidate.pageId));\nif (!opened) throw new Error("Expected a new page");\nawait context.setActivePage(opened);\nreturn await opened.url();\n```\n\n## Pagination pattern\n\n```js\nconst records = new Map();\nconst seenPages = new Set();\n\nfor (let pageIndex = 0; pageIndex < 50; pageIndex++) {\n const rows = await page.evaluate(() =>\n Array.from(document.querySelectorAll("table tbody tr")).map((row) =>\n Array.from(row.querySelectorAll("td")).map((cell) => cell.textContent?.trim() ?? ""),\n ),\n );\n const signature = JSON.stringify(rows);\n if (seenPages.has(signature)) break;\n seenPages.add(signature);\n for (const row of rows) records.set(JSON.stringify(row), row);\n\n const next = page.locator(".paginate_button.next").first();\n if (!(await next.isVisible())) break;\n await next.click();\n await page.waitForTimeout(300);\n}\n\nreturn Array.from(records.values());\n```\n\n## Common incompatibilities\n\n| Incorrect assumption | Stagehand V4 form |\n| ------------------------------ | ----------------------------------------------------------- |\n| `page.url` is a string | `await page.url()` |\n| `page.content()` | `await page.locator(\'body\').innerHtml()` or `page.evaluate` |\n| `locator.innerHTML()` | `await locator.innerHtml()` |\n| `locator.all()` | `count()` plus `nth(index)` |\n| `locator.evaluate()` | `page.evaluate()` |\n| `page.frameLocator()` | `page.snapshot({ includeIframes: true })` plus `xpathMap` |\n| `context.waitForEvent(\'page\')` | Compare `await context.pages()` before and after |\n| `result.success` from `act` | `result.data.success` |\n| Raw array from `observe` | `result.data` |\n| Raw object from `extract` | `result.data` |\n\nWhen this reference and the installed SDK disagree, the installed `@browserbasehq/stagehand`\nTypeScript declaration files are authoritative.'; diff --git a/packages/integrations/src/codemode/index.ts b/packages/integrations/src/codemode/index.ts new file mode 100644 index 0000000000..012a4103f6 --- /dev/null +++ b/packages/integrations/src/codemode/index.ts @@ -0,0 +1,24 @@ +export { stagehandCodeConfigFromEnv } from "./config.js"; +export { StagehandCodeExecutor, type StagehandCodeExecutorOptions } from "./executor.js"; +export { connectCodeModeStdio, createCodeModeMcp, createCodeModeMcpServer } from "./mcp-server.js"; +export { executeStagehandSnippet } from "./snippet.js"; +export { STAGEHAND_CODEMODE_REFERENCE, STAGEHAND_CODEMODE_SKILL } from "./generated-content.js"; +export { + CODE_EXECUTE_DESCRIPTION, + codeExecuteResultText, + codeExecuteSchema, +} from "./tool-contract.js"; +export type { + CodeExecuteErrorKind, + CodeExecuteFailure, + CodeExecuteInput, + CodeExecuteResult, + CodeExecuteSuccess, + CodeLogEntry, + CodePageState, + ExecuteStagehandSnippetInput, + StagehandCodeBrowserConfig, + StagehandCodeConfig, + StagehandSnippetBindings, + StagehandSnippetConsole, +} from "./types.js"; diff --git a/packages/integrations/src/codemode/limits.ts b/packages/integrations/src/codemode/limits.ts new file mode 100644 index 0000000000..624fd5e789 --- /dev/null +++ b/packages/integrations/src/codemode/limits.ts @@ -0,0 +1 @@ +export const MAX_CODE_BYTES = 100_000; diff --git a/packages/integrations/src/codemode/mcp-runtime.ts b/packages/integrations/src/codemode/mcp-runtime.ts new file mode 100644 index 0000000000..89fa58ab89 --- /dev/null +++ b/packages/integrations/src/codemode/mcp-runtime.ts @@ -0,0 +1,13 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; + +export function createCodeModeMcpHost(): McpServer { + return new McpServer({ + name: "stagehand-codemode", + version: "4.0.0", + }); +} + +export async function connectCodeModeStdio(server: McpServer): Promise { + await server.connect(new StdioServerTransport()); +} diff --git a/packages/integrations/src/codemode/mcp-server.ts b/packages/integrations/src/codemode/mcp-server.ts new file mode 100644 index 0000000000..6862e8cca1 --- /dev/null +++ b/packages/integrations/src/codemode/mcp-server.ts @@ -0,0 +1,50 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StagehandCodeExecutor, type StagehandCodeExecutorOptions } from "./executor.js"; +import * as codeModeMcpRuntime from "./mcp-runtime.js"; +import { + CODE_EXECUTE_DESCRIPTION, + codeExecuteOutputSchema, + codeExecuteResultText, + codeExecuteSchema, +} from "./tool-contract.js"; +import type { CodeExecuteInput, CodeExecuteResult } from "./types.js"; + +export function createCodeModeMcpServer(executor: StagehandCodeExecutor): McpServer { + const server = codeModeMcpRuntime.createCodeModeMcpHost(); + server.registerTool( + "code_execute", + { + title: "Execute Stagehand V4 code", + description: CODE_EXECUTE_DESCRIPTION, + inputSchema: codeExecuteSchema.shape, + outputSchema: codeExecuteOutputSchema, + }, + async (input, extra) => { + const result = await executor.execute(input as CodeExecuteInput, extra.signal); + return mcpResult(result); + }, + ); + return server; +} + +export async function connectCodeModeStdio(executor: StagehandCodeExecutor): Promise { + const server = createCodeModeMcpServer(executor); + await codeModeMcpRuntime.connectCodeModeStdio(server); + return server; +} + +export function createCodeModeMcp(options: StagehandCodeExecutorOptions): { + executor: StagehandCodeExecutor; + server: McpServer; +} { + const executor = new StagehandCodeExecutor(options); + return { executor, server: createCodeModeMcpServer(executor) }; +} + +function mcpResult(result: CodeExecuteResult) { + return { + content: [{ type: "text" as const, text: codeExecuteResultText(result) }], + structuredContent: result as unknown as Record, + isError: !result.ok, + }; +} diff --git a/packages/integrations/src/codemode/snippet.ts b/packages/integrations/src/codemode/snippet.ts new file mode 100644 index 0000000000..279c0e4a22 --- /dev/null +++ b/packages/integrations/src/codemode/snippet.ts @@ -0,0 +1,48 @@ +import { z } from "zod/v4"; +import type { ExecuteStagehandSnippetInput } from "./types.js"; + +const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor as new ( + ...args: string[] +) => (...values: unknown[]) => Promise; + +const RESERVED_BINDINGS = new Set(["page", "context", "stagehand", "z", "console"]); +const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + +export async function executeStagehandSnippet( + input: ExecuteStagehandSnippetInput, +): Promise { + const bindings = Object.entries(input.bindings ?? {}); + for (const [name] of bindings) { + if (!isAsyncFunctionParameter(name)) { + throw new TypeError(`Code-mode binding "${name}" is not a valid JavaScript identifier.`); + } + if (RESERVED_BINDINGS.has(name)) { + throw new TypeError(`Code-mode binding "${name}" is reserved.`); + } + } + + const parameters: Array<[string, unknown]> = [ + ["page", input.page], + ["context", input.context], + ...(input.stagehand + ? ([ + ["stagehand", input.stagehand], + ["z", z], + ] as Array<[string, unknown]>) + : []), + ...bindings, + ["console", input.console ?? console], + ]; + const fn = new AsyncFunction(...parameters.map(([name]) => name), input.code); + return await fn(...parameters.map(([, value]) => value)); +} + +function isAsyncFunctionParameter(name: string): boolean { + if (!IDENTIFIER.test(name)) return false; + try { + new AsyncFunction(name, ""); + return true; + } catch { + return false; + } +} diff --git a/packages/integrations/src/codemode/stdio-lifecycle.ts b/packages/integrations/src/codemode/stdio-lifecycle.ts new file mode 100644 index 0000000000..b12736a647 --- /dev/null +++ b/packages/integrations/src/codemode/stdio-lifecycle.ts @@ -0,0 +1,25 @@ +export type AsyncCloser = { + close(): Promise; +}; + +export const STDIO_SHUTDOWN_GRACE_MS = 5_000; + +export async function closeCodeModeStdio( + resources: readonly AsyncCloser[], + timeoutMs = STDIO_SHUTDOWN_GRACE_MS, +): Promise { + let timeout: NodeJS.Timeout | undefined; + const cleanup = Promise.allSettled( + resources.map((resource) => Promise.resolve().then(() => resource.close())), + ).then((results) => results.every((result) => result.status === "fulfilled")); + const deadline = new Promise((resolve) => { + timeout = setTimeout(() => resolve(false), timeoutMs); + timeout.unref(); + }); + + try { + return await Promise.race([cleanup, deadline]); + } finally { + if (timeout) clearTimeout(timeout); + } +} diff --git a/packages/integrations/src/codemode/stdio-server.ts b/packages/integrations/src/codemode/stdio-server.ts new file mode 100644 index 0000000000..d673f96929 --- /dev/null +++ b/packages/integrations/src/codemode/stdio-server.ts @@ -0,0 +1,27 @@ +import { stagehandCodeConfigFromEnv } from "./config.js"; +import { StagehandCodeExecutor } from "./executor.js"; +import { createCodeModeMcpServer } from "./mcp-server.js"; +import { connectCodeModeStdio } from "./mcp-runtime.js"; +import { closeCodeModeStdio } from "./stdio-lifecycle.js"; + +const executor = new StagehandCodeExecutor(stagehandCodeConfigFromEnv()); +const server = createCodeModeMcpServer(executor); +let closing = false; + +async function shutdown(code: number): Promise { + if (closing) return; + closing = true; + const clean = await closeCodeModeStdio([server, executor]); + if (!clean) { + process.stderr.write("Failed to close Stagehand code mode cleanly.\n"); + } + process.exit(code === 0 && !clean ? 1 : code); +} + +process.once("SIGINT", () => void shutdown(130)); +process.once("SIGTERM", () => void shutdown(143)); +process.stdin.once("end", () => void shutdown(0)); +process.stdin.once("close", () => void shutdown(0)); + +await connectCodeModeStdio(server); +process.stderr.write("Stagehand code-mode MCP listening on stdio\n"); diff --git a/packages/integrations/src/codemode/tool-contract.ts b/packages/integrations/src/codemode/tool-contract.ts new file mode 100644 index 0000000000..a9ec1b7a39 --- /dev/null +++ b/packages/integrations/src/codemode/tool-contract.ts @@ -0,0 +1,75 @@ +import { z } from "zod/v4"; +import { STAGEHAND_CODEMODE_SKILL } from "./generated-content.js"; +import { MAX_CODE_BYTES } from "./limits.js"; +import type { CodeExecuteResult } from "./types.js"; + +export const CODE_EXECUTE_DESCRIPTION = [ + "Execute an async JavaScript function body against one long-lived Stagehand V4 browser.", + "The executor lazily creates a local or Browserbase browser on the first call and reuses it for later calls.", + "The executor itself is not a security sandbox. The owning framework may run it inside a sandbox or another isolation boundary.", + "If execution stops responding, the owning framework should terminate and restart the local tool process.", + "", + STAGEHAND_CODEMODE_SKILL, +].join("\n"); + +export const codeExecuteSchema = z.object({ + code: z + .string() + .refine((code) => code.trim().length > 0, "code must contain JavaScript source") + .refine( + (code) => new TextEncoder().encode(code).byteLength <= MAX_CODE_BYTES, + `code must be at most ${MAX_CODE_BYTES} UTF-8 bytes`, + ) + .describe( + "Async JavaScript function body. page, context, stagehand, z, and console are in scope.", + ), +}); + +export const codeExecuteOutputSchema = z + .object({ + ok: z.boolean(), + page: z + .object({ + url: z.string(), + title: z.string(), + }) + .optional(), + value: z.unknown().optional(), + logs: z + .array( + z.object({ + level: z.enum(["log", "warn", "error"]), + text: z.string(), + }), + ) + .optional(), + error: z + .object({ + kind: z.enum(["validation", "runtime", "aborted", "closed"]), + name: z.string(), + message: z.string(), + }) + .optional(), + }) + .superRefine((result, context) => { + if (result.ok) { + if (!result.page) { + context.addIssue({ code: "custom", message: "successful results require page state" }); + } + if (result.error) { + context.addIssue({ code: "custom", message: "successful results cannot include an error" }); + } + return; + } + + if (!result.error) { + context.addIssue({ code: "custom", message: "failed results require an error" }); + } + if (result.value !== undefined) { + context.addIssue({ code: "custom", message: "failed results cannot include a value" }); + } + }); + +export function codeExecuteResultText(result: CodeExecuteResult): string { + return JSON.stringify(result, null, 2); +} diff --git a/packages/integrations/src/codemode/types.ts b/packages/integrations/src/codemode/types.ts new file mode 100644 index 0000000000..41f7e31dc8 --- /dev/null +++ b/packages/integrations/src/codemode/types.ts @@ -0,0 +1,72 @@ +import type { + BrowserbaseLaunchOptions, + BrowserContext, + LocalBrowserLaunchOptions, + Page, + Stagehand, + StagehandClientCreateConfig, +} from "@browserbasehq/stagehand"; + +export type CodeExecuteInput = { + code: string; +}; + +export type CodePageState = { + url: string; + title: string; +}; + +export type CodeLogEntry = { + level: "log" | "warn" | "error"; + text: string; +}; + +export type CodeExecuteErrorKind = "validation" | "runtime" | "aborted" | "closed"; + +export type CodeExecuteSuccess = { + ok: true; + page: CodePageState; + value?: unknown; + logs?: CodeLogEntry[]; +}; + +export type CodeExecuteFailure = { + ok: false; + page?: CodePageState; + logs?: CodeLogEntry[]; + error: { + kind: CodeExecuteErrorKind; + name: string; + message: string; + }; +}; + +export type CodeExecuteResult = CodeExecuteSuccess | CodeExecuteFailure; + +export type StagehandSnippetConsole = Pick; + +export type StagehandSnippetBindings = Record; + +export type ExecuteStagehandSnippetInput = { + code: string; + page: Page; + context: BrowserContext; + stagehand?: Stagehand; + bindings?: StagehandSnippetBindings; + console?: StagehandSnippetConsole; +}; + +export type StagehandCodeBrowserConfig = + | { + type: "local"; + launchOptions?: LocalBrowserLaunchOptions; + } + | { + type: "browserbase"; + launchOptions: BrowserbaseLaunchOptions; + }; + +export type StagehandCodeConfig = { + browser: StagehandCodeBrowserConfig; + stagehand?: StagehandClientCreateConfig; +}; diff --git a/packages/integrations/tests/config.test.ts b/packages/integrations/tests/config.test.ts new file mode 100644 index 0000000000..dcc982bfc6 --- /dev/null +++ b/packages/integrations/tests/config.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import { stagehandCodeConfigFromEnv } from "../src/codemode/config.js"; + +describe("stagehandCodeConfigFromEnv", () => { + it("defaults to a headless local browser without Browserbase credentials", () => { + expect(stagehandCodeConfigFromEnv({})).toMatchObject({ + browser: { type: "local", launchOptions: { headless: true } }, + stagehand: { logging: { level: "off" } }, + }); + }); + + it("lets an explicit local selection win when Browserbase credentials are present", () => { + expect( + stagehandCodeConfigFromEnv({ + STAGEHAND_BROWSER: " local ", + BROWSERBASE_API_KEY: "bb_secret", + BROWSERBASE_PROJECT_ID: "project-id", + }).browser, + ).toStrictEqual({ type: "local", launchOptions: { headless: true } }); + }); + + it("forwards Browserbase API key and project ID", () => { + expect( + stagehandCodeConfigFromEnv({ + STAGEHAND_BROWSER: "browserbase", + BROWSERBASE_API_KEY: " bb_secret ", + BROWSERBASE_PROJECT_ID: " project-id ", + }).browser, + ).toStrictEqual({ + type: "browserbase", + launchOptions: { apiKey: "bb_secret", projectId: "project-id" }, + }); + }); + + it("omits a blank Browserbase project ID", () => { + expect( + stagehandCodeConfigFromEnv({ + BROWSERBASE_API_KEY: "bb_secret", + BROWSERBASE_PROJECT_ID: " ", + }).browser, + ).toStrictEqual({ + type: "browserbase", + launchOptions: { apiKey: "bb_secret" }, + }); + }); + + it("rejects invalid or unauthenticated Browserbase selections", () => { + for (const [env, message] of [ + [ + { STAGEHAND_BROWSER: "remote" }, + 'STAGEHAND_BROWSER must be either "local" or "browserbase".', + ], + [ + { STAGEHAND_BROWSER: "browserbase" }, + 'BROWSERBASE_API_KEY is required when STAGEHAND_BROWSER="browserbase".', + ], + ] as const) { + try { + stagehandCodeConfigFromEnv(env); + throw new Error("expected configuration to be rejected"); + } catch (error) { + expect(error).toMatchObject({ name: "StagehandCodeConfigError", message }); + } + } + }); + + it.each([ + ["openai/gpt-5.4-mini", "OPENAI_API_KEY", "openai-key"], + ["anthropic/claude-sonnet-4-6", "ANTHROPIC_API_KEY", "anthropic-key"], + ["google/gemini-3-flash-preview", "GEMINI_API_KEY", "google-key"], + ["groq/llama-3.3-70b-versatile", "GROQ_API_KEY", "groq-key"], + ["cerebras/llama3.1-8b", "CEREBRAS_API_KEY", "cerebras-key"], + ])("pairs an explicit %s model with its provider key", (modelName, envName, apiKey) => { + const config = stagehandCodeConfigFromEnv({ + STAGEHAND_MODEL_NAME: modelName, + [envName]: apiKey, + }); + + expect(config.stagehand?.model).toStrictEqual({ + modelName, + apiKey, + ...(modelName.startsWith("anthropic/") + ? { + headers: { + "anthropic-dangerous-direct-browser-access": "true", + }, + } + : {}), + }); + }); + + it("prefers the explicit model key over provider environment keys", () => { + const config = stagehandCodeConfigFromEnv({ + STAGEHAND_MODEL_NAME: "openai/gpt-5.4-mini", + STAGEHAND_MODEL_API_KEY: "explicit-key", + OPENAI_API_KEY: "provider-key", + }); + + expect(config.stagehand?.model).toStrictEqual({ + modelName: "openai/gpt-5.4-mini", + apiKey: "explicit-key", + }); + }); + + it("uses the eval-native Google key precedence", () => { + const config = stagehandCodeConfigFromEnv({ + STAGEHAND_MODEL_NAME: "google/gemini-3-flash-preview", + GEMINI_API_KEY: "gemini-key", + GOOGLE_GENERATIVE_AI_API_KEY: "generative-key", + GOOGLE_API_KEY: "google-key", + }); + + expect(config.stagehand?.model).toStrictEqual({ + modelName: "google/gemini-3-flash-preview", + apiKey: "generative-key", + }); + }); + + it("infers the default Google model only when no model is explicit", () => { + const config = stagehandCodeConfigFromEnv({ + GOOGLE_GENERATIVE_AI_API_KEY: "generative-key", + }); + + expect(config.stagehand?.model).toStrictEqual({ + modelName: "google/gemini-2.5-flash-lite", + apiKey: "generative-key", + }); + }); + + it("does not apply Google credentials to an explicit non-Google model", () => { + const config = stagehandCodeConfigFromEnv({ + STAGEHAND_MODEL_NAME: "anthropic/claude-sonnet-4-6", + GEMINI_API_KEY: "google-key", + }); + + expect(config.stagehand?.model).toStrictEqual({ + modelName: "anthropic/claude-sonnet-4-6", + headers: { + "anthropic-dangerous-direct-browser-access": "true", + }, + }); + }); + + it("rejects an explicit model key without a model name", () => { + try { + stagehandCodeConfigFromEnv({ STAGEHAND_MODEL_API_KEY: "orphan-key" }); + throw new Error("expected configuration to be rejected"); + } catch (error) { + expect(error).toMatchObject({ + name: "StagehandCodeConfigError", + message: "STAGEHAND_MODEL_NAME is required when STAGEHAND_MODEL_API_KEY is set.", + }); + } + }); +}); diff --git a/packages/integrations/tests/executor.test.ts b/packages/integrations/tests/executor.test.ts new file mode 100644 index 0000000000..eff7956826 --- /dev/null +++ b/packages/integrations/tests/executor.test.ts @@ -0,0 +1,434 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { StagehandCodeConfig } from "../src/codemode/types.js"; + +const sdkMocks = vi.hoisted(() => ({ + browserbaseLaunch: vi.fn(), + localLaunch: vi.fn(), + stagehandCreate: vi.fn(), +})); + +vi.mock("@browserbasehq/stagehand", () => ({ + browserbase: { launch: sdkMocks.browserbaseLaunch }, + localBrowser: { launch: sdkMocks.localLaunch }, + Stagehand: { create: sdkMocks.stagehandCreate }, +})); + +const { StagehandCodeExecutor } = await import("../src/codemode/executor.js"); + +type Deferred = { + promise: Promise; + resolve(value: T): void; + reject(error: unknown): void; +}; + +function deferred(): Deferred { + let resolve!: (value: T) => void; + let reject!: (error: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function localConfig(stagehand: Record = {}): StagehandCodeConfig { + return { + browser: { type: "local", launchOptions: { headless: true } }, + stagehand: stagehand as never, + }; +} + +function fakeRuntime() { + const page = { + url: vi.fn(async () => "https://example.com"), + title: vi.fn(async () => "Example"), + hold: vi.fn(async () => undefined), + sideEffect: vi.fn(() => "side-effect"), + }; + const context = { + activePage: vi.fn(async () => page), + pages: vi.fn(async () => [page]), + newPage: vi.fn(async () => page), + }; + const stagehand = { + browser: { context }, + close: vi.fn(async () => undefined), + metrics: vi.fn(async () => ({ act: { prompt_tokens: 1 } })), + }; + const browser = { close: vi.fn(async () => undefined) }; + return { page, context, stagehand, browser }; +} + +describe("StagehandCodeExecutor", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("validates code before launching a browser", async () => { + const executor = new StagehandCodeExecutor(localConfig()); + + await expect(executor.execute({ code: " " })).resolves.toMatchObject({ + ok: false, + error: { kind: "validation" }, + }); + await expect(executor.execute({ code: "é".repeat(50_001) })).resolves.toMatchObject({ + ok: false, + error: { kind: "validation", message: expect.stringContaining("100000 UTF-8 bytes") }, + }); + expect(sdkMocks.localLaunch).not.toHaveBeenCalled(); + }); + + it("lazily launches once, reuses state, and closes both owners once", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + await expect(executor.execute({ code: "return 1;" })).resolves.toMatchObject({ + ok: true, + value: 1, + page: { url: "https://example.com", title: "Example" }, + }); + await expect(executor.execute({ code: "return 2;" })).resolves.toMatchObject({ + ok: true, + value: 2, + }); + + expect(sdkMocks.localLaunch).toHaveBeenCalledOnce(); + expect(sdkMocks.localLaunch).toHaveBeenCalledWith({ headless: true }); + expect(sdkMocks.stagehandCreate).toHaveBeenCalledOnce(); + await executor.close(); + await executor.close(); + expect(runtime.stagehand.close).toHaveBeenCalledOnce(); + expect(runtime.browser.close).toHaveBeenCalledOnce(); + }); + + it("reports lifecycle cleanup failures without retaining underlying errors", async () => { + const runtime = fakeRuntime(); + runtime.stagehand.close.mockRejectedValue(new Error("stagehand close secret")); + runtime.browser.close.mockRejectedValue(new Error("browser close secret")); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + await executor.execute({ code: "return 1;" }); + const error = await executor.close().then( + () => undefined, + (reason: unknown) => reason, + ); + + expect(error).toMatchObject({ + name: "StagehandCodeCloseError", + message: "Failed to close Stagehand code mode.", + }); + expect(error).not.toBeInstanceOf(AggregateError); + expect(error).not.toHaveProperty("errors"); + expect(String(error)).not.toContain("secret"); + }); + + it("forwards Browserbase launch options", async () => { + const runtime = fakeRuntime(); + sdkMocks.browserbaseLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor({ + browser: { + type: "browserbase", + launchOptions: { apiKey: "bb_secret", projectId: "project-id" }, + }, + }); + + await executor.execute({ code: "return 1;" }); + + expect(sdkMocks.browserbaseLaunch).toHaveBeenCalledWith({ + apiKey: "bb_secret", + projectId: "project-id", + }); + expect(sdkMocks.localLaunch).not.toHaveBeenCalled(); + }); + + it("serializes concurrent calls in FIFO order", async () => { + const runtime = fakeRuntime(); + const gate = deferred(); + runtime.page.hold.mockImplementationOnce(() => gate.promise); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + const first = executor.execute({ code: 'await page.hold(); return "first";' }); + const second = executor.execute({ code: 'return "second";' }); + await vi.waitFor(() => expect(runtime.page.hold).toHaveBeenCalledOnce()); + expect(await Promise.race([second.then(() => "settled"), Promise.resolve("queued")])).toBe( + "queued", + ); + + gate.resolve(); + + await expect(first).resolves.toMatchObject({ ok: true, value: "first" }); + await expect(second).resolves.toMatchObject({ ok: true, value: "second" }); + }); + + it("cancels queued work before its snippet begins", async () => { + const runtime = fakeRuntime(); + const gate = deferred(); + runtime.page.hold.mockImplementationOnce(() => gate.promise); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + const controller = new AbortController(); + + const first = executor.execute({ code: "await page.hold();" }); + const second = executor.execute({ code: "return page.sideEffect();" }, controller.signal); + await vi.waitFor(() => expect(runtime.page.hold).toHaveBeenCalledOnce()); + controller.abort(); + gate.resolve(); + + await first; + await expect(second).resolves.toMatchObject({ ok: false, error: { kind: "aborted" } }); + expect(runtime.page.sideEffect).not.toHaveBeenCalled(); + }); + + it("rechecks cancellation after lazy browser initialization", async () => { + const runtime = fakeRuntime(); + const launch = deferred(); + sdkMocks.localLaunch.mockReturnValue(launch.promise); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + const controller = new AbortController(); + + const result = executor.execute({ code: "return page.sideEffect();" }, controller.signal); + await vi.waitFor(() => expect(sdkMocks.localLaunch).toHaveBeenCalledOnce()); + controller.abort(); + launch.resolve(runtime.browser); + + await expect(result).resolves.toMatchObject({ + ok: false, + page: { url: "https://example.com", title: "Example" }, + error: { kind: "aborted" }, + }); + expect(runtime.page.sideEffect).not.toHaveBeenCalled(); + }); + + it("marks queued work closed and drains before cleanup", async () => { + const runtime = fakeRuntime(); + const gate = deferred(); + runtime.page.hold.mockImplementationOnce(() => gate.promise); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + const active = executor.execute({ code: "await page.hold();" }); + const queued = executor.execute({ code: "return page.sideEffect();" }); + await vi.waitFor(() => expect(runtime.page.hold).toHaveBeenCalledOnce()); + const close = executor.close(); + gate.resolve(); + + await active; + await expect(queued).resolves.toMatchObject({ ok: false, error: { kind: "closed" } }); + await close; + expect(runtime.page.sideEffect).not.toHaveBeenCalled(); + expect(runtime.stagehand.close).toHaveBeenCalledOnce(); + expect(runtime.browser.close).toHaveBeenCalledOnce(); + await expect(executor.execute({ code: "return 1;" })).resolves.toMatchObject({ + ok: false, + error: { kind: "closed" }, + }); + }); + + it("queues metrics behind execution", async () => { + const runtime = fakeRuntime(); + const gate = deferred(); + runtime.page.hold.mockImplementationOnce(() => gate.promise); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + const active = executor.execute({ code: "await page.hold();" }); + const metrics = executor.metrics(); + await vi.waitFor(() => expect(runtime.page.hold).toHaveBeenCalledOnce()); + expect(runtime.stagehand.metrics).not.toHaveBeenCalled(); + gate.resolve(); + + await active; + await expect(metrics).resolves.toStrictEqual({ act: { prompt_tokens: 1 } }); + }); + + it("closes a launched browser when Stagehand initialization fails", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockRejectedValue(new Error("initialization failed")); + const executor = new StagehandCodeExecutor(localConfig()); + + await expect(executor.execute({ code: "return 1;" })).resolves.toMatchObject({ + ok: false, + error: { kind: "runtime", message: "initialization failed" }, + }); + expect(runtime.browser.close).toHaveBeenCalledOnce(); + }); + + it("reports a generic aggregate when initialization and cleanup both fail", async () => { + const runtime = fakeRuntime(); + runtime.browser.close.mockRejectedValue(new Error("browser close secret")); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockRejectedValue(new Error("init secret")); + const executor = new StagehandCodeExecutor(localConfig()); + + await expect(executor.execute({ code: "return 1;" })).resolves.toMatchObject({ + ok: false, + error: { + kind: "runtime", + name: "StagehandCodeInitializationError", + message: "Stagehand code mode initialization and browser cleanup both failed.", + }, + }); + }); + + it("normalizes JSON values and bounds returned output", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + await expect( + executor.execute({ code: "return { big: 12n, bytes: new Uint8Array([1, 2, 3]) };" }), + ).resolves.toMatchObject({ + ok: true, + value: { + big: "12", + bytes: { type: "bytes", encoding: "base64", data: "AQID" }, + }, + }); + const large = await executor.execute({ code: 'return "x".repeat(300000);' }); + expect(large).toMatchObject({ + ok: true, + value: { truncated: true, original_bytes: 300_002 }, + }); + if (large.ok && typeof large.value === "object" && large.value) { + expect( + Buffer.byteLength(String((large.value as { preview: string }).preview)), + ).toBeLessThanOrEqual(256 * 1024); + } + + const multibyte = await executor.execute({ code: 'return "é".repeat(200000);' }); + expect(multibyte).toMatchObject({ ok: true, value: { truncated: true } }); + if (multibyte.ok && typeof multibyte.value === "object" && multibyte.value) { + const preview = String((multibyte.value as { preview: string }).preview); + expect(Buffer.byteLength(preview)).toBeLessThanOrEqual(256 * 1024); + expect(preview).not.toContain("�"); + } + }); + + it("truncates captured logs on UTF-8 character boundaries", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + const result = await executor.execute({ + code: 'console.log("a" + "é".repeat(40000)); return "ok";', + }); + + expect(result).toMatchObject({ ok: true, value: "ok" }); + if (result.ok && result.logs) { + expect(Buffer.byteLength(result.logs[0].text)).toBeLessThanOrEqual(64 * 1024); + expect(result.logs[0].text).not.toContain("�"); + } + }); + + it("stops capturing logs when the remaining byte cannot hold a UTF-8 character", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + const result = await executor.execute({ + code: ` + console.log("a".repeat(65535)); + for (let index = 0; index < 100; index += 1) console.log("é"); + return "ok"; + `, + }); + + expect(result).toMatchObject({ ok: true, value: "ok" }); + if (result.ok && result.logs) { + expect(result.logs).toHaveLength(1); + expect(Buffer.byteLength(result.logs[0].text)).toBe(65_535); + expect(result.logs.every((entry) => entry.text.length > 0)).toBe(true); + } + }); + + it("captures circular console values without changing snippet success", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + await expect( + executor.execute({ + code: 'const circular = {}; circular.self = circular; console.log(circular); return "ok";', + }), + ).resolves.toMatchObject({ + ok: true, + value: "ok", + logs: [{ level: "log", text: "[Unserializable value]" }], + }); + }); + + it("redacts nested and shared secrets while preserving short ordinary text", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const shared = { value: "shared-secret-value" }; + const circular: Record = { value: "circular-secret-value" }; + circular.self = circular; + const executor = new StagehandCodeExecutor( + localConfig({ + publicCopy: shared, + tokens: { shared, circular }, + cookies: { nested: { value: "nested-secret-value" } }, + apiKey: "short", + }), + ); + + const result = await executor.execute({ + code: ` + throw new Error( + "shared-secret-value nested-secret-value circular-secret-value short ordinary " + + "token=short Bearer bearer-value https://example.com/private" + ); + `, + }); + + expect(result).toMatchObject({ ok: false, error: { kind: "runtime" } }); + if (result.ok === false) { + expect(result.error.message).toContain("[REDACTED]"); + expect(result.error.message).toContain("short ordinary"); + expect(result.error.message).toContain("token=[REDACTED]"); + expect(result.error.message).toContain("Bearer [REDACTED]"); + expect(result.error.message).toContain("[REDACTED_URL]"); + expect(result.error.message).not.toContain("shared-secret-value"); + expect(result.error.message).not.toContain("nested-secret-value"); + expect(result.error.message).not.toContain("circular-secret-value"); + expect(result.error.message).not.toContain("bearer-value"); + } + }); + + it("normalizes non-Error throws and invalid error names", async () => { + const runtime = fakeRuntime(); + sdkMocks.localLaunch.mockResolvedValue(runtime.browser); + sdkMocks.stagehandCreate.mockResolvedValue(runtime.stagehand); + const executor = new StagehandCodeExecutor(localConfig()); + + await expect(executor.execute({ code: 'throw "raw secret";' })).resolves.toMatchObject({ + ok: false, + error: { name: "Error", message: "Code execution failed with a non-Error value." }, + }); + await expect( + executor.execute({ + code: 'const error = new Error("failed"); error.name = "bad name"; throw error;', + }), + ).resolves.toMatchObject({ + ok: false, + error: { name: "Error", message: "failed" }, + }); + }); +}); diff --git a/packages/integrations/tests/generated-content.test.ts b/packages/integrations/tests/generated-content.test.ts new file mode 100644 index 0000000000..e6ac6d2239 --- /dev/null +++ b/packages/integrations/tests/generated-content.test.ts @@ -0,0 +1,113 @@ +import { execFile } from "node:child_process"; +import { copyFile, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { afterEach, describe, expect, it } from "vitest"; +import { + STAGEHAND_CODEMODE_REFERENCE, + STAGEHAND_CODEMODE_SKILL, +} from "../src/codemode/generated-content.js"; +import { CODE_EXECUTE_DESCRIPTION } from "../src/codemode/tool-contract.js"; + +const execFileAsync = promisify(execFile); +const packageRoot = new URL("..", import.meta.url); +const temporaryRoots: string[] = []; + +describe("generated code-mode content", () => { + afterEach(async () => { + await Promise.all( + temporaryRoots.splice(0).map((root) => rm(root, { force: true, recursive: true })), + ); + }); + + it("matches the committed Markdown assets", async () => { + const [skill, reference] = await Promise.all([ + readFile(new URL("codemode/SKILL.md", packageRoot), "utf8"), + readFile(new URL("codemode/REFERENCE.md", packageRoot), "utf8"), + ]); + + expect(STAGEHAND_CODEMODE_SKILL).toBe(skill.trim()); + expect(STAGEHAND_CODEMODE_REFERENCE).toBe(reference.trim()); + }); + + it("resolves the raw Markdown assets through package exports", async () => { + const [skill, reference] = await Promise.all([ + readFile( + new URL(import.meta.resolve("@browserbasehq/stagehand-integrations/codemode/SKILL.md")), + "utf8", + ), + readFile( + new URL(import.meta.resolve("@browserbasehq/stagehand-integrations/codemode/REFERENCE.md")), + "utf8", + ), + ]); + + expect(skill.trim()).toBe(STAGEHAND_CODEMODE_SKILL); + expect(reference.trim()).toBe(STAGEHAND_CODEMODE_REFERENCE); + }); + + it("embeds the complete generated skill in the tool description", () => { + expect(CODE_EXECUTE_DESCRIPTION).toContain("Execute an async JavaScript function body"); + expect(CODE_EXECUTE_DESCRIPTION.endsWith(STAGEHAND_CODEMODE_SKILL)).toBe(true); + }); + + it("passes the committed stale-content check", async () => { + await expect( + execFileAsync(process.execPath, ["scripts/generate-codemode-content.mjs", "--check"], { + cwd: packageRoot, + }), + ).resolves.toMatchObject({ stdout: "Generated code-mode content is current\n" }); + }); + + it("escapes special characters and rejects stale output", async () => { + const fixtureSkill = "slash\\ quote' carriage\rreturn\nline\ttab\u2028separator\u2029paragraph"; + const fixtureReference = "reference"; + const fixtureRoot = await mkdtemp(path.join(tmpdir(), "stagehand-codemode-generator-")); + temporaryRoots.push(fixtureRoot); + await Promise.all([ + mkdir(path.join(fixtureRoot, "scripts"), { recursive: true }), + mkdir(path.join(fixtureRoot, "codemode"), { recursive: true }), + mkdir(path.join(fixtureRoot, "src", "codemode"), { recursive: true }), + ]); + await copyFile( + new URL("scripts/generate-codemode-content.mjs", packageRoot), + path.join(fixtureRoot, "scripts", "generate-codemode-content.mjs"), + ); + await writeFile(path.join(fixtureRoot, "codemode", "SKILL.md"), fixtureSkill); + await writeFile(path.join(fixtureRoot, "codemode", "REFERENCE.md"), fixtureReference); + + await execFileAsync(process.execPath, ["scripts/generate-codemode-content.mjs"], { + cwd: fixtureRoot, + }); + const generatedPath = path.join(fixtureRoot, "src", "codemode", "generated-content.ts"); + const generated = await readFile(generatedPath, "utf8"); + expect(generated).toContain("slash\\\\"); + expect(generated).toContain("quote\\'"); + expect(generated).toContain("carriage\\rreturn\\nline\\ttab"); + expect(generated).toContain("\\u2028separator\\u2029paragraph"); + + const generatedUrl = pathToFileURL(generatedPath); + generatedUrl.searchParams.set("test", String(Date.now())); + const roundTrip = (await import(generatedUrl.href)) as { + STAGEHAND_CODEMODE_REFERENCE: string; + STAGEHAND_CODEMODE_SKILL: string; + }; + expect(roundTrip.STAGEHAND_CODEMODE_SKILL).toBe(fixtureSkill); + expect(roundTrip.STAGEHAND_CODEMODE_REFERENCE).toBe(fixtureReference); + + await execFileAsync(process.execPath, ["scripts/generate-codemode-content.mjs", "--check"], { + cwd: fixtureRoot, + }); + await writeFile(path.join(fixtureRoot, "codemode", "SKILL.md"), "changed"); + + await expect( + execFileAsync(process.execPath, ["scripts/generate-codemode-content.mjs", "--check"], { + cwd: fixtureRoot, + }), + ).rejects.toMatchObject({ + stderr: expect.stringContaining("Generated code-mode content is stale"), + }); + }); +}); diff --git a/packages/integrations/tests/mcp-runtime.test.ts b/packages/integrations/tests/mcp-runtime.test.ts new file mode 100644 index 0000000000..dcfa098c3f --- /dev/null +++ b/packages/integrations/tests/mcp-runtime.test.ts @@ -0,0 +1,26 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { createCodeModeMcpHost } from "../src/codemode/mcp-runtime.js"; + +describe("code-mode MCP host", () => { + let client: Client; + let server: ReturnType; + + beforeEach(async () => { + server = createCodeModeMcpHost(); + client = new Client({ name: "stagehand-codemode-host-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + await server.close(); + }); + + it("initializes without advertising the tools capability", () => { + expect(client.getServerCapabilities()).not.toHaveProperty("tools"); + }); +}); diff --git a/packages/integrations/tests/mcp-server.test.ts b/packages/integrations/tests/mcp-server.test.ts new file mode 100644 index 0000000000..3ec4fcb6ad --- /dev/null +++ b/packages/integrations/tests/mcp-server.test.ts @@ -0,0 +1,101 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { StagehandCodeExecutor } from "../src/codemode/executor.js"; +import { createCodeModeMcpServer } from "../src/codemode/mcp-server.js"; +import type { CodeExecuteResult } from "../src/codemode/types.js"; + +describe("code-mode MCP server", () => { + let client: Client; + let server: ReturnType; + let execute: ReturnType; + + beforeEach(async () => { + execute = vi.fn(); + server = createCodeModeMcpServer({ execute } as unknown as StagehandCodeExecutor); + client = new Client({ name: "stagehand-codemode-test", version: "1.0.0" }); + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + await server.connect(serverTransport); + await client.connect(clientTransport); + }); + + afterEach(async () => { + await client.close(); + await server.close(); + }); + + it("advertises exactly one tool with complete input and output schemas", async () => { + const tools = await client.listTools(); + + expect(tools.tools).toHaveLength(1); + expect(tools.tools[0]).toMatchObject({ + name: "code_execute", + inputSchema: { + type: "object", + required: ["code"], + properties: { code: { type: "string" } }, + }, + outputSchema: { + type: "object", + required: ["ok"], + properties: { + ok: {}, + page: {}, + value: {}, + logs: {}, + error: {}, + }, + }, + }); + }); + + it("rejects invalid input before invoking the executor", async () => { + const response = await client.callTool({ + name: "code_execute", + arguments: { code: " " }, + }); + + expect(response).toMatchObject({ + isError: true, + content: [ + { + type: "text", + text: expect.stringContaining("code must contain JavaScript source"), + }, + ], + }); + expect(execute).not.toHaveBeenCalled(); + }); + + it.each([ + { + result: { + ok: true, + page: { url: "https://example.com", title: "Example" }, + value: { answer: 42 }, + } satisfies CodeExecuteResult, + isError: false, + }, + { + result: { + ok: false, + error: { kind: "runtime", name: "Error", message: "failed" }, + } satisfies CodeExecuteResult, + isError: true, + }, + ])("returns structured and text results for ok=$result.ok", async ({ result, isError }) => { + execute.mockResolvedValueOnce(result); + + const response = await client.callTool({ + name: "code_execute", + arguments: { code: "return 42;" }, + }); + + expect(response.structuredContent).toStrictEqual(result); + expect(response.isError).toBe(isError); + expect(response.content).toStrictEqual([ + { type: "text", text: JSON.stringify(result, null, 2) }, + ]); + expect(execute).toHaveBeenCalledWith({ code: "return 42;" }, expect.any(AbortSignal)); + }); +}); diff --git a/packages/integrations/tests/snippet.test.ts b/packages/integrations/tests/snippet.test.ts new file mode 100644 index 0000000000..462f81ade9 --- /dev/null +++ b/packages/integrations/tests/snippet.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it, vi } from "vitest"; +import { executeStagehandSnippet } from "../src/codemode/snippet.js"; + +const page = { marker: "page" }; +const context = { marker: "context" }; +const stagehand = { marker: "stagehand" }; + +describe("executeStagehandSnippet", () => { + it("injects browser, Stagehand, Zod, custom bindings, and console", async () => { + const codeConsole = { log: vi.fn(), warn: vi.fn(), error: vi.fn() }; + + const result = await executeStagehandSnippet({ + code: ` + console.log(label); + return { + page: page.marker, + context: context.marker, + stagehand: stagehand.marker, + parsed: z.object({ value: z.number() }).parse({ value: count }).value, + }; + `, + page: page as never, + context: context as never, + stagehand: stagehand as never, + bindings: { label: "ready", count: 3 }, + console: codeConsole, + }); + + expect(result).toStrictEqual({ + page: "page", + context: "context", + stagehand: "stagehand", + parsed: 3, + }); + expect(codeConsole.log).toHaveBeenCalledWith("ready"); + }); + + it("omits Stagehand and Zod in deterministic mode", async () => { + await expect( + executeStagehandSnippet({ + code: "return { stagehand: typeof stagehand, z: typeof z };", + page: page as never, + context: context as never, + }), + ).resolves.toStrictEqual({ stagehand: "undefined", z: "undefined" }); + }); + + it("awaits asynchronous code and propagates runtime errors", async () => { + await expect( + executeStagehandSnippet({ + code: "return await Promise.resolve(42);", + page: page as never, + context: context as never, + }), + ).resolves.toBe(42); + + await expect( + executeStagehandSnippet({ + code: 'throw new Error("snippet failed");', + page: page as never, + context: context as never, + }), + ).rejects.toThrow("snippet failed"); + }); + + it.each(["bad-name", "await", "class"])("rejects invalid binding name %s", async (name) => { + await expect( + executeStagehandSnippet({ + code: "return 1;", + page: page as never, + context: context as never, + bindings: { [name]: true }, + }), + ).rejects.toThrow(`Code-mode binding "${name}" is not a valid JavaScript identifier.`); + }); + + it.each(["page", "context", "stagehand", "z", "console"])( + "rejects reserved binding name %s", + async (name) => { + await expect( + executeStagehandSnippet({ + code: "return 1;", + page: page as never, + context: context as never, + bindings: { [name]: true }, + }), + ).rejects.toThrow(`Code-mode binding "${name}" is reserved.`); + }, + ); + + it("does not persist local variables between calls", async () => { + await executeStagehandSnippet({ + code: "const localOnly = 1; return localOnly;", + page: page as never, + context: context as never, + }); + + await expect( + executeStagehandSnippet({ + code: "return localOnly;", + page: page as never, + context: context as never, + }), + ).rejects.toThrow("localOnly is not defined"); + }); +}); diff --git a/packages/integrations/tests/stdio-lifecycle.test.ts b/packages/integrations/tests/stdio-lifecycle.test.ts new file mode 100644 index 0000000000..5eac6dfe5e --- /dev/null +++ b/packages/integrations/tests/stdio-lifecycle.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { closeCodeModeStdio } from "../src/codemode/stdio-lifecycle.js"; + +describe("closeCodeModeStdio", () => { + afterEach(() => vi.useRealTimers()); + + it("closes every resource concurrently", async () => { + const first = { close: vi.fn(async () => undefined) }; + const second = { close: vi.fn(async () => undefined) }; + + await expect(closeCodeModeStdio([first, second], 50)).resolves.toBe(true); + expect(first.close).toHaveBeenCalledOnce(); + expect(second.close).toHaveBeenCalledOnce(); + }); + + it("reports cleanup failures without exposing their messages", async () => { + const healthy = { close: vi.fn(async () => undefined) }; + const failing = { close: vi.fn(async () => Promise.reject(new Error("secret detail"))) }; + + await expect(closeCodeModeStdio([healthy, failing], 50)).resolves.toBe(false); + }); + + it("contains synchronous cleanup failures", async () => { + const failing = { + close: vi.fn(() => { + throw new Error("secret detail"); + }), + }; + + await expect(closeCodeModeStdio([failing], 50)).resolves.toBe(false); + }); + + it("bounds cleanup when a resource never settles", async () => { + vi.useFakeTimers(); + const stuck = { close: vi.fn(() => new Promise(() => undefined)) }; + const result = closeCodeModeStdio([stuck], 5_000); + + await vi.advanceTimersByTimeAsync(5_000); + + await expect(result).resolves.toBe(false); + }); +}); diff --git a/packages/integrations/tests/stdio-server.test.ts b/packages/integrations/tests/stdio-server.test.ts new file mode 100644 index 0000000000..ce9bac7245 --- /dev/null +++ b/packages/integrations/tests/stdio-server.test.ts @@ -0,0 +1,192 @@ +import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process"; +import { PassThrough, type Stream } from "node:stream"; +import { fileURLToPath } from "node:url"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"; +import { describe, expect, it } from "vitest"; + +const entrypoint = fileURLToPath(new URL("../dist/codemode/stdio-server.mjs", import.meta.url)); +const baseEnv = { + PATH: process.env.PATH ?? "", + STAGEHAND_BROWSER: "local", +}; +const readyMessage = "Stagehand code-mode MCP listening on stdio"; + +function startServer(env: NodeJS.ProcessEnv = baseEnv): ChildProcessWithoutNullStreams { + return spawn(process.execPath, [entrypoint], { + env, + stdio: ["pipe", "pipe", "pipe"], + }); +} + +async function waitForReady(child: ChildProcessWithoutNullStreams): Promise { + let stderr = ""; + return await new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`stdio server did not start: ${stderr}`)), + 10_000, + ); + const onData = (chunk: Buffer) => { + stderr += chunk.toString(); + if (!stderr.includes(readyMessage)) return; + clearTimeout(timeout); + child.stderr.off("data", onData); + resolve(stderr); + }; + child.stderr.on("data", onData); + child.once("close", (code, signal) => { + clearTimeout(timeout); + reject( + new Error(`stdio server exited before ready (code=${code}, signal=${signal}): ${stderr}`), + ); + }); + }); +} + +function waitForOutput(stream: Stream, expected: string): Promise { + let output = ""; + return new Promise((resolve, reject) => { + const cleanup = () => { + clearTimeout(timeout); + stream.off("data", onData); + stream.off("error", onError); + stream.off("end", onEnd); + stream.off("close", onClose); + }; + const succeed = () => { + cleanup(); + resolve(output); + }; + const fail = (message: string) => { + cleanup(); + reject(new Error(message)); + }; + const onData = (chunk: Buffer) => { + output += chunk.toString(); + if (output.includes(expected)) succeed(); + }; + const onError = () => fail(`stdio output stream failed before ${JSON.stringify(expected)}`); + const onEnd = () => fail(`stdio output stream ended before ${JSON.stringify(expected)}`); + const onClose = () => fail(`stdio output stream closed before ${JSON.stringify(expected)}`); + const timeout = setTimeout( + () => fail(`stdio host did not emit ${JSON.stringify(expected)}: ${output}`), + 10_000, + ); + stream.on("data", onData); + stream.once("error", onError); + stream.once("end", onEnd); + stream.once("close", onClose); + }); +} + +function waitForExit( + child: ChildProcessWithoutNullStreams, +): Promise<{ code: number | null; signal: NodeJS.Signals | null }> { + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + child.kill("SIGKILL"); + reject(new Error("stdio server did not exit within 10 seconds")); + }, 10_000); + child.once("error", reject); + child.once("close", (code, signal) => { + clearTimeout(timeout); + resolve({ code, signal }); + }); + }); +} + +describe("built code-mode stdio server", () => { + it("cleans up output waiters when the stream closes before the expected output", async () => { + const stream = new PassThrough(); + const output = waitForOutput(stream, readyMessage); + + stream.destroy(); + + await expect(output).rejects.toThrow(`closed before ${JSON.stringify(readyMessage)}`); + expect(stream.listenerCount("data")).toBe(0); + expect(stream.listenerCount("error")).toBe(0); + expect(stream.listenerCount("end")).toBe(0); + expect(stream.listenerCount("close")).toBe(0); + }); + + it("starts in explicit local mode and exits successfully on stdin EOF", async () => { + const child = startServer({ + ...baseEnv, + BROWSERBASE_API_KEY: "unused-browserbase-key", + BROWSERBASE_PROJECT_ID: "unused-project-id", + }); + try { + await waitForReady(child); + const exit = waitForExit(child); + child.stdin.end(); + await expect(exit).resolves.toStrictEqual({ code: 0, signal: null }); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } + }); + + it.skipIf(process.platform === "win32")( + "preserves SIGINT and SIGTERM exit semantics", + async () => { + for (const [signal, expectedCode] of [ + ["SIGINT", 130], + ["SIGTERM", 143], + ] as const) { + const child = startServer(); + try { + await waitForReady(child); + const exit = waitForExit(child); + child.kill(signal); + await expect(exit).resolves.toStrictEqual({ code: expectedCode, signal: null }); + } finally { + if (child.exitCode === null && child.signalCode === null) child.kill("SIGKILL"); + } + } + }, + 30_000, + ); + + it("fails startup for an invalid browser mode", async () => { + const child = startServer({ ...baseEnv, STAGEHAND_BROWSER: "remote" }); + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + const exit = await waitForExit(child); + + expect(exit.code).not.toBe(0); + expect(stderr).toContain('STAGEHAND_BROWSER must be either "local" or "browserbase".'); + }); + + it("supports MCP initialization, discovery, and validation through the compiled child", async () => { + const transport = new StdioClientTransport({ + command: process.execPath, + args: [entrypoint], + env: baseEnv, + stderr: "pipe", + }); + if (!transport.stderr) throw new Error("stdio transport did not expose stderr"); + const ready = waitForOutput(transport.stderr, readyMessage); + const client = new Client({ name: "stagehand-codemode-stdio-test", version: "1.0.0" }); + + try { + await Promise.all([client.connect(transport), ready]); + const tools = await client.listTools(); + expect(tools.tools.map((tool) => tool.name)).toStrictEqual(["code_execute"]); + await expect( + client.callTool({ name: "code_execute", arguments: { code: " " } }), + ).resolves.toMatchObject({ + isError: true, + content: [ + { + type: "text", + text: expect.stringContaining("code must contain JavaScript source"), + }, + ], + }); + } finally { + await client.close(); + } + }); +}); diff --git a/packages/integrations/tests/tool-contract.test.ts b/packages/integrations/tests/tool-contract.test.ts new file mode 100644 index 0000000000..b512d6ee9c --- /dev/null +++ b/packages/integrations/tests/tool-contract.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { + codeExecuteOutputSchema, + codeExecuteResultText, + codeExecuteSchema, +} from "../src/codemode/tool-contract.js"; + +describe("code-mode tool contract", () => { + it("accepts nonblank code up to 100,000 UTF-8 bytes", () => { + expect(codeExecuteSchema.parse({ code: "return 1;" })).toStrictEqual({ code: "return 1;" }); + expect(codeExecuteSchema.safeParse({ code: " \n\t " }).success).toBe(false); + expect(codeExecuteSchema.safeParse({ code: "é".repeat(50_000) }).success).toBe(true); + expect(codeExecuteSchema.safeParse({ code: `${"é".repeat(50_000)}a` }).success).toBe(false); + }); + + it("validates complete success and failure results", () => { + expect( + codeExecuteOutputSchema.parse({ + ok: true, + page: { url: "https://example.com", title: "Example" }, + value: { answer: 42 }, + logs: [{ level: "log", text: "ready" }], + }), + ).toMatchObject({ ok: true, value: { answer: 42 } }); + + expect( + codeExecuteOutputSchema.parse({ + ok: false, + error: { kind: "runtime", name: "Error", message: "failed" }, + }), + ).toMatchObject({ ok: false, error: { kind: "runtime" } }); + }); + + it("rejects invalid success/failure combinations", () => { + expect(codeExecuteOutputSchema.safeParse({ ok: true }).success).toBe(false); + expect( + codeExecuteOutputSchema.safeParse({ + ok: true, + page: { url: "https://example.com", title: "Example" }, + error: { kind: "runtime", name: "Error", message: "failed" }, + }).success, + ).toBe(false); + expect(codeExecuteOutputSchema.safeParse({ ok: false }).success).toBe(false); + expect( + codeExecuteOutputSchema.safeParse({ + ok: false, + value: 42, + error: { kind: "runtime", name: "Error", message: "failed" }, + }).success, + ).toBe(false); + }); + + it("rejects unknown error kinds and log levels", () => { + expect( + codeExecuteOutputSchema.safeParse({ + ok: false, + error: { kind: "timeout", name: "Error", message: "failed" }, + }).success, + ).toBe(false); + expect( + codeExecuteOutputSchema.safeParse({ + ok: true, + page: { url: "https://example.com", title: "Example" }, + logs: [{ level: "debug", text: "nope" }], + }).success, + ).toBe(false); + }); + + it("renders the result as stable pretty JSON", () => { + expect( + codeExecuteResultText({ + ok: true, + page: { url: "https://example.com", title: "Example" }, + value: 42, + }), + ).toBe( + '{\n "ok": true,\n "page": {\n "url": "https://example.com",\n "title": "Example"\n },\n "value": 42\n}', + ); + }); +}); diff --git a/packages/integrations/tsconfig.json b/packages/integrations/tsconfig.json new file mode 100644 index 0000000000..36876ff6db --- /dev/null +++ b/packages/integrations/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "target": "ES2022", + "types": ["node"], + "rootDir": ".", + "noEmit": true + }, + "include": ["src/**/*.ts", "tests/**/*.ts"], + "exclude": ["dist", "node_modules"] +} diff --git a/packages/integrations/tsdown.config.ts b/packages/integrations/tsdown.config.ts new file mode 100644 index 0000000000..c203574ed1 --- /dev/null +++ b/packages/integrations/tsdown.config.ts @@ -0,0 +1,16 @@ +import { defineConfig } from "tsdown"; + +export default defineConfig({ + entry: { + "codemode/index": "src/codemode/index.ts", + "codemode/stdio-server": "src/codemode/stdio-server.ts", + }, + format: ["esm"], + platform: "node", + target: "node22", + dts: { + sourcemap: true, + }, + sourcemap: true, + outDir: "dist", +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b461760f35..4e014c0158 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -244,6 +244,9 @@ catalogs: '@mdx-js/mdx': specifier: 3.1.1 version: 3.1.1 + '@modelcontextprotocol/sdk': + specifier: 1.29.0 + version: 1.29.0 '@opentelemetry/api': specifier: 1.9.1 version: 1.9.1 @@ -491,72 +494,6 @@ importers: specifier: 'catalog:' version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0)) - packages/protocol: - dependencies: - camelcase-keys: - specifier: 'catalog:' - version: 10.0.2 - snakecase-keys: - specifier: 'catalog:' - version: 9.0.2 - zod: - specifier: 'catalog:' - version: 4.4.3 - devDependencies: - '@types/chrome': - specifier: 'catalog:' - version: 0.2.2 - '@types/node': - specifier: 'catalog:' - version: 24.13.2 - chrome-launcher: - specifier: 'catalog:' - version: 1.2.1 - oxfmt: - specifier: 'catalog:' - version: 0.57.0 - typescript: - specifier: 'catalog:' - version: 5.9.3 - vite: - specifier: 8.1.3 - version: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0) - vitest: - specifier: 'catalog:' - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0)) - - packages/sdk-go: {} - - packages/sdk-python: {} - - packages/sdk-ts: - dependencies: - '@browserbasehq/sdk': - specifier: 'catalog:' - version: 2.16.0 - '@opentelemetry/api': - specifier: 'catalog:' - version: 1.9.1 - '@opentelemetry/core': - specifier: 'catalog:' - version: 2.9.0(@opentelemetry/api@1.9.1) - chrome-launcher: - specifier: 'catalog:' - version: 1.2.1 - zod: - specifier: 'catalog:' - version: 4.4.3 - devDependencies: - publint: - specifier: 'catalog:' - version: 0.3.21 - tsdown: - specifier: 'catalog:' - version: 0.22.3(publint@0.3.21)(tsx@4.23.1)(typescript@5.9.3) - vitest: - specifier: 'catalog:' - version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0)) - packages/extension: dependencies: '@ai-sdk/anthropic': @@ -627,6 +564,97 @@ importers: specifier: 'catalog:' version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0)) + packages/integrations: + dependencies: + '@browserbasehq/stagehand': + specifier: workspace:* + version: link:../sdk-ts + '@modelcontextprotocol/sdk': + specifier: 'catalog:' + version: 1.29.0(zod@4.4.3) + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@types/node': + specifier: 'catalog:' + version: 24.13.2 + tsdown: + specifier: 'catalog:' + version: 0.22.3(publint@0.3.21)(tsx@4.23.1)(typescript@5.9.3) + typescript: + specifier: 'catalog:' + version: 5.9.3 + vitest: + specifier: 'catalog:' + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0)) + + packages/protocol: + dependencies: + camelcase-keys: + specifier: 'catalog:' + version: 10.0.2 + snakecase-keys: + specifier: 'catalog:' + version: 9.0.2 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + '@types/chrome': + specifier: 'catalog:' + version: 0.2.2 + '@types/node': + specifier: 'catalog:' + version: 24.13.2 + chrome-launcher: + specifier: 'catalog:' + version: 1.2.1 + oxfmt: + specifier: 'catalog:' + version: 0.57.0 + typescript: + specifier: 'catalog:' + version: 5.9.3 + vite: + specifier: 8.1.3 + version: 8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0) + vitest: + specifier: 'catalog:' + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.13.2)(vite@8.1.3(@types/node@24.13.2)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0)) + + packages/sdk-go: {} + + packages/sdk-python: {} + + packages/sdk-ts: + dependencies: + '@browserbasehq/sdk': + specifier: 'catalog:' + version: 2.16.0 + '@opentelemetry/api': + specifier: 'catalog:' + version: 1.9.1 + '@opentelemetry/core': + specifier: 'catalog:' + version: 2.9.0(@opentelemetry/api@1.9.1) + chrome-launcher: + specifier: 'catalog:' + version: 1.2.1 + zod: + specifier: 'catalog:' + version: 4.4.3 + devDependencies: + publint: + specifier: 'catalog:' + version: 0.3.21 + tsdown: + specifier: 'catalog:' + version: 0.22.3(publint@0.3.21)(tsx@4.23.1)(typescript@5.9.3) + vitest: + specifier: 'catalog:' + version: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@25.9.4)(vite@8.1.3(@types/node@25.9.4)(esbuild@0.28.1)(jiti@1.21.7)(tsx@4.23.1)(yaml@2.9.0)) + packages: '@ai-sdk/amazon-bedrock@3.0.111': @@ -8976,8 +9004,8 @@ snapshots: cross-spawn: 7.0.6 eventsource: 3.0.7 eventsource-parser: 3.1.0 - express: 5.2.1(supports-color@8.1.1) - express-rate-limit: 8.6.0(express@5.2.1(supports-color@8.1.1)) + express: 5.2.1 + express-rate-limit: 8.6.0(express@5.2.1) hono: 4.12.32 jose: 6.2.4 json-schema-typed: 8.0.2 @@ -10315,7 +10343,7 @@ snapshots: transitivePeerDependencies: - supports-color - body-parser@2.3.0(supports-color@8.1.1): + body-parser@2.3.0: dependencies: bytes: 3.1.2 content-type: 2.0.0 @@ -11181,10 +11209,10 @@ snapshots: expr-eval-fork@3.0.3: {} - express-rate-limit@8.6.0(express@5.2.1(supports-color@8.1.1)): + express-rate-limit@8.6.0(express@5.2.1): dependencies: debug: 4.4.3(supports-color@8.1.1) - express: 5.2.1(supports-color@8.1.1) + express: 5.2.1 ip-address: 10.2.0 transitivePeerDependencies: - supports-color @@ -11225,10 +11253,10 @@ snapshots: transitivePeerDependencies: - supports-color - express@5.2.1(supports-color@8.1.1): + express@5.2.1: dependencies: accepts: 2.0.0 - body-parser: 2.3.0(supports-color@8.1.1) + body-parser: 2.3.0 content-disposition: 1.1.0 content-type: 1.0.5 cookie: 0.7.2 @@ -11238,7 +11266,7 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 etag: 1.8.1 - finalhandler: 2.1.1(supports-color@8.1.1) + finalhandler: 2.1.1 fresh: 2.0.0 http-errors: 2.0.1 merge-descriptors: 2.0.0 @@ -11249,8 +11277,8 @@ snapshots: proxy-addr: 2.0.7 qs: 6.15.3 range-parser: 1.2.1 - router: 2.2.0(supports-color@8.1.1) - send: 1.2.1(supports-color@8.1.1) + router: 2.2.0 + send: 1.2.1 serve-static: 2.2.1 statuses: 2.0.2 type-is: 2.1.0 @@ -11364,7 +11392,7 @@ snapshots: transitivePeerDependencies: - supports-color - finalhandler@2.1.1(supports-color@8.1.1): + finalhandler@2.1.1: dependencies: debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 @@ -14041,7 +14069,7 @@ snapshots: '@rolldown/binding-win32-arm64-msvc': 1.1.5 '@rolldown/binding-win32-x64-msvc': 1.1.5 - router@2.2.0(supports-color@8.1.1): + router@2.2.0: dependencies: debug: 4.4.3(supports-color@8.1.1) depd: 2.0.0 @@ -14131,7 +14159,7 @@ snapshots: transitivePeerDependencies: - supports-color - send@1.2.1(supports-color@8.1.1): + send@1.2.1: dependencies: debug: 4.4.3(supports-color@8.1.1) encodeurl: 2.0.0 @@ -14166,7 +14194,7 @@ snapshots: encodeurl: 2.0.0 escape-html: 1.0.3 parseurl: 1.3.3 - send: 1.2.1(supports-color@8.1.1) + send: 1.2.1 transitivePeerDependencies: - supports-color diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 90e5e925a0..1ffc7f1bd4 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -4,6 +4,7 @@ packages: catalogMode: prefer catalog: + "@modelcontextprotocol/sdk": 1.29.0 "@ast-grep/lang-go": 0.0.6 "@ast-grep/lang-python": 0.0.6 "@ast-grep/napi": 0.44.1 diff --git a/turbo.json b/turbo.json index 21eef1e19d..1532c38c26 100644 --- a/turbo.json +++ b/turbo.json @@ -26,6 +26,11 @@ "inputs": ["$TURBO_DEFAULT$", "!dist/**"], "outputs": ["dist/**"] }, + "@browserbasehq/stagehand-integrations#build": { + "dependsOn": ["^build"], + "inputs": ["$TURBO_DEFAULT$", "!dist/**"], + "outputs": ["dist/**"] + }, "@browserbasehq/stagehand-evals#build": { "dependsOn": ["^build"], "inputs": ["$TURBO_DEFAULT$", "!dist/**"], @@ -66,6 +71,22 @@ "@browserbasehq/stagehand-evals#typecheck": { "dependsOn": ["^build"] }, + "@browserbasehq/stagehand-integrations#typecheck": { + "dependsOn": ["^build"] + }, + "@browserbasehq/stagehand-integrations#test:unit": { + "dependsOn": ["^build", "@browserbasehq/stagehand-integrations#build"], + "inputs": [ + "$TURBO_DEFAULT$", + "tests/**", + "src/**", + "**/*.test.ts", + "$TURBO_ROOT$/vitest.config.ts", + "!$TURBO_ROOT$/packages/*/dist/**", + "!$TURBO_ROOT$/packages/*/.turbo/**", + "!$TURBO_ROOT$/packages/*/node_modules/**" + ] + }, "@browserbasehq/stagehand-docs#typecheck": {}, "test:unit": { "dependsOn": ["^build"], diff --git a/vitest.config.ts b/vitest.config.ts index 826ac255c9..0c3f2127e5 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ "packages/protocol/json-rpc/tests/**/*.test.ts", "packages/docs/tests/**/*.test.ts", "packages/evals/tests/**/*.test.ts", + "packages/integrations/tests/**/*.test.ts", "packages/extension/tests/**/*.test.ts", "packages/sdk-ts/tests/**/*.test.ts", "packages/extension/understudy/**/*.test.ts",