From 28db3051841c32ca53cef1863ad81b68114c76c7 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 26 Jun 2026 14:14:22 -0700 Subject: [PATCH 1/2] Add playwright-to-stagehand skill: migrate Playwright (TS/JS or Python) to Stagehand v3 on Browserbase MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Converts Playwright automation scripts to Stagehand v3 (TypeScript) on Browserbase. Stagehand v3's understudy page API is Playwright-flavored but only partially compatible, so the skill frames every step as one of three moves — Port the compatible subset, Rewrite the different-shape constructs (page.click(sel) -> locator(sel).click(), $$eval -> evaluate, getByTestId -> [data-testid], positional setViewportSize), and Upgrade-or-flag the rest (brittle selectors/list scrapes -> act/extract; getByRole/Text/Label -> act; route/waitForResponse/expect/downloads -> needs-human-review). Handles TS/JS and Python sources; flags @playwright/test files as out of scope (Stagehand is not a test runner). - SKILL.md: scope gate, source detection, inventory, port/rewrite/upgrade triage, v3 rewrite, migration summary - references/: api-mapping (full page-API compatibility table verified vs Stagehand 3.6.0), determinism (keep/rewrite/upgrade decision tree), guide, prompt (tool-agnostic), trace-assisted - EXAMPLES.md: before/after pairs (TS + Python, plus the test-file and network-interception gaps) - README row added; passes validate-skills.mjs Validated with a live eval (9 real Playwright scripts converted via skill-only subagents -> tsc -> run on live Browserbase): 9/9 compile, 9/9 run, outcomes match ground truth. Co-Authored-By: Claude Opus 4.8 (1M context) --- README.md | 1 + skills/playwright-to-stagehand/EXAMPLES.md | 306 +++++++++++++++ skills/playwright-to-stagehand/LICENSE.txt | 21 + skills/playwright-to-stagehand/SKILL.md | 225 +++++++++++ .../references/api-mapping.md | 364 ++++++++++++++++++ .../references/determinism.md | 106 +++++ .../references/guide.md | 116 ++++++ .../references/prompt.md | 71 ++++ .../references/trace-assisted.md | 51 +++ 9 files changed, 1261 insertions(+) create mode 100644 skills/playwright-to-stagehand/EXAMPLES.md create mode 100644 skills/playwright-to-stagehand/LICENSE.txt create mode 100644 skills/playwright-to-stagehand/SKILL.md create mode 100644 skills/playwright-to-stagehand/references/api-mapping.md create mode 100644 skills/playwright-to-stagehand/references/determinism.md create mode 100644 skills/playwright-to-stagehand/references/guide.md create mode 100644 skills/playwright-to-stagehand/references/prompt.md create mode 100644 skills/playwright-to-stagehand/references/trace-assisted.md diff --git a/README.md b/README.md index 6b06aea7..ac15d7ef 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,7 @@ This plugin includes the following skills (see `skills/` for details): | [search](skills/search/SKILL.md) | Search the web and return structured results (titles, URLs, metadata) without a browser session | | [ui-test](skills/ui-test/SKILL.md) | AI-powered adversarial UI testing — analyzes git diffs to test changes, or explores the full app to find bugs | | [browser-use-to-stagehand](skills/browser-use-to-stagehand/SKILL.md) | Migrate browser-use (Python) automation to Stagehand v3 (TypeScript) on Browserbase — maps features and picks the right determinism level per step | +| [playwright-to-stagehand](skills/playwright-to-stagehand/SKILL.md) | Migrate Playwright (TypeScript/JavaScript or Python) automation to Stagehand v3 (TypeScript) on Browserbase — ports the compatible page API, rewrites the different-shape constructs, and upgrades brittle selectors to act/extract/observe | | [agent-experience](skills/agent-experience/SKILL.md) | Audit how agent-friendly a product, SDK, or docs site is — drops Claude subagents at it with tiny prompts, captures their traces, and scores setup friction, speed, error recovery, and doc quality | | [company-research](skills/company-research/SKILL.md) | Discover target companies matching your ICP using the Browserbase Search API, deep-research each one, and score fit into a research report and CSV | | [event-prospecting](skills/event-prospecting/SKILL.md) | Extract speakers from a conference page, filter their companies against your ICP, and deep-research the best-fit people into a person-first prospecting report | diff --git a/skills/playwright-to-stagehand/EXAMPLES.md b/skills/playwright-to-stagehand/EXAMPLES.md new file mode 100644 index 00000000..5e6d9a24 --- /dev/null +++ b/skills/playwright-to-stagehand/EXAMPLES.md @@ -0,0 +1,306 @@ +# Examples: Playwright → Stagehand + +Before/after pairs showing the migration moves. Each "before" is a Playwright script (TS or Python); +each "after" is its Stagehand v3 (TypeScript) rewrite on Browserbase. Illustrative — validate against +your real site and verify signatures against the installed package (see [api-mapping.md](references/api-mapping.md)). + +See [SKILL.md](SKILL.md) for the workflow, [the guide](references/guide.md) for the philosophy, and +[references/determinism.md](references/determinism.md) for the port/rewrite/upgrade decision. + +## Running an "after" example + +```bash +npm install @browserbasehq/stagehand zod +npm install -D tsx dotenv +``` + +`.env`: +```bash +BROWSERBASE_API_KEY=... +BROWSERBASE_PROJECT_ID=... +ANTHROPIC_API_KEY=... # or OPENAI_API_KEY, matching the model string in the file +``` + +```bash +npx tsx example.ts +``` + +Swap `env: "BROWSERBASE"` for `env: "LOCAL"` (with Chrome installed) to run locally during dev. + +--- + +## 1. Brittle list scrape (TS) → `extract()` + +A `$$eval` with per-element CSS — the classic thing that breaks on DOM drift. `$$eval` has no +understudy equivalent anyway, so this is the highest-value **upgrade**. + +**Before — Playwright (TS)** +```typescript +import { chromium } from "playwright"; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage(); +await page.goto("https://quotes.toscrape.com/", { waitUntil: "networkidle" }); + +const quotes = await page.$$eval("div.quote", (els) => + els.slice(0, 5).map((el) => ({ + text: el.querySelector("span.text")?.textContent?.trim() ?? "", + author: el.querySelector("small.author")?.textContent?.trim() ?? "", + })), +); +console.log(quotes); +await browser.close(); +``` + +**After — Stagehand v3** +```typescript +import "dotenv/config"; +import { Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod"; + +async function main() { + const stagehand = new Stagehand({ env: "BROWSERBASE", model: "anthropic/claude-sonnet-4-6" }); + await stagehand.init(); + try { + const page = stagehand.context.pages()[0]; + await page.goto("https://quotes.toscrape.com/"); // ported; dropped "networkidle" + await page.waitForLoadState("domcontentloaded"); // settle before the AI snapshot + + const quotes = await stagehand.extract( // upgraded: $$eval scrape → extract + "extract the first 5 quotes with their text and author", + z.array(z.object({ text: z.string(), author: z.string() })), + ); + console.log(quotes); + } finally { + await stagehand.close(); + } +} +main().catch((e) => { console.error(e); process.exit(1); }); +``` + +--- + +## 2. Login form (TS): `#id` selectors kept, secrets → `variables`, `expect` → read + throw + +Stable `#id` selectors are **ported** through `locator` (page-level `page.fill(sel)` doesn't exist). +Hardcoded creds become `variables` + env. `expect()` isn't available — read and throw. + +**Before — Playwright (TS)** +```typescript +import { chromium } from "playwright"; +import { expect } from "@playwright/test"; + +const browser = await chromium.launch({ headless: true }); +const page = await browser.newPage(); +await page.goto("https://the-internet.herokuapp.com/login"); +await page.fill("#username", "tomsmith"); +await page.fill("#password", "SuperSecretPassword!"); +await page.click("button[type='submit']"); +await expect(page.locator("#flash")).toContainText("You logged into a secure area!"); +await browser.close(); +``` + +**After — Stagehand v3** +```typescript +import "dotenv/config"; +import { Stagehand } from "@browserbasehq/stagehand"; + +async function main() { + const stagehand = new Stagehand({ env: "BROWSERBASE", model: "anthropic/claude-sonnet-4-6" }); + await stagehand.init(); + try { + const page = stagehand.context.pages()[0]; + await page.goto("https://the-internet.herokuapp.com/login"); + + // #username is stable → port via locator. Username is not secret here, but the password is: + await page.locator("#username").fill(process.env.APP_USER ?? "tomsmith"); + await stagehand.act("type %password% into the password field", { + variables: { password: process.env.APP_PASS ?? "SuperSecretPassword!" }, + }); + await page.locator("button[type='submit']").click(); // page.click(sel) → locator(sel).click() + + // expect() has no equivalent → read + throw (add waitForSelector since #flash appears post-nav) + await page.waitForSelector("#flash"); + const flash = (await page.locator("#flash").textContent())?.trim() ?? ""; + if (!flash.includes("You logged into a secure area!")) { + throw new Error(`login assertion failed: ${flash}`); + } + console.log("login ok:", flash.split("\n")[0]); + } finally { + await stagehand.close(); + } +} +main().catch((e) => { console.error(e); process.exit(1); }); +``` + +> **Migration summary note:** `expect()` → manual read loses Playwright's auto-retry. For repeat runs, +> reuse a Browserbase **Context** to skip re-login. Consider `selfHeal: true` for production. + +--- + +## 3. Semantic locators (`getByRole`/`getByLabel`) → `act()` + +Playwright's role/label/text engines have **no understudy equivalent**. Where an obvious underlying +selector exists, use a CSS `locator`; otherwise `act()`. This is where migrating *from* Playwright +legitimately adds AI. + +**Before — Playwright (TS)** +```typescript +await page.getByRole("link", { name: "Form Authentication" }).click(); +await page.getByLabel("Username").fill("tomsmith"); +await page.getByRole("button", { name: "Login" }).click(); +``` + +**After — Stagehand v3** +```typescript +await stagehand.act("click the 'Form Authentication' link"); // no getByRole → act +await page.locator("#username").fill("tomsmith"); // label maps to a known id → port +await stagehand.act("click the Login button"); // no getByRole → act +``` + +--- + +## 4. Python (sync) scrape → Stagehand TypeScript + +Cross-language: `sync_playwright()` + snake_case + `query_selector_all` (`page.$$`, no equivalent) +become an async TS `extract`. + +**Before — Playwright (Python, sync)** +```python +from playwright.sync_api import sync_playwright + +with sync_playwright() as p: + browser = p.chromium.launch(headless=True) + page = browser.new_page() + page.set_viewport_size({"width": 1280, "height": 720}) + page.goto("https://quotes.toscrape.com/", wait_until="networkidle") + for q in page.query_selector_all("div.quote")[:5]: + print(q.query_selector("small.author").inner_text()) + browser.close() +``` + +**After — Stagehand v3 (TypeScript)** +```typescript +import "dotenv/config"; +import { Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod"; + +async function main() { + const stagehand = new Stagehand({ env: "BROWSERBASE", model: "anthropic/claude-sonnet-4-6" }); + await stagehand.init(); + try { + const page = stagehand.context.pages()[0]; + await page.setViewportSize(1280, 720); // positional, not an object + await page.goto("https://quotes.toscrape.com/"); // dropped "networkidle" + await page.waitForLoadState("domcontentloaded"); + + const authors = await stagehand.extract( // query_selector_all scrape → extract + "the authors of the first 5 quotes, in order", + z.array(z.object({ author: z.string() })), + ); + for (const a of authors) console.log(a.author); + } finally { + await stagehand.close(); + } +} +main().catch((e) => { console.error(e); process.exit(1); }); +``` + +--- + +## 5. `@playwright/test` spec → flag the scaffold, convert the browser logic + +The test runner (`test`, fixtures, `expect`, page-objects) is **out of scope**. Lift the browser +logic into a plain Stagehand script; map assertions to read + throw. + +**Before — Playwright Test** +```typescript +import { test, expect } from "@playwright/test"; + +test("valid login", async ({ page }) => { + await page.goto("https://the-internet.herokuapp.com/login"); + await page.fill("#username", "tomsmith"); + await page.fill("#password", "SuperSecretPassword!"); + await page.click("button[type='submit']"); + await expect(page.locator("#flash")).toContainText("You logged into a secure area!"); +}); +``` + +**After — Stagehand v3** (+ a migration note) +```typescript +// NOTE (needs human review): the @playwright/test scaffolding — test(), the { page } fixture, and +// expect() — has no Stagehand equivalent. Stagehand is automation, not a test runner. Below is the +// browser logic lifted into a plain script; re-wrap it in your own runner if you need test reporting. +import "dotenv/config"; +import { Stagehand } from "@browserbasehq/stagehand"; + +async function main() { + const stagehand = new Stagehand({ env: "BROWSERBASE", model: "anthropic/claude-sonnet-4-6" }); + await stagehand.init(); + try { + const page = stagehand.context.pages()[0]; + await page.goto("https://the-internet.herokuapp.com/login"); + await page.locator("#username").fill("tomsmith"); + await page.locator("#password").fill("SuperSecretPassword!"); + await page.locator("button[type='submit']").click(); + + await page.waitForSelector("#flash"); + const flash = (await page.locator("#flash").textContent())?.trim() ?? ""; + if (!flash.includes("You logged into a secure area!")) throw new Error(`assertion failed: ${flash}`); + console.log("valid login: ok"); + } finally { + await stagehand.close(); + } +} +main().catch((e) => { console.error(e); process.exit(1); }); +``` + +--- + +## 6. Network interception (`route` + `waitForResponse`) → restructure or flag + +`page.route()` and `page.waitForResponse()` have **no Stagehand surface**. Prefer to drop incidental +interception and read the rendered result instead; flag anything load-bearing. + +**Before — Playwright (TS)** +```typescript +await page.route("**/*.{png,jpg}", (r) => r.abort()); // block images for speed +const resp = page.waitForResponse((r) => r.url().includes("/api/quotes")); +await page.goto("https://quotes.toscrape.com/scroll"); +const json = await (await resp).json(); +console.log(json.quotes.slice(0, 5)); +``` + +**After — Stagehand v3** (+ a migration note) +```typescript +// NOTE (needs human review): page.route() (image blocking) and waitForResponse() (sniffing the +// /api/quotes XHR) have no Stagehand equivalent. Image blocking is incidental — Browserbase handles +// perf, and blockAds:true is available. Instead of sniffing the XHR, read the rendered quotes. +import "dotenv/config"; +import { Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod"; + +async function main() { + const stagehand = new Stagehand({ + env: "BROWSERBASE", + model: "anthropic/claude-sonnet-4-6", + browserbaseSessionCreateParams: { browserSettings: { blockAds: true } }, + }); + await stagehand.init(); + try { + const page = stagehand.context.pages()[0]; + await page.goto("https://quotes.toscrape.com/scroll"); + await page.waitForLoadState("domcontentloaded"); + + const quotes = await stagehand.extract( + "extract the first 5 quotes with their text and author", + z.array(z.object({ text: z.string(), author: z.string() })), + ); + console.log(quotes); + // If you truly must read the raw API response, use CDP passthrough: page.sendCDP("Network.enable"). + } finally { + await stagehand.close(); + } +} +main().catch((e) => { console.error(e); process.exit(1); }); +``` diff --git a/skills/playwright-to-stagehand/LICENSE.txt b/skills/playwright-to-stagehand/LICENSE.txt new file mode 100644 index 00000000..f2f43974 --- /dev/null +++ b/skills/playwright-to-stagehand/LICENSE.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Browserbase, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/skills/playwright-to-stagehand/SKILL.md b/skills/playwright-to-stagehand/SKILL.md new file mode 100644 index 00000000..fbf22148 --- /dev/null +++ b/skills/playwright-to-stagehand/SKILL.md @@ -0,0 +1,225 @@ +--- +name: playwright-to-stagehand +description: Migrate Playwright browser-automation scripts (TypeScript/JavaScript or Python) to Stagehand v3 (TypeScript) on Browserbase. Use when the user wants to convert, port, rewrite, or migrate a Playwright script to Stagehand, move Playwright automation onto Browserbase, make brittle CSS/XPath selectors resilient with AI (act/extract/observe), or map Playwright APIs (chromium.launch, page.goto/click/fill/locator, getByRole) to Stagehand primitives. Triggers on "playwright", "@playwright/test", "chromium.launch", "sync_playwright". +compatibility: "The skill itself uses only Read/Write/Edit/Grep/Bash — no install step. The Stagehand code it generates needs Node 18+, `@browserbasehq/stagehand` (v3) and `zod`, plus `BROWSERBASE_API_KEY` / `BROWSERBASE_PROJECT_ID` and a model-provider key (e.g. `ANTHROPIC_API_KEY`) to run on Browserbase." +license: MIT +allowed-tools: Read, Write, Edit, Grep, Bash +--- + +# Playwright → Stagehand on Browserbase (`/playwright-to-stagehand`) + +Convert a Playwright script (**TypeScript/JavaScript or Python**) into an idiomatic **Stagehand v3 +(TypeScript)** script on **Browserbase**, keeping the deterministic skeleton and upgrading only the +brittle parts. + +**Core principle:** Playwright is deterministic but **brittle** — hardcoded CSS/XPath selectors break +on DOM drift, and a local browser has no proxy/stealth/captcha/scale. Stagehand v3 drives the browser +with an internal CDP engine (*understudy*) that exposes a **Playwright-*flavored* but only *partially* +compatible** `page` API. So every step is one of three moves: **Port** the compatible subset +(`page.goto`, `page.locator(css/xpath).fill/click`, `evaluate`, `screenshot`); **Rewrite** the +different-shape constructs (`page.click(sel)`→`locator(sel).click()`, `$$eval`→`evaluate`, +`getByTestId`→`[data-testid]`, positional `setViewportSize`); and **Upgrade or flag** the rest +(brittle selectors & list scrapes → `act`/`extract`; semantic `getByRole/Text/Label` → `act`; +`route`/events/`waitForResponse`/`expect`/downloads → needs-human-review). Then move the session to +Browserbase. This is a *selective* refactor — **not** "wrap every step in AI," and **not** a verbatim copy. + +> **Source of truth & versions.** The durable value here is the *judgment* — the keep-vs-upgrade +> selector triage — not the API specifics, which drift every release. The code mappings are a +> **snapshot validated against `@browserbasehq/stagehand` 3.6.x (2026-06)**. On any conflict the +> **live docs win** — verify against the installed package before emitting code: +> - Stagehand v3: · installed types: `node_modules/@browserbasehq/stagehand` +> - Browserbase: · Playwright: +> +> If the installed Stagehand major is **not 3**, treat this skill as conceptual only and follow the +> live docs for every signature. + +## Reference files (read as needed) + +- [`references/api-mapping.md`](references/api-mapping.md) — the mechanical Playwright → Stagehand + mapping: lifecycle, the full page-API support table (what maps 1:1, what differs, what's missing), + before/after code, Browserbase platform options, Python-source specifics, and v3 gotchas. + **Read this for any non-trivial construct.** +- [`references/determinism.md`](references/determinism.md) — the keep-vs-upgrade decision tree: + which selectors to leave deterministic vs move to `act`/`extract`/`observe`. **Read this when + deciding how to translate each step.** +- [`references/guide.md`](references/guide.md) — the human migration guide: the philosophy shift, + selector triage, what you gain/trade, and a recommended path. +- [`references/prompt.md`](references/prompt.md) — a self-contained, tool-agnostic version of this + skill; paste it into any AI assistant along with a Playwright script. +- [`EXAMPLES.md`](EXAMPLES.md) — before/after script pairs (TS and Python sources). + +## Workflow + +### 1. Get the source +Obtain the Playwright script(s). If the user only described a script, ask for the file(s). Note the +target: **TypeScript Stagehand on Browserbase** unless they say otherwise — including when the source +is **Python**. + +> **First, gate on scope.** If the source imports from **`@playwright/test`** (TS) or uses +> `expect` from **`playwright.sync_api`** (Python), it is a **test**, not an automation script. +> The test-runner surface (`test()`, `test.describe`, fixtures like `{ page }`, projects, retries, +> reporters, web-first `expect()` assertions) has **no Stagehand equivalent** — Stagehand is +> automation, not a test framework. **Flag the test scaffolding as out of scope**, then convert only +> the browser logic inside it (page-object methods, navigation/fill/click steps), mapping assertions +> to explicit checks. See guide.md "`@playwright/test` files are out of scope". + +### 2. Detect the source language + shape +- **TypeScript/JavaScript** (`import { chromium } from "playwright"`) — same language as the target; + the page API maps closest to 1:1. +- **Python** (`from playwright.sync_api import ...` / `async_api`) — translate to TS: snake_case → + camelCase (`wait_for_selector` → `waitForSelector`, `query_selector_all` → `$$`/`locator`), + `sync_playwright()` context-manager → `init()`/`close()`, sync calls → `await`. See api-mapping §6. +- **Plain script vs `@playwright/test`** — see the scope gate in step 1. + +State which you found. + +### 3. Inventory the script +Before writing any TypeScript, extract: +- **Lifecycle** — `chromium.launch()` options (headless, args), `browser.newContext()` (viewport, + userAgent, storageState, proxy, locale), `newPage()`, multiple contexts/pages. +- **Navigations** — every `goto` (+ `waitUntil`), `waitForURL`, `waitForNavigation`. +- **Selectors + actions** — every `locator`/`$`/`getBy*` + `click`/`fill`/`type`/`select`/`check`, + noting whether each selector is **stable** or **brittle** (this drives step 4). +- **Reads** — `textContent`/`innerText`/`$$eval`/`getAttribute` and list/table scrapes. +- **Assertions** — `expect(...)` usage (web-first or standalone). +- **Secrets** — hardcoded credentials, tokens, env usage, login flows. +- **Platform / gaps** — `page.route()`, `waitForResponse`/`waitForRequest`, downloads, file chooser, + `page.on(...)` events, proxies, storage state. +- **Waits** — `waitForTimeout` sleeps (flag to replace), `waitForLoadState` (watch for `networkidle`). + +### 4. Triage every step: port, rewrite, or upgrade +Apply the decision tree in determinism.md. For each step: +- **Port** — native calls understudy implements (`goto`, `screenshot`, `evaluate`, `waitForLoadState`, + `frames`) and stable `page.locator(css/xpath).fill/click/textContent/…`. Keep ~1:1 (mind signature diffs). +- **Rewrite** — deterministic but different shape: page-level `page.click(sel)`/`fill(sel)` → + `page.locator(sel).click()/.fill()`; `$$eval`/`$`/`$$`/`content()` → `page.evaluate(...)`; + `getByTestId("x")` → `locator('[data-testid="x"]')`; `setViewportSize({w,h})` → positional `(w,h)`. +- **Upgrade** — brittle selectors (long CSS, `nth-child`, text-XPath) → `act("…")` / `observe()`→`act()`; + list/table reads (`$$eval` scrapes) → `extract("…", zodSchema)`; semantic `getByRole/getByText/getByLabel` + (no understudy equivalent) → `act()` or a CSS/XPath locator. +- **Flag** — `page.route()`/interception, `waitForResponse/Request`, `page.on(event)`, `expect()` + assertions, downloads, multiple contexts → needs-human-review (api-mapping §7). +- **Arbitrary sleep** (`waitForTimeout`) → replace with a real wait. + +Default to **port/rewrite** for stable selectors; upgrade only what's fragile or semantic. +Over-AI-ifying stable locators is a failure — but copying `getBy*`/`page.click(sel)`/`$$eval` +verbatim won't work either (those APIs don't exist on understudy). + +### 5. Produce the Stagehand v3 rewrite +**First, verify the API.** Confirm the exact signatures against the installed package +(`node_modules/@browserbasehq/stagehand` types) or . The mappings are a +3.6.x snapshot; the installed version wins. Then emit runnable TypeScript. Always: +- `import { Stagehand } from "@browserbasehq/stagehand";` and `import { z } from "zod";` when extracting. +- Map `chromium.launch()` + `newContext()` + `newPage()` → one `new Stagehand({ env: "BROWSERBASE" })` + + `await stagehand.init()`. Get the page via `const page = stagehand.context.pages()[0];`. +- Call AI methods on the **instance**: `stagehand.act(...)`, `stagehand.extract(...)`, + `stagehand.observe(...)` — **never** `page.act(...)`. +- Keep ported page calls on that page object: `page.goto`, `page.locator(sel).click()/.fill()`, + `page.evaluate`, `page.screenshot`, `page.waitForSelector`/`page.waitForLoadState`. **Selector + actions go through `page.locator(sel)`** — page-level `page.click(sel)`/`page.fill(sel)` don't + exist (page-level click is coordinate-based). +- Set the model as a `"provider/model"` string; default `env: "BROWSERBASE"` (show `LOCAL` as the dev option). +- Pass secrets via `variables` + `process.env`, never hardcoded. +- `await stagehand.init()` at the start, `await stagehand.close()` in a `finally`. + +Include the project setup so it runs (see templates below). + +### 6. Write the migration summary +Alongside the code: +- **Source detected** (TS/JS or Python; plain vs `@playwright/test`) and the **keep-vs-upgrade + decisions** per step, with reasoning. +- **Needs human review** — anything that didn't map 1:1: `page.route()`/network interception, + `waitForResponse`/`waitForRequest`, test-runner scaffolding, multiple contexts, downloads, + `page.on(...)` listeners, storage-state files. +- **Recommended next step** — a Browserbase Context for auth reuse, `selfHeal`/caching for production, + proxies/stealth for protected targets. + +### 7. Offer the trace-assisted path (only if warranted) +If the rewrite can't be confidently mapped (heavy network interception, an opaque flow, flakiness), +offer the trace-assisted workflow (trace-assisted.md): run on Browserbase, read the session logs, +then refine from observed behavior. Don't run anything without the user's go-ahead. + +## Output templates + +**`package.json`** +```json +{ + "name": "stagehand-migration", + "type": "module", + "scripts": { "start": "tsx index.ts" }, + "dependencies": { + "@browserbasehq/stagehand": "^3.0.0", + "dotenv": "^16.0.0", + "zod": "^3.25.0" + }, + "devDependencies": { "tsx": "^4.0.0", "typescript": "^5.0.0" } +} +``` + +**`.env`** +```bash +BROWSERBASE_API_KEY=... +BROWSERBASE_PROJECT_ID=... +ANTHROPIC_API_KEY=... # or the provider matching your model string +``` + +**`index.ts` skeleton** (kept skeleton + upgraded brittle steps) +```typescript +import "dotenv/config"; +import { Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod"; + +async function main() { + const stagehand = new Stagehand({ + env: "BROWSERBASE", // "LOCAL" for dev with a real Chrome + model: "anthropic/claude-sonnet-4-6", + // selfHeal: true, // recover cached selectors from DOM drift + }); + await stagehand.init(); + try { + const page = stagehand.context.pages()[0]; + + await page.goto("https://example.com"); // kept: navigation (no AI) + await page.locator("#id-that-is-stable").click(); // kept: stable selector (no AI) + await stagehand.act("click the brittle thing"); // upgraded: was a fragile selector + const data = await stagehand.extract("…", z.array(z.object({ /* … */ }))); // upgraded: list scrape + + console.log(data); + } finally { + await stagehand.close(); + } +} + +main().catch((err) => { console.error(err); process.exit(1); }); +``` + +## Validation checklist (before declaring done) +- [ ] Lifecycle mapped: `launch`/`newContext`/`newPage` → `new Stagehand()` + `init()`; page via `stagehand.context.pages()[0]`. +- [ ] AI methods are on the **instance** (`stagehand.act/extract/observe`), not the page. +- [ ] Selector actions routed through `page.locator(sel)` (no page-level `page.click(sel)`/`page.fill(sel)`); `getBy*`/`$$eval`/`page.content()` rewritten (locator/`evaluate`) or upgraded (`act`/`extract`). +- [ ] Stable selectors ported deterministically; only **brittle/semantic** ones upgraded to `act`/`extract`. +- [ ] List/table scrapes use `extract` + a zod schema; `zod` is in dependencies. +- [ ] Model is a `"provider/model"` string; the matching provider key is in `.env`. +- [ ] Secrets use `variables` + `process.env`; nothing hardcoded. +- [ ] `waitForTimeout` sleeps replaced with real waits; no `"networkidle"`. +- [ ] `init()` / `close()` present; `close()` in `finally`. +- [ ] Gaps flagged in the summary: `route()`/network interception, `waitForResponse`, `@playwright/test` scaffolding, multi-context, downloads. + +## Common mistakes to avoid +- **Over-AI-ifying** — replacing a stable `page.locator("#id").click()` with `act()`. Adds cost, + latency, and non-determinism for nothing. Keep ported selectors. +- **Emitting APIs understudy doesn't have** — `page.click(sel)` (page-level is coordinate-based), + `page.getByRole/getByText/getByLabel`, `page.$$eval`/`$`/`$$`, `page.keyboard`/`page.mouse`, + `page.waitForResponse`, `expect()`. Rewrite to `locator`/`evaluate`/`keyPress`/`act`, or flag the + gap (api-mapping §3, §7). +- **Under-migrating** — copying brittle CSS/XPath verbatim onto the Stagehand page. It runs, but you + kept the brittleness and gained only a cloud browser. Upgrade fragile selectors. +- **Putting AI methods on the page** (`page.act()`) — v3 AI methods live on the **instance**. +- **Carrying `waitUntil: "networkidle"`** — it times out on analytics/long-poll pages; use `"domcontentloaded"`. +- **Converting `@playwright/test` into a fake test** — there's no Stagehand test runner; flag the + scaffolding, convert only the browser logic. +- **Inventing network interception** — `page.route()` / `waitForResponse` have no Stagehand equivalent; + flag them for human review rather than faking them. +- **Inventing Stagehand/Browserbase options** — verify against / + rather than guessing. +``` diff --git a/skills/playwright-to-stagehand/references/api-mapping.md b/skills/playwright-to-stagehand/references/api-mapping.md new file mode 100644 index 00000000..760e6753 --- /dev/null +++ b/skills/playwright-to-stagehand/references/api-mapping.md @@ -0,0 +1,364 @@ +# Playwright → Stagehand + Browserbase: API Mapping + +The authoritative, mechanical mapping the `/playwright-to-stagehand` skill uses to translate code. +Pair it with [`determinism.md`](determinism.md) (keep vs upgrade) and [`guide.md`](guide.md) (the why). + +> ⚠️ **Point-in-time snapshot — verified against `@browserbasehq/stagehand` 3.6.0 source +> (2026-06), not a live spec.** Signatures drift every release. The **live docs supersede this +> table on any conflict** — verify against the installed package +> (`node_modules/@browserbasehq/stagehand`) or before relying on an +> exact signature. + +> **The one thing to internalize:** Stagehand v3 does **not** run Playwright. It drives the browser +> with an internal CDP engine called **understudy** that exposes a **Playwright-*flavored* but only +> *partially* compatible** `page`/`locator`/`context` API. A migration is therefore three moves, not +> a copy: **Port** the compatible subset, **Rewrite** the deterministic-but-different constructs, and +> **Upgrade-or-flag** the ones with no equivalent. The tables below tell you which is which. + +--- + +## 1. Detect the source flavor first + +| Flavor | Tell-tale | Handling | +|---|---|---| +| **TS/JS, plain script** | `import { chromium } from "playwright"` / `require("playwright")`; `chromium.launch()` | Primary path. Same language as target. | +| **Python, plain script** | `from playwright.sync_api import ...` / `from playwright.async_api import ...` | Translate to TS (see §6). Target is still TS. | +| **`@playwright/test` (TS) or `expect` from `playwright.sync_api` (Py)** | `import { test, expect } from "@playwright/test"`; `test(...)`, fixtures `{ page }` | **Out of scope as a test.** Flag the runner scaffolding; convert only the browser logic (§4.7). | + +State which you found before translating. + +--- + +## 2. Lifecycle mapping + +Playwright's `chromium.launch() → browser.newContext() → context.newPage()` collapses into a single +Stagehand construction + `init()`. + +| Playwright | Stagehand v3 | +|---|---| +| `const browser = await chromium.launch({ headless })` | `const stagehand = new Stagehand({ env: "BROWSERBASE", model: "…" })` | +| `const context = await browser.newContext({ viewport, … })` | (no separate context construction — `init()` provisions it) | +| `const page = await context.newPage()` | `await stagehand.init(); const page = stagehand.context.pages()[0];` | +| `await browser.close()` | `await stagehand.close()` (put in `finally`) | +| second tab: `await context.newPage()` | `await stagehand.context.newPage(url?)` | +| `browser.newContext()` ×N (isolation) | **one Stagehand instance = one context.** For true isolation use multiple `Stagehand` instances. Flag. | + +```typescript +import { Stagehand } from "@browserbasehq/stagehand"; + +const stagehand = new Stagehand({ + env: "BROWSERBASE", // or "LOCAL" for dev with a real Chrome + model: "anthropic/claude-sonnet-4-6", // "provider/model" string + // selfHeal: true, // recover cached selectors from DOM drift +}); +await stagehand.init(); +const page = stagehand.context.pages()[0]; // the active page +``` + +> There is **no `stagehand.page` getter** — get the page from `stagehand.context.pages()[0]`. AI +> methods (`act`/`extract`/`observe`/`agent`) live on the **instance**, not the page. + +**Constructor option names (v3.6.0, `lib/v3/types/public/options.ts`) — watch the v2→v3 renames:** +- `env: "LOCAL" | "BROWSERBASE"`, `apiKey`, `projectId`. +- LLM provider key: pass `model` as an object — `model: { modelName: "openai/gpt-5", apiKey: "…" }`. + There is **no top-level `modelClientOptions`** (v2-ism). +- `selfHeal?`, `cacheDir?` (on-disk act cache), `serverCache?` (server-side, default on under BB), + `experimental?`, `verbose?: 0|1|2`, `systemPrompt?`. +- `domSettleTimeout?` — **not** `domSettleTimeoutMs`. +- Browserbase session / proxy / stealth / context → `browserbaseSessionCreateParams` (§5). +- Local Chromium config → `localBrowserLaunchOptions` (§5). + +--- + +## 3. The page-API compatibility table (the heart of the migration) + +For each Playwright construct, the move is **Port** (works ~as-is), **Rewrite** (deterministic but a +different shape), or **Upgrade/Flag** (no deterministic equivalent → `act`/`extract` or human review). + +### Navigation & document +| Playwright | Move | Stagehand v3 | +|---|---|---| +| `page.goto(url, { waitUntil })` | **Port (signature diff)** | `page.goto(url, { waitUntil, timeoutMs })` — option is **`timeoutMs`** not `timeout`; returns `Response\|null`; `waitUntil` defaults to `"domcontentloaded"` | +| `page.url()`, `page.title()` | **Port** | same | +| `page.reload/goBack/goForward` | **Port** | `reload/goBack/goForward({ waitUntil, timeoutMs })` | +| `page.content()` | **Rewrite** | `page.evaluate(() => document.documentElement.outerHTML)` — no `content()` | + +### Selector actions — **page-level selector methods do NOT exist; route through `locator`** +| Playwright | Move | Stagehand v3 | +|---|---|---| +| `page.click("#sel")` | **Rewrite** | `page.locator("#sel").click()` — page-level `click(x, y)` is **coordinate-based** | +| `page.fill("#sel", v)` | **Rewrite** | `page.locator("#sel").fill(v)` | +| `page.type("#sel", t)` | **Rewrite** | `page.locator("#sel").type(t)` — page-level `type(text)` types at current focus | +| `page.selectOption("#sel", v)` | **Rewrite** | `page.locator("#sel").selectOption(v)` | +| `page.hover("#sel")` | **Rewrite** | `page.locator("#sel").hover()` | +| `page.check/uncheck("#sel")` | **Rewrite/Upgrade** | locator has **no `.check()`** — `act("check the … box")`, or `locator(sel).click()` if it toggles | +| `page.press("#sel", "Enter")` | **Rewrite** | focus via `locator(sel).click()` then `page.keyPress("Enter")` (no `page.keyboard`, no `locator.press`) | + +### Locators +| Playwright | Move | Stagehand v3 | +|---|---|---| +| `page.locator(css/xpath)` | **Port** | `page.locator(selector)` | +| `.fill/.type/.hover/.click/.selectOption` | **Port** (`.click({button,clickCount})` only) | same | +| `.textContent/.innerText/.inputValue/.isVisible/.isChecked/.count/.first/.nth/.setInputFiles` | **Port** | same (note: `.innerHtml()` is lower-case-h) | +| `.last()` | **Rewrite** | `.nth((await loc.count()) - 1)` — no `.last()` | +| `.getAttribute(name)` | **Rewrite** | `page.evaluate((el)=>el.getAttribute(name), )` or `extract(…)` — no `locator.getAttribute` | +| `.filter()/.all()/.waitFor()/.isEnabled()/.focus()/.press()` | **Rewrite/Upgrade** | not on understudy locator — restructure, `evaluate`, or `act` | +| `locator(a).locator(b)` chaining | **Rewrite** | compose one selector string `locator("a b")` — locators don't chain | +| `page.getByRole/getByText/getByLabel/getByPlaceholder` | **Upgrade** | **no semantic getBy\*** — `act("click the 'Login' button")` / `extract(…)`, or a CSS/XPath approximation | +| `page.getByTestId("x")` | **Rewrite (stays deterministic)** | `page.locator('[data-testid="x"]')` | + +> **Playwright selector engines** (`text=`, `role=`, `:has-text()`, `:nth-match()`) are **not** +> understood by understudy's `locator()` — it resolves CSS/XPath. Convert engine selectors to plain +> CSS/XPath, or to `act()`/`extract()`. + +### Reads / scraping +| Playwright | Move | Stagehand v3 | +|---|---|---| +| `page.$$eval(sel, fn)` / `$eval` | **Rewrite or Upgrade** | `page.evaluate(...)` for a 1:1 deterministic read; **prefer `extract("…", zodSchema)`** for any brittle/list scrape | +| `page.$(sel)` / `page.$$(sel)` | **Rewrite** | no `$`/`$$` — use `page.locator(sel)` (+ `.nth()`/`.count()`) or `page.evaluate` | +| `locator.allInnerTexts()` / list reads | **Upgrade** | `extract("…", z.array(z.object({…})))` | + +### Waits +| Playwright | Move | Stagehand v3 | +|---|---|---| +| `page.waitForSelector(sel, { state })` | **Port (returns boolean)** | `page.waitForSelector(sel, { state, timeout, pierceShadow })` → `boolean` (not an ElementHandle) | +| `page.waitForLoadState(state)` | **Port (positional timeout)** | `page.waitForLoadState(state, timeoutMs?)` | +| `page.waitForTimeout(ms)` | **Port — but prefer to remove** | exists; replace arbitrary sleeps with a real wait | +| `page.waitForURL/waitForNavigation` | **Rewrite** | poll `page.url()` after the action, or `waitForLoadState` — no `waitForURL` | +| `page.waitForFunction` | **Rewrite** | loop on `page.evaluate(...)` | +| `page.waitForResponse/waitForRequest/waitForEvent` | **Flag** | no equivalent (see §4.6) | + +### Viewport / capture / scripting +| Playwright | Move | Stagehand v3 | +|---|---|---| +| `page.setViewportSize({ width, height })` | **Rewrite (positional!)** | `page.setViewportSize(width, height)` — positional args, not an object | +| `page.screenshot(opts)` | **Port** | `page.screenshot(options?)` → `Buffer` | +| `page.pdf()` | **Flag** | not implemented | +| `page.evaluate(fn, arg)` | **Port** | same (PW-compatible) | +| `page.setExtraHTTPHeaders` / `page.addInitScript` | **Port** | same (also on `context`) | +| `page.setDefaultTimeout` | **Rewrite** | per-call timeouts only — pass `timeout`/`timeoutMs` per call | + +### Keyboard / mouse / frames +| Playwright | Move | Stagehand v3 | +|---|---|---| +| `page.keyboard.press/type` | **Rewrite** | `page.keyPress(key, { delay })` — no `keyboard` object | +| `page.mouse.*` | **Rewrite** | `page.click(x, y)`, `page.hover(x, y)`, `page.scroll(...)`, `page.dragAndDrop(...)` | +| `page.frames()` / `page.frameLocator(sel)` | **Port** | same; plus Stagehand-only `page.deepLocator()` for cross-iframe | + +### Interception / events / assertions — **flag these** +| Playwright | Move | Stagehand v3 | +|---|---|---| +| `page.route()` / `unroute()` (request mocking/blocking) | **Flag** | no interception. Fall back to `page.sendCDP("Network.…")`, or restructure. Needs human review | +| `page.on('request'/'response'/'download'/'dialog'/'popup'/…)` | **Flag** | `.on()` supports **only `"console"`** — flag other listeners | +| `expect(locator).toHaveText(...)` (`@playwright/test`) | **Rewrite** | `expect` is **not exported** — read the value (`locator.textContent()` / `page.url()` / `extract`) and `throw` on mismatch; loses auto-retry/auto-wait | +| `page.waitForEvent('download')` / file chooser open | **Flag** | no download event; uploads via `locator.setInputFiles`. Downloads → Browserbase session-downloads API | + +--- + +## 4. Detailed translations + +### 4.1 Brittle list scrape → `extract()` (the highest-value upgrade) + +**Before — Playwright (TS)** +```typescript +const quotes = await page.$$eval("div.quote", (els) => + els.slice(0, 5).map((el) => ({ + text: el.querySelector("span.text")?.textContent?.trim(), + author: el.querySelector("small.author")?.textContent?.trim(), + })), +); +``` +**After — Stagehand** +```typescript +import { z } from "zod"; +const quotes = await stagehand.extract( + "extract the first 5 quotes with their text and author", + z.array(z.object({ text: z.string(), author: z.string() })), +); +``` +`$$eval` has no understudy equivalent, and the per-element CSS chain is exactly what breaks on DOM +drift. One schema'd read replaces it. (A deterministic `$$eval` with *stable* selectors can instead +be a 1:1 `page.evaluate(...)` — only upgrade when the selectors are brittle.) + +### 4.2 Page-level selector action → `locator` (mechanical rewrite, stays deterministic) + +```typescript +// Playwright +await page.click("#submit"); +await page.fill("#email", "a@b.com"); +// Stagehand — page-level click/type are coordinate/focus-based; route through locator +await page.locator("#submit").click(); +await page.locator("#email").fill("a@b.com"); +``` + +### 4.3 Semantic locator (`getBy*`) → `act()` or CSS + +```typescript +// Playwright (resilient, but no understudy equivalent) +await page.getByRole("button", { name: "Login" }).click(); +await page.getByLabel("Username").fill("tomsmith"); +// Stagehand — no getBy*; use act() (AI resolves the accessible UI), or a CSS/XPath locator if obvious +await stagehand.act("click the Login button"); +await page.locator("#username").fill("tomsmith"); // when the underlying id is known +// getByTestId stays deterministic: +await page.locator('[data-testid="submit"]').click(); +``` +This is the one place migrating *from* Playwright legitimately *adds* AI: Playwright's +role/text/label engines don't exist in understudy, so a semantic locator with no obvious CSS +equivalent becomes an `act()`. + +### 4.4 Login / secrets → `variables` + +```typescript +// Playwright: hardcoded creds + #id fills +await page.fill("#username", "tomsmith"); +await page.fill("#password", "SuperSecretPassword!"); +await page.click("button[type='submit']"); +``` +```typescript +// Stagehand: keep the stable #id fills; move secrets out of source +await page.locator("#username").fill(process.env.APP_USER!); +await stagehand.act("type %password% into the password field", { + variables: { password: process.env.APP_PASS! }, +}); +await page.locator("button[type='submit']").click(); +``` +For repeat runs prefer a **Browserbase Context** (§5) so you log in once and reuse the auth state. + +### 4.5 Assertions (`expect`) → explicit checks + +```typescript +// Playwright +await expect(page.locator("#flash")).toContainText("You logged into a secure area!"); +``` +```typescript +// Stagehand — expect() is not available; read + throw +const flash = (await page.locator("#flash").textContent())?.trim() ?? ""; +if (!flash.includes("You logged into a secure area!")) { + throw new Error(`assertion failed: ${flash}`); +} +``` +Note in the summary that this loses Playwright's web-first auto-retry; add an explicit +`waitForSelector("#flash")` first if the element appears asynchronously. + +### 4.6 Network interception / `waitForResponse` (gap — flag) + +`page.route(...)` (blocking/mocking) and `page.waitForResponse(...)` have **no Stagehand surface**. +Honest treatments, in order of preference: +1. **Drop it if incidental** (e.g. `route` only blocked images for speed — Browserbase already + handles perf; `blockAds: true` covers ad noise). +2. **Restructure** to not depend on interception — e.g. read the rendered result with `extract` + instead of sniffing the XHR JSON. +3. **CDP passthrough** — `page.sendCDP("Network.enable", …)` exists for raw control. +4. Otherwise **flag needs-human-review** and keep the original behavior documented. + +### 4.7 `@playwright/test` files (out of scope as a test) + +The test-runner surface — `test()`, `test.describe`, `beforeEach`/fixtures (`{ page }`), projects, +retries, reporters, `expect()` web-first assertions — has **no Stagehand equivalent**. Don't fake a +test runner. Instead: +1. **Flag** the scaffolding as out of scope. +2. **Lift the browser logic** (page-object methods, the navigation/fill/click steps) into a plain + Stagehand `main()` script. +3. Map `expect()` → explicit read + throw (§4.5). + +--- + +## 5. Browserbase platform features + +Everything you'd set on a raw Browserbase session is reachable through +`browserbaseSessionCreateParams` (it is Browserbase's `SessionCreateParams`); local Chromium config +goes in `localBrowserLaunchOptions`. + +| Need | Playwright | Stagehand v3 | +|---|---|---| +| Headless / args / executable | `chromium.launch({ headless, args, executablePath })` | `localBrowserLaunchOptions: { headless, args, executablePath }` (LOCAL) | +| Viewport | `newContext({ viewport })` | `localBrowserLaunchOptions.viewport` (LOCAL) / `browserSettings.viewport` (BB); or `page.setViewportSize(w,h)` | +| Persistent auth / cookies | `newContext({ storageState })` | **Context**: `browserSettings.context: { id, persist: true }`, or `context.addCookies([...])` | +| Proxies | `newContext({ proxy })` | `proxies: true` (managed) or `[{ type, geolocation }]` | +| Stealth / fingerprint | (extra libs) | `browserSettings.advancedStealth: true` (Scale plan), `fingerprint`, Verified Sessions | +| Captcha solving | — | `browserSettings.solveCaptchas: true` (on by default) | +| Ad blocking | — | `browserSettings.blockAds: true` | +| Region | — | `region: "us-east-1" \| "us-west-2" \| "eu-central-1" \| "ap-southeast-1"` | +| Keep session alive | — | `keepAlive: true` | +| Downloads | `waitForEvent('download')` | `@browserbasehq/sdk` → `bb.sessions.downloads.list(id)` | + +```typescript +const stagehand = new Stagehand({ + env: "BROWSERBASE", + browserbaseSessionCreateParams: { + region: "us-east-1", + proxies: true, + keepAlive: true, + browserSettings: { + blockAds: true, + solveCaptchas: true, + context: { id: process.env.BB_CONTEXT_ID!, persist: true }, + }, + }, +}); +``` + +--- + +## 6. Python Playwright → TypeScript (the cross-language path) + +Target is always Stagehand **TypeScript**. Translate: + +- **Lifecycle**: `with sync_playwright() as p:` / `async with async_playwright() as p:` + + `p.chromium.launch()` / `browser.new_context()` / `context.new_page()` → `new Stagehand()` + + `await init()` + `stagehand.context.pages()[0]`, in a `try/finally { await close() }`. +- **Sync→async**: Python sync API has no `await` (`page.goto(...)`); Stagehand TS is **always + `await`** in an `async` function. Async-API Python already awaits. +- **snake_case → camelCase**, applying the §3 moves: + - `wait_for_selector`→`waitForSelector` (boolean), `wait_for_load_state`→`waitForLoadState`, + `wait_for_timeout`→`waitForTimeout`. + - `set_viewport_size({"width":w,"height":h})` → `setViewportSize(w, h)` (**positional**). + - `query_selector`/`query_selector_all` (`page.$`/`$$`) → **no equivalent** → `page.locator()` + (+`.nth()`/`.count()`) or `page.evaluate()`, or `extract()` for scrapes. + - `inner_text`→`innerText`, `text_content`→`textContent`, `inner_html`→`innerHtml`, + `input_value`→`inputValue`, `is_visible`/`is_checked`→`isVisible`/`isChecked`. + - `get_attribute` → **missing on locator** → `evaluate`/`extract`. `is_enabled` → missing. + - `select_option`→`selectOption`, `set_input_files`→`locator.setInputFiles`. + - `get_by_role`/`get_by_text`/`get_by_label`/`get_by_test_id` → **no getBy\*** → `act()` / + CSS-XPath `locator()` (`get_by_test_id` → `[data-testid=…]`). + - `set_extra_http_headers`→`setExtraHTTPHeaders`, `add_init_script`→`addInitScript`. +- **Assertions**: `from playwright.sync_api import expect`; `expect(loc).to_have_text(...)` → read + + throw (§4.5). +- **Schemas**: Pydantic models in an `extract` task → zod schemas. + +--- + +## 7. Gaps (no clean equivalent — call these out in the summary) + +- **Network interception / `route` / `waitForResponse` / `waitForRequest`** — no surface; CDP + passthrough or restructure (§4.6). +- **`expect()` / `@playwright/test` runner** — not a test framework; convert to read+throw and flag + the scaffolding (§4.5, §4.7). +- **Semantic `getBy*` locators & PW selector engines (`text=`/`role=`/`:has-text`)** — `act()` or + CSS/XPath (§4.3). +- **`page.$`/`$$`/`$eval`/`$$eval`, `page.content()`** — `page.evaluate()` or `extract()`. +- **`locator.getAttribute/.check/.uncheck/.press/.filter/.all/.waitFor/.isEnabled/.focus`, + `locator.locator()` chaining** — rewrite via `evaluate`/`act`/selector composition. +- **`page.keyboard`/`page.mouse` objects** — `page.keyPress`, `page.click(x,y)`/`page.scroll`. +- **`page.waitForURL/waitForNavigation/waitForFunction`** — poll `page.url()` / loop `evaluate`. +- **`page.pdf`, `setDefaultTimeout`, `exposeFunction`, `bringToFront`** — missing; substitute or flag. +- **Downloads / file-chooser events** — Browserbase downloads API; uploads via `locator.setInputFiles`. +- **Multiple `browser.newContext()` (isolation)** — one Stagehand = one context; use multiple instances. + +--- + +## Version notes (read before translating) + +- **AI methods are on the instance** — `stagehand.act/extract/observe` (and `stagehand.agent()`), + **not** `page.act()`. The page (`stagehand.context.pages()[0]`) is only for native page calls. +- **Signatures (3.6.0):** `act(string | Action, options?)`; `extract(instruction, schema, options?)` + positional; `observe(instruction, options?)` returns `Action[]`; `stagehand.agent(config).execute(...)`. +- **Models are `"provider/model"` strings** (e.g. `"anthropic/claude-sonnet-4-6"`) via the `model` + field; the object form `{ modelName, apiKey, … }` carries client options. +- **Page settling:** `page.waitForLoadState("domcontentloaded")` / `"load"` — **not `"networkidle"`** + (Playwright sources love `waitUntil: "networkidle"`; it times out on analytics/long-poll pages). +- **`setViewportSize` is positional** `(width, height)`; **`goto` timeout option is `timeoutMs`**; + **`waitForSelector` returns a boolean**. +- **zod is a peer dependency** (`^3.25.76 || ^4.2.0`): the consuming project must install `zod`. +- Confirm exact signatures against the installed version: . diff --git a/skills/playwright-to-stagehand/references/determinism.md b/skills/playwright-to-stagehand/references/determinism.md new file mode 100644 index 00000000..7d6065e9 --- /dev/null +++ b/skills/playwright-to-stagehand/references/determinism.md @@ -0,0 +1,106 @@ +# Keep, rewrite, or upgrade: the per-step decision + +The central judgment in a Playwright → Stagehand migration is, for **each step**, which of three +moves applies. Playwright is already at the deterministic end of the spectrum, but Stagehand v3's +page API (the *understudy* CDP engine) is only **partially** Playwright-compatible — so "keep" isn't +always "copy." The three moves: + +- **Port** — works ~as-is on the Stagehand page (mind small signature diffs). +- **Rewrite** — deterministic, but understudy spells it differently (route through `locator`, use + `evaluate`, positional args). +- **Upgrade** — no deterministic equivalent, or the selector is brittle → `act`/`extract`/`observe`. + (A subset of these are pure **gaps** to flag: `route`, events, `expect`, downloads — see api-mapping §7.) + +--- + +## The spectrum (least → most AI) + +| Level | Stagehand surface | Use when | Cost / reliability | +|---|---|---|---| +| **0. Port — native page calls** | `page.goto`, `page.url()`, `page.screenshot`, `page.evaluate`, `page.waitForLoadState`, `page.frames` | Playwright did it with no selector ambiguity and understudy implements it. | No AI, no cost. | +| **1. Port — stable locator** | `page.locator("#id" / css / xpath).fill/click/textContent/...` | The selector is stable (`#id`, `data-testid` via `[data-testid=…]`, a clean CSS/XPath). | No AI, no cost, deterministic. **Default for stable selectors.** | +| **1r. Rewrite — same intent, different shape** | page-level `click("#x")` → `locator("#x").click()`; `$$eval` → `evaluate`; `getByTestId` → `[data-testid]`; positional `setViewportSize` | The Playwright construct doesn't exist on understudy but has a deterministic equivalent. | No AI; mechanical. | +| **2. Observe → act (cached)** | `observe("…")` → replay `act(action)` | A brittle selector for a *repeatable* step; you want a concrete resolved action to persist. | One LLM call to resolve, **zero** on replay. | +| **3. Self-heal + cache** | `selfHeal: true`, `cacheDir` / `serverCache` | Production runs that replay deterministically but recover when the DOM drifts. | Cheapest steady-state; AI only on a cache miss/break. | +| **4. Per-step AI** | `stagehand.act("…")`, `stagehand.extract("…", schema)` | Brittle selector, a list scrape, a `getByRole/Text/Label` with no clean CSS equivalent, or markup that varies. | One LLM call per step. Inspectable. | +| **5. Autonomous** | `stagehand.agent().execute("…")` | The flow is open-ended / unknown at authoring time. **Rare** — a Playwright source already encodes the steps. | Highest cost, lowest determinism. Use sparingly. | + +A good Playwright migration lives mostly at **Levels 0–1r** (port/rewrite what works) and reaches for +**2–4** only on brittle or semantic steps. Level 5 is uncommon: if the original was a fully scripted +flow, don't throw that determinism away for an agent loop. + +--- + +## Decision tree (apply per Playwright step) + +``` +Is it a native page call understudy implements (goto, screenshot, evaluate, waitForLoadState, frames)? +├─ YES → Port it onto the Stagehand page (mind signature diffs) (Level 0) +└─ NO → It selects/acts on an element. Does understudy support that exact call? + ├─ NO, but there's a deterministic equivalent + │ (page.click(sel)→locator(sel).click(); $$eval→evaluate; getByTestId→[data-testid]) → Rewrite (Level 1r) + └─ It resolves an element by selector. Is the selector stable AND CSS/XPath-expressible? + ├─ YES → Port: page.locator(sel). (Level 1) + └─ NO → brittle CSS / nth-child / text-XPath, OR a semantic getByRole/Text/Label, OR a list scrape. + Is it a structured READ? + ├─ YES → extract("…", zodSchema) (Level 4) + └─ NO → action. Repeats / needs replay? + ├─ YES → observe("…") once, persist, replay act(action) (Level 2/3) + └─ NO → act("natural-language instruction") (Level 4) +``` + +Reading a list/table is **always** `extract("…", schema)` — never reproduce a `$$eval` with +per-element selectors when one schema'd read survives markup churn. + +--- + +## The three failure modes to avoid + +1. **Over-AI-ifying** — replacing a stable `page.locator("#id").click()` with `act()`. Adds latency, + token cost, and non-determinism for nothing. Keep Levels 0–1r where the selector is stable. +2. **Under-migrating** — copying brittle CSS/XPath verbatim. It compiles and runs, but you carried the + brittleness over and gained only a cloud browser. Upgrade fragile selectors. +3. **Copying what doesn't exist** — emitting `page.click("#x")`, `page.getByRole(...)`, `page.$$eval`, + `page.keyboard.*`, or `expect(...)` as if understudy supported them. It won't compile (or won't + behave). Rewrite (Level 1r) or upgrade (Level 4), or flag the gap. + +--- + +## The observe → act caching pattern (Level 2/3) + +`observe()` turns a natural-language instruction into a concrete `Action` (selector + method + args). +Feeding that `Action` back into `act()` executes it **without another LLM call** — deterministic +replay for a brittle-but-repeatable step. + +```typescript +// Resolve once (one LLM call) +const [submit] = await stagehand.observe("the submit button"); +// Replay deterministically (no LLM call) — persist `submit` to reuse across runs +if (submit) await stagehand.act(submit); +``` + +For production, layer caching + self-heal so steady-state runs are deterministic but recover from drift: + +```typescript +const stagehand = new Stagehand({ + env: "BROWSERBASE", + selfHeal: true, // re-resolve with AI only when a cached selector breaks + // serverCache defaults on under BROWSERBASE; cacheDir for local persistence +}); +``` + +--- + +## Best practices to bake into rewrites + +- **Replace arbitrary sleeps.** `page.waitForTimeout(3000)` → `page.waitForSelector(...)` or + `page.waitForLoadState("domcontentloaded")`. +- **Avoid `"networkidle"`.** Playwright sources love `waitUntil: "networkidle"`; it times out on + Google/analytics/long-poll pages. Use `"domcontentloaded"` / `"load"`, or wait for a specific element. +- **AI methods are on the instance** — `stagehand.act/extract/observe`, **not** `page.act`. The page + (`stagehand.context.pages()[0]`) is only for native + locator calls. +- **Scope `extract`/`observe`** with `{ selector: "//main" }` to cut noise/cost on big pages. +- **Lock the viewport** so cached selectors stay valid: `page.setViewportSize(width, height)` — + **positional** args, not Playwright's `{ width, height }` object. +- **Secrets** move from hardcoded strings into `act("…%key%…", { variables: { key } })` + `process.env`. +- **Anchor `act` prompts to visible UI labels** ("click the *Sign in* button"), not internal structure. diff --git a/skills/playwright-to-stagehand/references/guide.md b/skills/playwright-to-stagehand/references/guide.md new file mode 100644 index 00000000..d91b7862 --- /dev/null +++ b/skills/playwright-to-stagehand/references/guide.md @@ -0,0 +1,116 @@ +# Playwright → Stagehand on Browserbase: the migration guide + +The human-readable companion to the `/playwright-to-stagehand` skill. Read this for the *why*; read +[`api-mapping.md`](api-mapping.md) for the mechanical *how* and [`determinism.md`](determinism.md) +for the per-step decision. + +--- + +## The philosophy shift + +Playwright and browser-use sit at opposite ends of the same spectrum, so the two migrations pull in +opposite directions: + +- **browser-use is agentic-by-default** — an LLM decides every action. Migrating it *removes* AI + wherever the flow is actually known. +- **Playwright is deterministic-by-default** — you wrote every selector and every step. It's fast, + cheap, and inspectable. Its weaknesses are **brittleness** (hardcoded CSS/XPath selectors break the + moment the DOM shifts) and **no cloud story** (a local browser has no proxy/stealth/captcha/scale). + +A Playwright → Stagehand migration is therefore **not** "sprinkle AI on everything," and it is **not** +a verbatim copy either. Stagehand v3 does **not** run Playwright — it drives the browser with an +internal CDP engine called **understudy** that exposes a **Playwright-*flavored* but only *partially* +compatible** page API. So every step is one of three moves: + +1. **Port** — the compatible subset moves over ~1:1: `page.goto`, `page.locator(css/xpath)` + + `.fill/.click/.textContent/.selectOption/.count/.nth`, `page.evaluate`, `page.screenshot`, + `page.frames`, `waitForSelector`/`waitForLoadState`. +2. **Rewrite** — deterministic constructs that exist in a *different shape*: page-level + `page.click("#x")` → `page.locator("#x").click()` (page-level click is coordinate-based); + `$$eval`/`$`/`$$`/`page.content()` → `page.evaluate(...)`; `getByTestId("x")` → + `locator('[data-testid="x"]')`; `setViewportSize({w,h})` → positional `(w,h)`. +3. **Upgrade or flag** — constructs with *no deterministic equivalent*: + - brittle selectors / list scrapes → **upgrade** to `act()` / `observe()→act()` / `extract()`; + - semantic `getByRole`/`getByText`/`getByLabel` (no understudy equivalent) → `act()` or a CSS/XPath + locator; + - `page.route()`, network interception, `waitForResponse`, `page.on(event)`, `expect()` + assertions, downloads, multi-context, `@playwright/test` scaffolding → **flag needs-human-review** + (with a CDP/Browserbase-platform substitute where one exists). + +Then move the session to **Browserbase** for proxies, stealth, captcha-solving, contexts, and scale. + +The judgment that makes a migration good (or bad) is **classifying each step into the right move** — +especially not over-AI-ifying what has a clean deterministic port/rewrite, and not faking what's +genuinely a gap. + +--- + +## The triage table + +| Source construct | Move | Notes | +|---|---|---| +| `page.goto`, `page.evaluate`, `page.screenshot`, `page.frames`, `waitForSelector/LoadState` | **Port** | mind small signature diffs (`timeoutMs`, positional, boolean returns) | +| `page.locator("#id"/css/xpath).fill/click/textContent/selectOption/count/nth/isVisible` | **Port** | the compatible locator subset | +| `page.getByTestId("x")` | **Rewrite (stays deterministic)** | `locator('[data-testid="x"]')` | +| `page.click("#x")` / `page.fill("#x")` (page-level selector actions) | **Rewrite** | → `page.locator("#x").click()/.fill()` — page-level click/type are coordinate/focus-based | +| `$$eval`/`$eval`/`$`/`$$`, `page.content()` | **Rewrite (deterministic) or Upgrade (if brittle)** | `page.evaluate(...)` for a stable read; `extract()` for a fragile/list scrape | +| `setViewportSize({w,h})`, `goto({timeout})`, `page.keyboard`/`page.mouse` | **Rewrite (signature)** | positional `setViewportSize(w,h)`; `timeoutMs`; `page.keyPress`/`page.click(x,y)` | +| `getByRole`/`getByText`/`getByLabel`/`getByPlaceholder`, `text=`/`role=` engine selectors | **Upgrade (usually `act`)** | no semantic locators / PW engines in understudy → `act()`, or CSS/XPath if obvious | +| brittle CSS chains / `nth-child` / text-coupled XPath / list scrapes | **Upgrade** | `act()` / `observe→act` / `extract()` — the resilience win | +| `locator.getAttribute/.check/.press/.filter/.all/.waitFor`, `locator(a).locator(b)` chaining | **Rewrite/Upgrade** | not on understudy locator → `evaluate`/`act`/compose one selector | +| `page.route`/`on(event)`/`waitForResponse`/`waitForRequest`, `expect()`, downloads, multi-context, `@playwright/test` | **Flag (needs human review)** | CDP passthrough or Browserbase platform feature where possible | + +Over-AI-ifying is a real failure mode: turning a stable `page.locator("#id").click()` into +`act("click the button")` adds latency, cost, and *non-determinism* for zero benefit. But the inverse +trap exists too — `getByRole`/`getByText` have **no** understudy equivalent, so leaving them as a +plain `locator()` won't work; those genuinely need `act()` or a real CSS/XPath. Classify honestly. + +--- + +## What you gain (put this in the migration summary) + +- **Resilience** — `act`/`extract` + `selfHeal` survive DOM drift that breaks a hardcoded selector. +- **Cloud + scale** — Browserbase sessions: proxies, advanced stealth, captcha solving, parallelism. +- **Auth reuse** — Browserbase **Contexts** persist login across runs (vs re-running a fragile login). +- **Structured reads** — `extract("…", zodSchema)` returns typed data without per-field selectors. +- **Determinism where it matters** — ported selectors and cached `observe→act` cost no LLM calls. + +The honest trade-offs to name in the summary: + +- **Cost/latency** on any step moved to AI. Keep the deterministic path where the selector is stable. +- **No test runner.** Stagehand is automation, not `@playwright/test`. `test()`, fixtures, projects, + retries, reporters, and `expect()` web-first assertions have **no equivalent** — see below. +- **No network interception.** `page.route()` / mocking / `waitForResponse` don't map — flag them. +- **Partial page API.** `getBy*`, `$$eval`, `keyboard`/`mouse` objects, `waitForURL`, etc. need a + rewrite, not a copy (api-mapping §3). + +--- + +## `@playwright/test` files are out of scope (convert the browser logic only) + +If the source imports from `@playwright/test` (or Python `playwright.sync_api`'s `expect`), it's a +**test**, not an automation script. The test framework — `test()`, `test.describe`, `beforeEach`, +fixtures (`{ page }`), projects, retries, reporters, `expect(locator).toHaveText(...)` — has no +Stagehand equivalent. Don't fabricate one. Instead: + +1. **Flag** the test scaffolding as out of scope / needs-human-review. +2. **Extract and convert only the browser logic** (page-object methods, navigation/fill/click steps) + into a plain Stagehand script. +3. Map web-first assertions to honest checks: `expect(locator).toContainText("x")` → read the value + (`locator.textContent()` or `extract`) and `throw` if it doesn't match. Note that this loses + Playwright's auto-retry/auto-wait — add an explicit `waitForSelector` first if needed. + +--- + +## A recommended migration path + +1. **Inventory** the script: launch/context/page setup, every navigation, every selector + action, + every read, every assertion, and any network/route/download/test-runner usage. +2. **Classify each step** Port / Rewrite / Upgrade-or-flag with the triage table. +3. **Map the lifecycle**: `chromium.launch()` + `newContext()` + `newPage()` → one + `new Stagehand({ env: "BROWSERBASE" })` + `init()`; page via `stagehand.context.pages()[0]`. +4. **Port** the compatible skeleton; **rewrite** the different-shape constructs; **upgrade** the + brittle/semantic ones; **flag** the gaps. +5. **Verify against the installed package** before emitting — signatures drift; the live docs win. +6. **Summarize**: each step's move (with reasoning), gaps flagged, and the recommended next step + (a Context for auth, `selfHeal`/caching for production, proxies/stealth for protected targets). diff --git a/skills/playwright-to-stagehand/references/prompt.md b/skills/playwright-to-stagehand/references/prompt.md new file mode 100644 index 00000000..b4f34ccb --- /dev/null +++ b/skills/playwright-to-stagehand/references/prompt.md @@ -0,0 +1,71 @@ +# Self-contained prompt: migrate a Playwright script to Stagehand v3 + +Paste this whole block into any AI assistant, followed by your Playwright script. It's a +tool-agnostic distillation of the `/playwright-to-stagehand` skill. (Signatures are a Stagehand +3.6.x snapshot — tell the assistant to verify against the installed package / live docs.) + +--- + +You are migrating a **Playwright** browser-automation script (TypeScript/JavaScript **or** Python) +to an idiomatic **Stagehand v3 (TypeScript)** script running on **Browserbase**. The target is always +TypeScript, even if the source is Python. + +**Mental model.** Stagehand v3 does **not** run Playwright. It uses an internal CDP engine +("understudy") with a **Playwright-flavored but only partially compatible** page API. Every step is +one of three moves: + +1. **Port** (works ~as-is): `page.goto`, `page.locator(css/xpath).fill/click/textContent/selectOption/count/nth/isVisible`, + `page.evaluate`, `page.screenshot`, `page.frames`, `page.waitForSelector` (returns boolean), + `page.waitForLoadState`. AI methods are on the **instance** (`stagehand.act/extract/observe`), not the page. +2. **Rewrite** (deterministic, different shape): page-level `page.click(sel)`/`fill(sel)`/`type`/`hover`/`selectOption` + → `page.locator(sel).click()/.fill()/…` (page-level click/type are coordinate/focus-based); + `page.$$eval`/`$eval`/`$`/`$$`/`page.content()` → `page.evaluate(...)`; `getByTestId("x")` → + `locator('[data-testid="x"]')`; `setViewportSize({w,h})` → positional `setViewportSize(w,h)`; + `goto(url,{timeout})` → `{timeoutMs}`; `page.keyboard`/`page.mouse` → `page.keyPress`/`page.click(x,y)`; + `page.waitForURL` → poll `page.url()`. +3. **Upgrade or flag** (no deterministic equivalent): + - brittle selectors (long CSS, `nth-child`, text-coupled XPath) and list scrapes → `stagehand.act("…")`, + `stagehand.observe()`→`act(action)` (cached), or `stagehand.extract("…", zodSchema)`; + - semantic `getByRole`/`getByText`/`getByLabel`/`getByPlaceholder` and `text=`/`role=` engine + selectors (no understudy equivalent) → `act()` or a CSS/XPath `locator()`; + - **flag needs-human-review** (no surface): `page.route()`/request mocking, `waitForResponse`/`waitForRequest`, + `page.on(event)` (only `"console"` is supported), `expect()` assertions, downloads + (`waitForEvent('download')`), `page.pdf`, multiple `browser.newContext()` isolation. + +**Scope gate first.** If the source imports `@playwright/test` (TS) or `expect` from +`playwright.sync_api` (Py), it's a **test**, not an automation script. The test runner — `test()`, +`describe`, fixtures (`{ page }`), projects, retries, reporters, web-first `expect()` — has no +Stagehand equivalent. Flag the scaffolding as out of scope and convert only the browser logic; +map `expect(locator).toContainText("x")` to: read `locator.textContent()` (or `extract`) and `throw` +on mismatch. + +**Lifecycle.** `chromium.launch()` + `browser.newContext()` + `context.newPage()` collapse to: +```typescript +import { Stagehand } from "@browserbasehq/stagehand"; +import { z } from "zod"; // when extracting +const stagehand = new Stagehand({ env: "BROWSERBASE", model: "anthropic/claude-sonnet-4-6" }); +await stagehand.init(); +try { + const page = stagehand.context.pages()[0]; // no stagehand.page getter + // … ported / rewritten / upgraded steps … +} finally { + await stagehand.close(); +} +``` +Browserbase session options (proxies, stealth, captcha, persistent context) go in +`browserbaseSessionCreateParams`; local Chromium options in `localBrowserLaunchOptions`. + +**Rules.** +- Model is a `"provider/model"` string (`"anthropic/claude-sonnet-4-6"`, `"openai/gpt-5"`, …). +- Default `env: "BROWSERBASE"`; show `env: "LOCAL"` as the dev option. +- `extract` takes `(instruction, zodSchema, options?)` and supports a top-level `z.array(...)`. +- Secrets: `act("…%key%…", { variables: { key } })` + `process.env`; never hardcode. +- Replace `waitForTimeout` sleeps with real waits; never use `waitUntil: "networkidle"` (use `"domcontentloaded"`). +- Don't over-AI-ify stable selectors; don't copy `getBy*`/`page.click(sel)`/`$$eval`/`expect` verbatim. + +**Output:** +1. The runnable Stagehand v3 TypeScript (plus `package.json` + `.env` if asked). +2. A **migration summary**: source flavor (TS/JS or Python; plain vs `@playwright/test`); each step's + move (port/rewrite/upgrade) with reasoning; **needs-human-review** items (route/interception, + `waitForResponse`, test scaffolding, downloads, multi-context); and the recommended next step + (a Browserbase Context for auth, `selfHeal`/caching for production, proxies/stealth for protected targets). diff --git a/skills/playwright-to-stagehand/references/trace-assisted.md b/skills/playwright-to-stagehand/references/trace-assisted.md new file mode 100644 index 00000000..52eb9c40 --- /dev/null +++ b/skills/playwright-to-stagehand/references/trace-assisted.md @@ -0,0 +1,51 @@ +# The trace-assisted path (optional) + +Most Playwright migrations are a static rewrite — Playwright scripts are explicit, so the source code +tells you everything. Reach for this run-and-observe path **only** when a static rewrite is +unreliable: + +- the script leans on **network interception** (`page.route` / `waitForResponse`) you can't cleanly + drop, and you need to see what actually loads; +- it's **flaky** or timing-dependent and you can't tell which waits matter; +- it uses **semantic/engine selectors** (`getByRole`, `text=`) and you want to confirm what the AI + resolves them to before committing to `act()` vs a CSS locator; +- the page is heavily dynamic and you're unsure whether a step needs `act()` or a stable `locator`. + +**Never run anything without the user's go-ahead.** + +--- + +## Workflow + +1. **Do the static rewrite first.** Produce the best Port/Rewrite/Upgrade version from the source. + Mark the steps you're unsure about (the gaps and the `act()` guesses). + +2. **Run it on Browserbase.** With `env: "BROWSERBASE"`, every run is a real cloud session with a + replayable trace. Run the converted script (or the original, if you also want a baseline). + +3. **Read the session trace / logs.** Pull what happened and compare it to your assumptions: + - **Browserbase session inspector / replay** — the visual replay + network panel for the session. + - **`@browserbasehq/sdk`** — `bb.sessions.logs.list(sessionId)` for the structured event log, and + `bb.sessions.downloads.list(sessionId)` if the original captured downloads. + - **The sibling `browser-trace` skill** — a fuller CDP trace (network firehose, screenshots, DOM + dumps) bucketed per page, if you want deeper signal than the session logs. + - Stagehand's own `verbose: 2` logging and `cacheStatus` on `act`/`observe` results (HIT/MISS). + +4. **Refine from observed behavior.** Use the trace to: + - confirm which `act()` calls resolved to the intended element (and whether a stable CSS/XPath + `locator` would be tighter/cheaper); + - replace any `act()` whose resolved selector is stable with an `observe()→act(action)` cached pair; + - see which XHR/responses the dropped `route`/`waitForResponse` actually depended on, and decide + whether to read the rendered result with `extract` or fall back to `page.sendCDP("Network.…")`; + - find the real settle signal to replace a brittle wait. + +5. **Re-verify.** Re-run the refined script; confirm the outcome matches the original's intent. Note + in the migration summary what the trace changed. + +--- + +## When NOT to bother + +If the source is a clean, deterministic Playwright script with stable selectors and no interception, +a static rewrite is faster and just as correct — skip the trace pass. This path costs a live session +and a round-trip; spend it only where the source's behavior is genuinely opaque. From f1410dfe88e7dc49b2577823eba930df0ad690a4 Mon Sep 17 00:00:00 2001 From: Shrey Pandya Date: Fri, 26 Jun 2026 14:23:23 -0700 Subject: [PATCH 2/2] fix(playwright-to-stagehand): default reads/secret-fills to deterministic, not AI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The decision tree routed every read to extract() and showed the password fill via act()+variables — both over-AI-ify deterministic code. Corrected: - Reads: stable-selector scrapes default to a deterministic page.evaluate(...) (zero AI/zero cost); extract() reserved for brittle/variable markup or wanted DOM-drift resilience. ($$eval has no understudy equivalent, but evaluate does.) - Secrets: stable fields fill deterministically via locator(sel).fill(process.env...) — no LLM call, and the secret never enters a prompt; act()+variables only when the field needs AI resolution. - Updated SKILL.md (triage, checklist, mistakes), determinism.md (read decision + failure modes), api-mapping.md (§4.1, §4.4), EXAMPLES.md (#1, #2), prompt.md. Re-verified with skill-only reconversions on the corrected skill: case 01 (scrape) now emits page.evaluate (0 extract), case 03 (login) fills the password deterministically (0 act) — both tsc-clean and run live with correct output. Co-Authored-By: Claude Opus 4.8 (1M context) --- skills/playwright-to-stagehand/EXAMPLES.md | 38 ++++++++++----- skills/playwright-to-stagehand/SKILL.md | 30 +++++++----- .../references/api-mapping.md | 38 +++++++++++---- .../references/determinism.md | 46 ++++++++++++------- .../references/prompt.md | 16 +++++-- 5 files changed, 115 insertions(+), 53 deletions(-) diff --git a/skills/playwright-to-stagehand/EXAMPLES.md b/skills/playwright-to-stagehand/EXAMPLES.md index 5e6d9a24..ffd9a081 100644 --- a/skills/playwright-to-stagehand/EXAMPLES.md +++ b/skills/playwright-to-stagehand/EXAMPLES.md @@ -52,11 +52,12 @@ console.log(quotes); await browser.close(); ``` -**After — Stagehand v3** +**After — Stagehand v3 (default: deterministic `page.evaluate`)** +`$$eval` has no understudy equivalent, but the selectors here are stable, so the right port is a +deterministic `page.evaluate` — no LLM call, no cost, same result. ```typescript import "dotenv/config"; import { Stagehand } from "@browserbasehq/stagehand"; -import { z } from "zod"; async function main() { const stagehand = new Stagehand({ env: "BROWSERBASE", model: "anthropic/claude-sonnet-4-6" }); @@ -64,11 +65,12 @@ async function main() { try { const page = stagehand.context.pages()[0]; await page.goto("https://quotes.toscrape.com/"); // ported; dropped "networkidle" - await page.waitForLoadState("domcontentloaded"); // settle before the AI snapshot - const quotes = await stagehand.extract( // upgraded: $$eval scrape → extract - "extract the first 5 quotes with their text and author", - z.array(z.object({ text: z.string(), author: z.string() })), + const quotes = await page.evaluate(() => // $$eval → evaluate (deterministic) + Array.from(document.querySelectorAll("div.quote")).slice(0, 5).map((el) => ({ + text: el.querySelector("span.text")?.textContent?.trim() ?? "", + author: el.querySelector("small.author")?.textContent?.trim() ?? "", + })), ); console.log(quotes); } finally { @@ -78,12 +80,24 @@ async function main() { main().catch((e) => { console.error(e); process.exit(1); }); ``` +**Alternative — `extract` (only if the markup is brittle/variable, or you want DOM-drift resilience)** +Trades an LLM call per read for resilience. Don't use it for a stable table like this one. +```typescript +import { z } from "zod"; +// const page = stagehand.context.pages()[0]; await page.goto(...); await page.waitForLoadState("domcontentloaded"); +const quotes = await stagehand.extract( + "extract the first 5 quotes with their text and author", + z.array(z.object({ text: z.string(), author: z.string() })), +); +``` + --- -## 2. Login form (TS): `#id` selectors kept, secrets → `variables`, `expect` → read + throw +## 2. Login form (TS): `#id` selectors kept deterministic, secrets → `process.env`, `expect` → read + throw Stable `#id` selectors are **ported** through `locator` (page-level `page.fill(sel)` doesn't exist). -Hardcoded creds become `variables` + env. `expect()` isn't available — read and throw. +The password is stable too, so it's a **deterministic** `locator.fill(process.env…)` — that keeps the +secret out of any prompt without an LLM call (no `act` needed). `expect()` isn't available — read and throw. **Before — Playwright (TS)** ```typescript @@ -112,12 +126,12 @@ async function main() { const page = stagehand.context.pages()[0]; await page.goto("https://the-internet.herokuapp.com/login"); - // #username is stable → port via locator. Username is not secret here, but the password is: + // Stable #id fields → deterministic locator.fill; secrets from process.env (never sent to an LLM). await page.locator("#username").fill(process.env.APP_USER ?? "tomsmith"); - await stagehand.act("type %password% into the password field", { - variables: { password: process.env.APP_PASS ?? "SuperSecretPassword!" }, - }); + await page.locator("#password").fill(process.env.APP_PASS ?? "SuperSecretPassword!"); await page.locator("button[type='submit']").click(); // page.click(sel) → locator(sel).click() + // (Only if the field had no clean selector would you reach for + // stagehand.act("type %password%…", { variables: { password } }) to let AI locate it.) // expect() has no equivalent → read + throw (add waitForSelector since #flash appears post-nav) await page.waitForSelector("#flash"); diff --git a/skills/playwright-to-stagehand/SKILL.md b/skills/playwright-to-stagehand/SKILL.md index fbf22148..7d2028ba 100644 --- a/skills/playwright-to-stagehand/SKILL.md +++ b/skills/playwright-to-stagehand/SKILL.md @@ -92,18 +92,24 @@ Apply the decision tree in determinism.md. For each step: - **Port** — native calls understudy implements (`goto`, `screenshot`, `evaluate`, `waitForLoadState`, `frames`) and stable `page.locator(css/xpath).fill/click/textContent/…`. Keep ~1:1 (mind signature diffs). - **Rewrite** — deterministic but different shape: page-level `page.click(sel)`/`fill(sel)` → - `page.locator(sel).click()/.fill()`; `$$eval`/`$`/`$$`/`content()` → `page.evaluate(...)`; - `getByTestId("x")` → `locator('[data-testid="x"]')`; `setViewportSize({w,h})` → positional `(w,h)`. -- **Upgrade** — brittle selectors (long CSS, `nth-child`, text-XPath) → `act("…")` / `observe()`→`act()`; - list/table reads (`$$eval` scrapes) → `extract("…", zodSchema)`; semantic `getByRole/getByText/getByLabel` + `page.locator(sel).click()/.fill()`; **stable-selector reads** `$$eval`/`$`/`$$`/`content()` → + `page.evaluate(...)` (deterministic, zero AI — the **default** for scrapes whose selectors don't + change); `getByTestId("x")` → `locator('[data-testid="x"]')`; `setViewportSize({w,h})` → positional `(w,h)`. +- **Upgrade** — only what's genuinely fragile/semantic: brittle selectors (long CSS, `nth-child`, + text-XPath) → `act("…")` / `observe()`→`act()`; **brittle or variable-markup reads, or reads you + want to survive DOM drift** → `extract("…", zodSchema)`; semantic `getByRole/getByText/getByLabel` (no understudy equivalent) → `act()` or a CSS/XPath locator. - **Flag** — `page.route()`/interception, `waitForResponse/Request`, `page.on(event)`, `expect()` assertions, downloads, multiple contexts → needs-human-review (api-mapping §7). +- **Secrets** — for a **stable** field, fill deterministically: `page.locator("#password").fill(process.env.PASS!)` + (no LLM call, secret never enters a prompt). Use `act("…%key%…", { variables })` only when the field + needs AI resolution. - **Arbitrary sleep** (`waitForTimeout`) → replace with a real wait. -Default to **port/rewrite** for stable selectors; upgrade only what's fragile or semantic. -Over-AI-ifying stable locators is a failure — but copying `getBy*`/`page.click(sel)`/`$$eval` -verbatim won't work either (those APIs don't exist on understudy). +Default to **port/rewrite** for stable selectors and stable reads; reach for `act`/`extract` only when +a step is fragile, semantic, or you explicitly want DOM-drift resilience. Two symmetric failures: +**over-AI-ifying** deterministic code (a stable `$$eval` → `extract`, a stable `#id` fill → `act`), and +**copying what doesn't exist** (`getBy*`/`page.click(sel)`/`$$eval` verbatim — not on understudy). ### 5. Produce the Stagehand v3 rewrite **First, verify the API.** Confirm the exact signatures against the installed package @@ -198,16 +204,18 @@ main().catch((err) => { console.error(err); process.exit(1); }); - [ ] AI methods are on the **instance** (`stagehand.act/extract/observe`), not the page. - [ ] Selector actions routed through `page.locator(sel)` (no page-level `page.click(sel)`/`page.fill(sel)`); `getBy*`/`$$eval`/`page.content()` rewritten (locator/`evaluate`) or upgraded (`act`/`extract`). - [ ] Stable selectors ported deterministically; only **brittle/semantic** ones upgraded to `act`/`extract`. -- [ ] List/table scrapes use `extract` + a zod schema; `zod` is in dependencies. +- [ ] Stable-selector reads use deterministic `page.evaluate(...)`; `extract` + zod reserved for brittle/variable markup or wanted DOM-drift resilience (`zod` in deps when used). - [ ] Model is a `"provider/model"` string; the matching provider key is in `.env`. -- [ ] Secrets use `variables` + `process.env`; nothing hardcoded. +- [ ] Secrets out of source via `process.env`; **stable** fields filled deterministically (`locator(sel).fill(process.env…)`), `act`+`variables` only for AI-resolved fields; nothing hardcoded. - [ ] `waitForTimeout` sleeps replaced with real waits; no `"networkidle"`. - [ ] `init()` / `close()` present; `close()` in `finally`. - [ ] Gaps flagged in the summary: `route()`/network interception, `waitForResponse`, `@playwright/test` scaffolding, multi-context, downloads. ## Common mistakes to avoid -- **Over-AI-ifying** — replacing a stable `page.locator("#id").click()` with `act()`. Adds cost, - latency, and non-determinism for nothing. Keep ported selectors. +- **Over-AI-ifying** — turning deterministic code into AI calls: a stable-selector `$$eval` scrape → + `extract()` (use `page.evaluate(...)`); a stable `page.locator("#id").click()` → `act()`; a stable + `#password` fill → `act("type %password%…")` (use `locator("#password").fill(process.env.PASS!)` — + deterministic and the secret never enters a prompt). Adds cost/latency/non-determinism for nothing. - **Emitting APIs understudy doesn't have** — `page.click(sel)` (page-level is coordinate-based), `page.getByRole/getByText/getByLabel`, `page.$$eval`/`$`/`$$`, `page.keyboard`/`page.mouse`, `page.waitForResponse`, `expect()`. Rewrite to `locator`/`evaluate`/`keyPress`/`act`, or flag the diff --git a/skills/playwright-to-stagehand/references/api-mapping.md b/skills/playwright-to-stagehand/references/api-mapping.md index 760e6753..8db929bb 100644 --- a/skills/playwright-to-stagehand/references/api-mapping.md +++ b/skills/playwright-to-stagehand/references/api-mapping.md @@ -157,7 +157,10 @@ different shape), or **Upgrade/Flag** (no deterministic equivalent → `act`/`ex ## 4. Detailed translations -### 4.1 Brittle list scrape → `extract()` (the highest-value upgrade) +### 4.1 List scrape → `page.evaluate` (default) or `extract` (when brittle) + +`$$eval`/`querySelectorAll` have no understudy equivalent, so a scrape must be re-expressed. **Choose +by selector stability — don't reflexively reach for AI.** **Before — Playwright (TS)** ```typescript @@ -168,7 +171,20 @@ const quotes = await page.$$eval("div.quote", (els) => })), ); ``` -**After — Stagehand** + +**Default — deterministic `page.evaluate` (stable selectors, zero AI, zero cost):** +```typescript +const quotes = await page.evaluate(() => + Array.from(document.querySelectorAll("div.quote")).slice(0, 5).map((el) => ({ + text: el.querySelector("span.text")?.textContent?.trim() ?? "", + author: el.querySelector("small.author")?.textContent?.trim() ?? "", + })), +); +``` +Same CSS read as the original, no LLM call. This is the right move whenever the selectors are stable — +which is most scrapes. (Verified to run on understudy.) + +**Upgrade — `extract` (brittle/variable markup, or you want DOM-drift resilience):** ```typescript import { z } from "zod"; const quotes = await stagehand.extract( @@ -176,9 +192,9 @@ const quotes = await stagehand.extract( z.array(z.object({ text: z.string(), author: z.string() })), ); ``` -`$$eval` has no understudy equivalent, and the per-element CSS chain is exactly what breaks on DOM -drift. One schema'd read replaces it. (A deterministic `$$eval` with *stable* selectors can instead -be a 1:1 `page.evaluate(...)` — only upgrade when the selectors are brittle.) +`extract` trades an LLM call per read for resilience — it survives markup churn that would break the +selectors. Reserve it for fragile/variable pages or production scrapes you need to keep working as the +DOM changes, **not** for a stable table. ### 4.2 Page-level selector action → `locator` (mechanical rewrite, stays deterministic) @@ -207,7 +223,7 @@ This is the one place migrating *from* Playwright legitimately *adds* AI: Playwr role/text/label engines don't exist in understudy, so a semantic locator with no obvious CSS equivalent becomes an `act()`. -### 4.4 Login / secrets → `variables` +### 4.4 Login / secrets ```typescript // Playwright: hardcoded creds + #id fills @@ -216,12 +232,18 @@ await page.fill("#password", "SuperSecretPassword!"); await page.click("button[type='submit']"); ``` ```typescript -// Stagehand: keep the stable #id fills; move secrets out of source +// Stagehand: stable #id fields → deterministic locator.fill; secrets out of source via process.env. +// A deterministic fill is best for a secret — the value (and the field) never go to an LLM at all. await page.locator("#username").fill(process.env.APP_USER!); +await page.locator("#password").fill(process.env.APP_PASS!); +await page.locator("button[type='submit']").click(); +``` +Only when a field must be **resolved by AI** (brittle/semantic selector, no clean `#id`) use `act` +with `variables` — that keeps the secret out of the prompt while the LLM locates the field: +```typescript await stagehand.act("type %password% into the password field", { variables: { password: process.env.APP_PASS! }, }); -await page.locator("button[type='submit']").click(); ``` For repeat runs prefer a **Browserbase Context** (§5) so you log in once and reuse the auth state. diff --git a/skills/playwright-to-stagehand/references/determinism.md b/skills/playwright-to-stagehand/references/determinism.md index 7d6065e9..ec685879 100644 --- a/skills/playwright-to-stagehand/references/determinism.md +++ b/skills/playwright-to-stagehand/references/determinism.md @@ -35,29 +35,38 @@ flow, don't throw that determinism away for an agent loop. ``` Is it a native page call understudy implements (goto, screenshot, evaluate, waitForLoadState, frames)? -├─ YES → Port it onto the Stagehand page (mind signature diffs) (Level 0) -└─ NO → It selects/acts on an element. Does understudy support that exact call? - ├─ NO, but there's a deterministic equivalent - │ (page.click(sel)→locator(sel).click(); $$eval→evaluate; getByTestId→[data-testid]) → Rewrite (Level 1r) - └─ It resolves an element by selector. Is the selector stable AND CSS/XPath-expressible? - ├─ YES → Port: page.locator(sel). (Level 1) - └─ NO → brittle CSS / nth-child / text-XPath, OR a semantic getByRole/Text/Label, OR a list scrape. - Is it a structured READ? - ├─ YES → extract("…", zodSchema) (Level 4) - └─ NO → action. Repeats / needs replay? - ├─ YES → observe("…") once, persist, replay act(action) (Level 2/3) - └─ NO → act("natural-language instruction") (Level 4) +├─ YES → Port onto the Stagehand page (mind signature diffs) (Level 0) +└─ NO → Is it a READ (scrape / get text / list)? + ├─ YES → Are the selectors stable (#id, data-*, clean CSS/XPath)? + │ ├─ YES → page.evaluate(...) — deterministic, zero AI, zero cost (Level 1r) ← DEFAULT + │ └─ NO → brittle/variable markup, OR you explicitly want DOM-drift + │ resilience → extract("…", zodSchema) (Level 4) + └─ NO → it's an ACTION on an element. Does understudy support the call as written? + ├─ Stable selector, needs reshaping (page.click(sel)→locator(sel).click(); + │ getByTestId→[data-testid]) → Rewrite (Level 1r) + ├─ Stable CSS/XPath selector → page.locator(sel). (Level 1) + └─ Brittle selector / semantic getByRole/Text/Label / no clean selector: + repeats & needs replay? → observe("…") once, replay act(action) (Level 2/3) + else → act("natural-language instruction") (Level 4) ``` -Reading a list/table is **always** `extract("…", schema)` — never reproduce a `$$eval` with -per-element selectors when one schema'd read survives markup churn. +**Reads default to deterministic, not AI.** `$$eval`/`querySelectorAll` have no understudy +equivalent, but the right port for **stable** selectors is `page.evaluate(...)` — it's free, instant, +and exactly as deterministic as the original. Only reach for `extract("…", schema)` when the markup is +**brittle/variable**, or when you specifically want the scrape to survive DOM drift (the resilience +trade-off: an LLM call per read). Don't spend an LLM call to read a table whose selectors never change. --- ## The three failure modes to avoid -1. **Over-AI-ifying** — replacing a stable `page.locator("#id").click()` with `act()`. Adds latency, - token cost, and non-determinism for nothing. Keep Levels 0–1r where the selector is stable. +1. **Over-AI-ifying** — turning deterministic code into AI calls for no reason. The three common + slips: (a) a stable-selector scrape (`$$eval`) → `extract()` instead of `page.evaluate(...)`; + (b) a stable `page.click("#id")` → `act("click …")`; (c) filling a stable field via + `act("type %password%…", {variables})` when `page.locator("#password").fill(process.env.PASS)` is + deterministic **and** keeps the secret out of every prompt (no LLM sees it). Each adds latency, + token cost, and non-determinism for nothing. Keep Levels 0–1r where the selector is stable; reserve + `act`/`extract` for brittle/semantic/variable steps or when you explicitly want DOM-drift resilience. 2. **Under-migrating** — copying brittle CSS/XPath verbatim. It compiles and runs, but you carried the brittleness over and gained only a cloud browser. Upgrade fragile selectors. 3. **Copying what doesn't exist** — emitting `page.click("#x")`, `page.getByRole(...)`, `page.$$eval`, @@ -102,5 +111,8 @@ const stagehand = new Stagehand({ - **Scope `extract`/`observe`** with `{ selector: "//main" }` to cut noise/cost on big pages. - **Lock the viewport** so cached selectors stay valid: `page.setViewportSize(width, height)` — **positional** args, not Playwright's `{ width, height }` object. -- **Secrets** move from hardcoded strings into `act("…%key%…", { variables: { key } })` + `process.env`. +- **Secrets** move out of source into `process.env`. For a **stable** field, fill deterministically: + `page.locator("#password").fill(process.env.PASS!)` — no LLM call, and the secret never reaches a + prompt. Use `act("…%key%…", { variables: { key } })` only when the field itself needs AI resolution + (brittle/semantic selector); `variables` keeps the value out of the prompt in that case. - **Anchor `act` prompts to visible UI labels** ("click the *Sign in* button"), not internal structure. diff --git a/skills/playwright-to-stagehand/references/prompt.md b/skills/playwright-to-stagehand/references/prompt.md index b4f34ccb..1c9095b1 100644 --- a/skills/playwright-to-stagehand/references/prompt.md +++ b/skills/playwright-to-stagehand/references/prompt.md @@ -23,9 +23,12 @@ one of three moves: `locator('[data-testid="x"]')`; `setViewportSize({w,h})` → positional `setViewportSize(w,h)`; `goto(url,{timeout})` → `{timeoutMs}`; `page.keyboard`/`page.mouse` → `page.keyPress`/`page.click(x,y)`; `page.waitForURL` → poll `page.url()`. -3. **Upgrade or flag** (no deterministic equivalent): - - brittle selectors (long CSS, `nth-child`, text-coupled XPath) and list scrapes → `stagehand.act("…")`, - `stagehand.observe()`→`act(action)` (cached), or `stagehand.extract("…", zodSchema)`; +3. **Upgrade or flag** (no deterministic equivalent — but a stable-selector scrape is NOT this; that's + a deterministic `page.evaluate(...)` rewrite, above): + - brittle selectors (long CSS, `nth-child`, text-coupled XPath) → `stagehand.act("…")`, + `stagehand.observe()`→`act(action)` (cached); + - **brittle or variable-markup reads, or reads you want to survive DOM drift** → `stagehand.extract("…", zodSchema)` + (reserve for fragile pages — a stable table should stay a deterministic `page.evaluate`); - semantic `getByRole`/`getByText`/`getByLabel`/`getByPlaceholder` and `text=`/`role=` engine selectors (no understudy equivalent) → `act()` or a CSS/XPath `locator()`; - **flag needs-human-review** (no surface): `page.route()`/request mocking, `waitForResponse`/`waitForRequest`, @@ -59,9 +62,12 @@ Browserbase session options (proxies, stealth, captcha, persistent context) go i - Model is a `"provider/model"` string (`"anthropic/claude-sonnet-4-6"`, `"openai/gpt-5"`, …). - Default `env: "BROWSERBASE"`; show `env: "LOCAL"` as the dev option. - `extract` takes `(instruction, zodSchema, options?)` and supports a top-level `z.array(...)`. -- Secrets: `act("…%key%…", { variables: { key } })` + `process.env`; never hardcode. +- Secrets out of source via `process.env`. For a **stable** field, fill deterministically — + `page.locator("#password").fill(process.env.PASS!)` (no LLM call, secret never enters a prompt). + Use `act("…%key%…", { variables: { key } })` only when the field needs AI resolution. - Replace `waitForTimeout` sleeps with real waits; never use `waitUntil: "networkidle"` (use `"domcontentloaded"`). -- Don't over-AI-ify stable selectors; don't copy `getBy*`/`page.click(sel)`/`$$eval`/`expect` verbatim. +- Don't over-AI-ify deterministic code: a stable `$$eval` scrape → `page.evaluate` (not `extract`); a + stable `#id` fill/click stays a `locator` (not `act`). And don't copy `getBy*`/`page.click(sel)`/`$$eval`/`expect` verbatim. **Output:** 1. The runnable Stagehand v3 TypeScript (plus `package.json` + `.env` if asked).