diff --git a/playwriter/src/skill.md b/playwriter/src/skill.md index c91ccdd..0f288de 100644 --- a/playwriter/src/skill.md +++ b/playwriter/src/skill.md @@ -185,97 +185,47 @@ You can collaborate with the user - they can help with captchas, difficult eleme ## interaction feedback loop -Every browser interaction should follow a **observe → act → observe** loop. After every action, you must check its result before proceeding. Never chain multiple actions blindly — the page may not have responded as expected. +Every browser interaction must follow **observe → act → observe**. Never chain multiple actions blindly. -**Core loop:** - -1. **Open page** — get or create your page and navigate to the target URL -2. **Observe** — print `state.page.url()` and take an accessibility snapshot. Always print the URL so you know where you are — pages can redirect, and actions can trigger unexpected navigation. -3. **Check** — read the snapshot and URL. If the page isn't ready (still loading, expected content missing, wrong URL), **wait and observe again** — don't act on stale or incomplete state. Only proceed when you can identify the element to interact with. +1. **Open page** — get or create your page, navigate to URL +2. **Observe** — print `state.page.url()` + `snapshot()`. Always print URL — pages can redirect unexpectedly. +3. **Check** — if page isn't ready (loading, wrong URL, content missing), wait and observe again 4. **Act** — perform one action (click, type, submit) -5. **Observe again** — print URL + snapshot to verify the action's effect. If the action didn't take effect (nothing changed, page still loading), wait and observe again before proceeding. -6. **Repeat** — continue from step 3 until the task is complete - -``` -┌─────────────────────────────────────────────┐ -│ open page + goto URL │ -└──────────────────┬──────────────────────────┘ - ▼ - ┌────────────────┐ - ┌───►│ observe │◄─────────────────┐ - │ │ (url + snapshot) │ │ - │ └───────┬────────┘ │ - │ ▼ │ - │ ┌────────────────┐ │ - │ │ check │ │ - │ │ (read result) │ │ - │ └───┬────────┬───┘ │ - │ not │ │ ready │ - │ ready │ ▼ │ - └────────┘ ┌────────────────┐ │ - │ act │ │ - │ (click/type) │─────────────┘ - └────────────────┘ -``` - -**Example: opening a Framer plugin via the command palette** - -Each step is a separate execute call. Notice how every action is followed by a snapshot to verify what happened: +5. **Observe again** — print URL + snapshot to verify the action's effect +6. **Repeat** from step 3 until task is complete ```js -// 1. Open page and observe — always print URL first +// Each step should be a separate execute call: +// Step 1: navigate + observe state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage()) -await state.page.goto('https://framer.com/projects/my-project', { waitUntil: 'domcontentloaded' }) +await state.page.goto('https://example.com', { waitUntil: 'domcontentloaded' }) console.log('URL:', state.page.url()) await snapshot({ page: state.page }).then(console.log) ``` ```js -// 2. Act: open command palette → observe result -await state.page.keyboard.press('Meta+k') +// Step 2: act + observe +await state.page.locator('button:has-text("Submit")').click() console.log('URL:', state.page.url()) -await snapshot({ page: state.page, search: /dialog|Search/ }).then(console.log) -// If dialog didn't appear, observe again before retrying +await snapshot({ page: state.page }).then(console.log) ``` +If nothing changed after an action, try `waitForPageLoad({ page: state.page, timeout: 3000 })` or you may have clicked the wrong element. + +**Deeper observation** — when snapshots aren't enough to understand what happened, combine multiple channels: + ```js -// 3. Act: type search query → observe result -await state.page.keyboard.type('MCP') -console.log('URL:', state.page.url()) -await snapshot({ page: state.page, search: /MCP/ }).then(console.log) +// Check console for errors after an action +const errors = await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 }) + +// Combine snapshot + logs for full picture +const snap = await snapshot({ page: state.page, search: /dialog|error|message/ }) +const logs = await getLatestLogs({ page: state.page, search: /error/i, count: 10 }) +console.log('UI:', snap) +console.log('Logs:', logs) ``` -```js -// 4. Act: press Enter → observe plugin loaded -await state.page.keyboard.press('Enter') -await state.page.waitForTimeout(1000) -console.log('URL:', state.page.url()) -const frame = state.page.frames().find((f) => f.url().includes('plugins.framercdn.com')) -await snapshot({ page: state.page, frame: frame || undefined }).then(console.log) -// If frame not found, wait and observe again — plugin may still be loading -``` - -**Other ways to observe action results:** - -Snapshots are the primary feedback mechanism, but some actions have side effects that are better observed through other channels: - -- **Console logs** — check for errors or app state after an action: - ```js - await getLatestLogs({ page: state.page, search: /error|fail/i, count: 20 }) - ``` -- **Network requests** — verify API calls were made after a form submit or button click: - ```js - state.page.on('response', async (res) => { - if (res.url().includes('/api/')) { - console.log(res.status(), res.url()) - } - }) - ``` -- **URL changes** — confirm navigation happened: - ```js - console.log(state.page.url()) - ``` -- **Screenshots** — only for visual layout issues (see "choosing between snapshot methods" below). +Use `getLatestLogs()` for console errors, `state.page.url()` for navigation, screenshots only for visual layout issues. ## common mistakes to avoid @@ -301,13 +251,9 @@ await state.page.keyboard.press('Meta+v') // always verify with screenshot! ``` **3. Using stale locators from old snapshots** -Locators (especially ones with `>> nth=`) can change when the page updates. Always get a fresh snapshot before clicking: +Locators (especially ones with `>> nth=`) can change when the page updates. Always get a fresh snapshot before clicking, then immediately use locators from that output: ```js -// BAD: using ref from minutes ago -await state.page.locator('[id="old-id"]').click() // element may have changed - -// GOOD: get fresh snapshot, then immediately use locators from it await snapshot({ page: state.page, showDiffSinceLastCall: true }) // Now use the NEW locators from this output ``` @@ -322,29 +268,22 @@ await screenshotWithAccessibilityLabels({ page: state.page }) ``` **5. Text concatenation without line breaks** -`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')`: +`keyboard.type()` doesn't insert newlines from `\n` in strings. Use `keyboard.press('Enter')` between lines: ```js -// BAD: newlines in string don't create line breaks -await state.page.keyboard.type('Line 1\nLine 2') // becomes "Line 1Line 2" - -// GOOD: use Enter key for line breaks await state.page.keyboard.type('Line 1') await state.page.keyboard.press('Enter') await state.page.keyboard.type('Line 2') ``` **6. Quote escaping in bash** -Bash parses `$`, backticks, and `\` inside double-quoted strings. This silently corrupts JS code containing dollar signs (regex like `/\$[\d.]+/`), template literals, or backslash patterns. +Bash parses `$`, backticks, and `\` inside double-quoted strings. This silently corrupts JS code. Always use single quotes or heredoc: ```bash -# BAD: double quotes — bash interprets $ and backticks in your JS -playwriter -s 1 -e "const price = text.match(/\$[\d.]+/)" - -# GOOD: single quotes — bash passes everything through literally +# single quotes — bash passes everything through literally playwriter -s 1 -e 'await state.page.locator(`[id="_r_a_"]`).click()' -# GOOD: heredoc for complex code with mixed quotes +# heredoc for complex code with mixed quotes playwriter -s 1 -e "$(cat <<'EOF' await state.page.locator('[id="_r_a_"]').click() const match = html.match(/\$[\d.]+/g) @@ -353,17 +292,10 @@ EOF ``` **7. Using screenshots when snapshots suffice** -Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues: +Screenshots + image analysis is expensive and slow. Only use screenshots for visual/CSS issues. Use snapshot for text checks: ```js -// BAD: screenshot to check if text appeared (wastes tokens on image analysis) -await state.page.screenshot({ path: 'check.png', scale: 'css' }) - -// GOOD: snapshot is text — fast, cheap, searchable await snapshot({ page: state.page, search: /expected text/i }) - -// GOOD: evaluate DOM directly for content checks -const text = await state.page.evaluate(() => document.querySelector('.message')?.textContent) ``` **8. Assuming page content loaded** @@ -378,28 +310,19 @@ await waitForPageLoad({ page: state.page, timeout: 5000 }) ``` **9. Not using playwriter for JS-rendered sites** -Do NOT waste context trying webfetch, curl, or Playwright CLI screenshots on SPAs (Instagram, Twitter, etc.). These sites return empty HTML shells — the real content is rendered by JavaScript. Use playwriter with a real browser session instead: +Do NOT waste context trying webfetch, curl, or Playwright CLI screenshots on SPAs (Instagram, Twitter, etc.). These return empty HTML shells. Use playwriter directly: ```js -// BAD: webfetch/curl on Instagram returns empty HTML, grep finds nothing, huge context wasted -// BAD: Playwright CLI screenshot needs browser install, produces blank/modal-blocked images - -// GOOD: use playwriter — real browser, full JS rendering, interactive state.page = context.pages().find((p) => p.url() === 'about:blank') ?? (await context.newPage()) await state.page.goto('https://www.instagram.com/p/ABC123/', { waitUntil: 'domcontentloaded' }) await waitForPageLoad({ page: state.page, timeout: 8000 }) await snapshot({ page: state.page, search: /cookie|consent|accept/i }).then(console.log) -// Now you can see modals, dismiss them, navigate carousels, extract content ``` **10. Login buttons that open popups** -Playwriter extension cannot control popup windows. If a login button opens a popup (common with OAuth/SSO), use cmd+click to open in a new tab instead: +Playwriter cannot control popup windows. Use cmd+click to open in a new tab instead: ```js -// BAD: popup window is not controllable by playwriter -await state.page.click('button:has-text("Login with Google")') - -// GOOD: cmd+click opens in new tab that playwriter can control await state.page.locator('button:has-text("Login with Google")').click({ modifiers: ['Meta'] }) await state.page.waitForTimeout(2000) @@ -427,14 +350,9 @@ await state.page.getByRole('radio', { name: 'Nope, Vanilla' }).click() ``` **12. Never use `dispatchEvent` or `{ force: true }` to bypass blockers** -`dispatchEvent(new MouseEvent(...))` and `{ force: true }` bypass Playwright checks but **do not trigger React/Vue/Svelte handlers** — state won't update. The same applies to `element.click()` inside `page.evaluate()`. If a click "succeeds" but nothing changes, you're either clicking the wrong node or using the wrong interaction pattern: +`dispatchEvent(new MouseEvent(...))`, `{ force: true }`, and `element.click()` inside `page.evaluate()` bypass Playwright checks but **do not trigger React/Vue/Svelte handlers** — state won't update. Use snapshot to find the real interactive element: ```js -// BAD: heading click bypasses overlay but React ignores it -await state.page.locator('h3:has-text("Node.js")').click({ force: true }) -// BAD: evaluate click bypasses all Playwright input simulation -await state.page.evaluate(() => document.querySelector('button').click()) -// GOOD: snapshot shows the real interactive element is a radio, not the heading await state.page.getByRole('radio', { name: 'Node.js' }).click() ``` @@ -450,30 +368,12 @@ When something doesn't respond to a click, do NOT start inspecting CDP event lis 3. Take another `snapshot()` to see what changed 4. Only investigate DOM internals if correct interaction patterns produce zero response after 2–3 attempts -## checking page state - -After any action (click, submit, navigate), verify what happened. Always print URL first, then snapshot: - -```js -// Always print URL first, then snapshot -console.log('URL:', state.page.url()) -await snapshot({ page: state.page }).then(console.log) - -// Filter for specific content when snapshot is large -console.log('URL:', state.page.url()) -await snapshot({ page: state.page, search: /dialog|button|error/i }).then(console.log) -``` - -If nothing changed, try `await waitForPageLoad({ page: state.page, timeout: 3000 })` or you may have clicked the wrong element. - ## accessibility snapshots ```js await snapshot({ page: state.page, search?, showDiffSinceLastCall? }) ``` -`accessibilitySnapshot` is still available as an alias for backward compatibility. - - `search` - string/regex to filter results (returns first 10 matching lines) - `showDiffSinceLastCall` - returns diff since last snapshot (default: `true`, but `false` when `search` is provided). Pass `false` to get full snapshot. @@ -512,12 +412,6 @@ const locator = refToLocator({ ref: 'e3' }) await state.page.locator(locator!).click() ``` -```js -await state.page.locator('[id="nav-home"]').click() -await state.page.locator('[data-testid="docs-link"]').click() -await state.page.locator('role=link[name="Blog"]').click() -``` - Search for specific elements: ```js @@ -540,38 +434,11 @@ await snapshot({ locator: state.page.locator('form#checkout') }) Use this whenever the full page snapshot is dominated by navigation or layout elements you don't need. It saves significant tokens and makes the output much easier to parse. -**Filtering large snapshots in JS** — when the built-in `search` isn't enough (e.g., you need multiple patterns or custom logic), filter the snapshot string directly: - -```js -const snap = await snapshot({ page: state.page, showDiffSinceLastCall: false }) -const relevant = snap - .split('\n') - .filter((l) => l.includes('dialog') || l.includes('error') || l.includes('button')) - .join('\n') -console.log(relevant) -``` - -This is much cheaper than taking a screenshot — use it as your primary debugging tool for verifying text content, checking if elements exist, or confirming state changes. +**Filtering large snapshots in JS** — when `search` isn't enough, filter the string directly: `snap.split('\n').filter(l => l.includes('dialog') || l.includes('error')).join('\n')` ## choosing between snapshot methods -Both `snapshot` and `screenshotWithAccessibilityLabels` use the same ref system, so you can combine them effectively. - -**Use `snapshot` when:** - -- Page has simple, semantic structure (articles, forms, lists) -- You need to search for specific text or patterns -- Token usage matters (text is smaller than images) -- You need to process the output programmatically - -**Use `screenshotWithAccessibilityLabels` when:** - -- Page has complex visual layout (grids, galleries, dashboards, maps) -- Spatial position matters (e.g., "first image", "top-left button") -- DOM order doesn't match visual order -- You need to understand the visual hierarchy - -**Combining both:** Use screenshot first to understand layout and identify target elements visually, then use `snapshot({ search: /pattern/ })` for efficient searching in subsequent calls. +Use `snapshot` for text-heavy pages (forms, articles) — fast, cheap, searchable. Use `screenshotWithAccessibilityLabels` for complex visual layouts (grids, galleries, dashboards) where spatial position matters. Both share the same ref system and can be combined. ## selector best practices @@ -657,13 +524,9 @@ await waitForPageLoad({ page: state.page, timeout: 5000 }) ## common patterns -**Authenticated fetches** - to access protected resources, fetch from within page context (includes session cookies automatically): +**Authenticated fetches** - fetch from within page context to include session cookies automatically: ```js -// BAD: curl/external requests don't have session cookies -// curl -H "Cookie: ..." often fails due to missing cookies or CSRF - -// GOOD: fetch inside state.page.evaluate uses browser's full session const data = await state.page.evaluate(async (url) => { const resp = await fetch(url) return await resp.text() @@ -695,16 +558,6 @@ await state.page.evaluate(async (url) => { Instead, use simpler alternatives (single download via `a.click()`, store data in `state`, etc). -**Links that open new tabs** - playwriter cannot control popup windows opened via `window.open`. Use cmd+click to open in a controllable new tab instead (see mistake #9 above for a full example): - -```js -await state.page.locator('a[target=_blank]').click({ modifiers: ['Meta'] }) -await state.page.waitForTimeout(1000) -const pages = context.pages() -const newTab = pages[pages.length - 1] -console.log('New tab URL:', newTab.url()) -``` - **Downloads** - capture and save: ```js @@ -805,19 +658,7 @@ const fullHtml = await getCleanHTML({ locator: state.page, showDiffSinceLastCall - `showDiffSinceLastCall` - returns diff since last call (default: `true`, but `false` when `search` is provided). Pass `false` to get full HTML. - `includeStyles` - keep style and class attributes (default: false) -**HTML processing:** -The function cleans HTML for compact, readable output: - -- **Removes tags**: script, style, link, meta, noscript, svg, head -- **Unwraps nested wrappers**: Empty divs/spans with no attributes that only wrap a single child are collapsed (e.g., `
text
text