From de9c3e961bec46ec8ed08eebbb4d17d410c8233a Mon Sep 17 00:00:00 2001 From: "Tommy D. Rossi" Date: Thu, 26 Feb 2026 12:55:03 +0100 Subject: [PATCH] playwriter skill self improvement: prevent context waste from modal-blocking loops, React dispatchEvent anti-pattern, and unclear snapshot locator direct-use --- playwriter/src/skill.md | 76 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/playwriter/src/skill.md b/playwriter/src/skill.md index 4afa9bf..970e61f 100644 --- a/playwriter/src/skill.md +++ b/playwriter/src/skill.md @@ -408,6 +408,35 @@ await loginPage.waitForURL('**/callback**') // Original page should now be authenticated ``` +**11. Click times out or does nothing — snapshot first to find the blocking dialog** +When a click times out or has no visible effect, the most common cause is a **modal or overlay intercepting pointer events**. Do not retry with different selectors or `{ force: true }` — snapshot immediately to find the blocker, then interact with it using its own snapshot locators: + +```js +// BAD: click timed out → retry with force:true (still blocked by the overlay) +await state.page.locator('button[name="Create Project"]').click({ force: true }) + +// GOOD: click timed out → snapshot first +const snap = await snapshot({ page: state.page, search: /dialog|modal/i }) +// Found: dialog > heading "Do you use a framework?" → interact with it properly +await state.page.getByRole('radio', { name: 'Nope, Vanilla' }).click() +await state.page.getByRole('button', { name: 'Configure SDK' }).click() +// Now the button is unblocked +await state.page.getByRole('button', { name: 'Create Project' }).click() +``` + +**12. `dispatchEvent` and `{ force: true }` do not work on React SPAs** +React uses a synthetic event system. Raw DOM `dispatchEvent(new MouseEvent(...))` and Playwright's `{ force: true }` bypass actionability checks but **do not trigger React event handlers** — component state won't update. If a click appears to succeed but nothing changes, you are clicking the wrong DOM node. Use the snapshot to find the real interactive element (`role=radio`, `role=button`), not a heading or wrapper div: + +```js +// BAD: H3 heading is not what React listens to — state won't update +await state.page.evaluate(() => h3El.dispatchEvent(new MouseEvent('click', { bubbles: true }))) +await state.page.locator('h3:has-text("Node.js")').click({ force: true }) + +// GOOD: snapshot shows the real interactive element +// - role=radio[name="Node.js"] ← React's event handler is here +await state.page.getByRole('radio', { name: 'Node.js' }).click() +``` + ## checking page state After any action (click, submit, navigate), verify what happened. Always print URL first, then snapshot: @@ -451,6 +480,29 @@ Each interactive line ends with a Playwright locator you can pass to `state.page If multiple elements share the same locator, a `>> nth=N` suffix is added (0-based) to make it unique. +**Use snapshot locators directly — never invent selectors.** The locator string shown in the snapshot IS the selector. Use it immediately with `getByRole` or `locator()`. Do not guess CSS selectors, `heading >> text=...`, or `getByText` when the snapshot already gives you the exact string: + +```js +// Snapshot shows: - role=radio[name="Nope, Vanilla"] +await state.page.getByRole('radio', { name: 'Nope, Vanilla' }).click() + +// Snapshot shows: - role=button[name="Configure SDK"] +await state.page.getByRole('button', { name: 'Configure SDK' }).click() + +// Snapshot shows: - role=link[name="SIGN IN"] +await state.page.locator('role=link[name="SIGN IN"]').click() +``` + +**SPA CSS text-transform case mismatch**: accessibility snapshots reflect the visual text, which may be uppercase due to CSS `text-transform`. The actual DOM value (and what `getByRole` matches on) may differ. Use case-insensitive regex to be safe: + +```js +// Snapshot shows heading "NODE.JS" but real DOM value is "Node.js" +// BAD: exact string may not match +await state.page.getByRole('heading', { name: 'NODE.JS' }) +// GOOD: case-insensitive regex always works +await state.page.getByRole('heading', { name: /node\.js/i }) +``` + If a screenshot shows ref labels like `e3`, resolve them using the last snapshot: ```js @@ -861,6 +913,24 @@ await screenshotWithAccessibilityLabels({ page: state.page }) Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkboxes, peach=sliders, salmon=menus, amber=tabs. +**resizeImage** - resize an image to consume fewer tokens when read back into context. Useful when you take a `page.screenshot()` and need to ingest the image later. By default fits within 1568×1568px (Claude-optimal, avoids server-side re-encoding). Token cost formula: `(width × height) / 750`. Always outputs JPEG. + +```js +// Shrink a screenshot for LLM ingestion (default: max 1568px, JPEG q80) +await resizeImage({ input: './screenshot.png', output: './small.jpg' }) + +// Resize to a specific width (aspect ratio preserved) +await resizeImage({ input: './large.png', width: 800, output: './resized.jpg' }) + +// Resize to fit inside exact dimensions +await resizeImage({ input: './photo.png', width: 1024, height: 768, output: './fit.jpg' }) + +// Custom max dimension and quality +await resizeImage({ input: './shot.png', maxDimension: 1024, quality: 60, output: './out.jpg' }) +``` + +Options: `input` (path or Buffer), `output` (path, optional), `maxDimension` (default 1568, ignored if width/height set), `width`, `height`, `fit` ('inside' | 'cover' | 'contain' | 'fill', default 'inside'), `quality` (1-100, default 80). Returns `{ buffer, mimeType, path? }`. + **recording.start / recording.stop** - record the page as a video at native FPS (30-60fps). Uses `chrome.tabCapture` in the extension context, so **recording survives page navigation**. Video is saved as mp4. While recording is active, Playwriter automatically overlays a smooth ghost cursor that follows automated mouse actions (`page.mouse.*`, `locator.click()`, hover flows) using `page.onMouseAction` from the Playwright fork. @@ -964,7 +1034,11 @@ Always use `scale: 'css'` to avoid 2-4x larger images on high-DPI displays: await state.page.screenshot({ path: 'shot.png', scale: 'css' }) ``` -If you want to read back the image file into context make sure to resize it first, scaling down the image to make sure max size is 1500px. for example with `sips --resampleHeightWidthMax 1500 input.png --out output.png` on macOS. +If you want to read back the image file into context, resize it first with `resizeImage` so it consumes fewer tokens: + +```js +await resizeImage({ input: './shot.png', output: './small.jpg' }) +``` ## page.evaluate