playwriter skill self improvement: document SPA navigation timeouts, ghost cursor MPA persistence, and executionTimestamps capture pattern

Four lessons from recording demo videos on MPA sites (GitHub, Hacker News):

1. recording.stop() result: examples now save the full result to state.recordingResult
   instead of destructuring — executionTimestamps must not be lost between execute calls
   or createDemoVideo cannot detect idle sections and you end up doing raw ffmpeg.

2. Ghost cursor MPA: document that cursor survives full-page navigation automatically
   (via Page.addScriptToEvaluateOnNewDocument registered at recording.start time).
   Previous docs implied you had to re-inject manually — which lost 2 recording attempts.

3. New mistake #13: SPA click timeouts — GitHub Turbo.js and similar SPA routers intercept
   link clicks without firing standard load events. Playwright's click() sees a navigation
   and waits for load, which never fires, so it times out even though the click succeeded.
   Documents .catch(() => {}), { noWaitAfter: true }, and { timeout: 10000 } patterns.

4. createDemoVideo call: clarified that state.recordingResult must be saved (not just
   destructured) and that createDemoVideo must run in its own execute call with high timeout.
This commit is contained in:
Tommy D. Rossi
2026-02-28 21:10:54 +01:00
parent d28752e9f9
commit 9222a96a98
+43 -12
View File
@@ -440,7 +440,31 @@ await state.page.evaluate(() => document.querySelector('button').click())
await state.page.getByRole('radio', { name: 'Node.js' }).click() await state.page.getByRole('radio', { name: 'Node.js' }).click()
``` ```
**13. Over-investigating instead of just interacting** **13. SPA/Turbo navigation makes `click()` time out**
Sites using Turbo (GitHub), Next.js soft nav, or other SPA routers intercept link clicks and update the URL without firing the browser's standard `load` event. Playwright's `click()` sees the navigation start and waits for it to complete — but the load event never fires, so it times out even though the navigation succeeded.
Symptoms: `locator.click: Timeout Xms exceeded` with logs showing `navigated to "..."` underneath — the click worked, Playwright just timed out waiting for cleanup.
Fix: add `.catch(() => {})` on the click, or raise `timeout`, or use `{ noWaitAfter: true }` then wait manually:
```js
// Pattern 1: swallow the navigation-wait timeout (click still fired)
await state.page.locator('a.notification-link').first().click({ timeout: 10000 }).catch(() => {})
await state.page.waitForTimeout(1500) // give SPA time to render
// Pattern 2: skip the post-click navigation wait entirely
await state.page.locator('a.notification-link').first().click({ noWaitAfter: true })
await state.page.waitForLoadState('domcontentloaded').catch(() => {})
await state.page.waitForTimeout(1000)
// Pattern 3: catch at waitForLoadState instead
await state.page.locator('a').first().click()
await state.page.waitForLoadState('domcontentloaded').catch(() => {})
```
Always verify the URL afterwards with `console.log(state.page.url())` — the navigation typically did happen.
**14. Over-investigating instead of just interacting**
When something doesn't respond to a click, do NOT start inspecting CDP event listeners, React fibers, canvas pixel data, or writing `page.evaluate()` to read class names and bounding boxes. This wastes massive context. Instead: When something doesn't respond to a click, do NOT start inspecting CDP event listeners, React fibers, canvas pixel data, or writing `page.evaluate()` to read class names and bounding boxes. This wastes massive context. Instead:
1. Take a `snapshot()` — it shows every interactive element and what to click 1. Take a `snapshot()` — it shows every interactive element and what to click
@@ -938,6 +962,8 @@ Labels are color-coded: yellow=links, orange=buttons, coral=inputs, pink=checkbo
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. 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.
**Ghost cursor survives MPA navigation**: when recording starts, the cursor bundle is registered via `Page.addScriptToEvaluateOnNewDocument`, so Chrome re-injects and re-enables it on every new document. This means the cursor is visible across full-page navigations (GitHub, Hacker News, etc.) without any extra work.
For demos where cursor movement should be visible and human-like, drive the page with interaction methods (`locator.click()`, `page.click()`, `page.mouse.move()`, `press`, typing). Avoid skipping interactions with direct state jumps (for example, `goto(itemUrl)` instead of clicking the link) when your goal is to show realistic pointer motion in the recording. For demos where cursor movement should be visible and human-like, drive the page with interaction methods (`locator.click()`, `page.click()`, `page.mouse.move()`, `press`, typing). Avoid skipping interactions with direct state jumps (for example, `goto(itemUrl)` instead of clicking the link) when your goal is to show realistic pointer motion in the recording.
**Note**: Recording requires the user to have clicked the Playwriter extension icon on the tab. This grants `activeTab` permission needed for `chrome.tabCapture`. Recording works on tabs where the icon was clicked - if you need to record a new tab, ask the user to click the icon on it first. **Note**: Recording requires the user to have clicked the Playwriter extension icon on the tab. This grants `activeTab` permission needed for `chrome.tabCapture`. Recording works on tabs where the icon was clicked - if you need to record a new tab, ask the user to click the icon on it first.
@@ -957,9 +983,11 @@ await state.page.click('a')
await state.page.waitForLoadState('domcontentloaded') await state.page.waitForLoadState('domcontentloaded')
await state.page.goBack() await state.page.goBack()
// Stop and get result // Stop and get result — always capture the full result including executionTimestamps
const { path, duration, size } = await recording.stop({ page: state.page }) // if you plan to call createDemoVideo. Save to state if using separate execute calls.
console.log(`Saved ${size} bytes, duration: ${duration}ms`) const result = await recording.stop({ page: state.page })
state.recordingResult = result
console.log(`Saved ${result.size} bytes, duration: ${result.duration}ms, timestamps: ${result.executionTimestamps.length}`)
``` ```
Additional recording utilities: Additional recording utilities:
@@ -993,7 +1021,7 @@ await ghostCursor.hide({ page: state.page })
While recording is active, playwriter tracks when each `execute()` call starts and ends. `recording.stop()` returns these timestamps alongside the video file. `createDemoVideo` uses this data to identify idle gaps and speed them up with ffmpeg in a single pass. While recording is active, playwriter tracks when each `execute()` call starts and ends. `recording.stop()` returns these timestamps alongside the video file. `createDemoVideo` uses this data to identify idle gaps and speed them up with ffmpeg in a single pass.
A 1-second buffer is preserved around each interaction so viewers see context before and after each action. A 0.5-second buffer is preserved on each side of an interaction (1 second total) so viewers see context before and after each action.
Requires `ffmpeg` and `ffprobe` installed on the system. Requires `ffmpeg` and `ffprobe` installed on the system.
@@ -1010,15 +1038,18 @@ await recording.start({ page: state.page, outputPath: './recording.mp4' })
``` ```
```js ```js
// Stop recording — executionTimestamps is included in the result // Stop recording — save the FULL result to state if createDemoVideo runs in a separate execute call
const recordingResult = await recording.stop({ page: state.page }) // (executionTimestamps is what powers the idle detection — don't destructure it away)
state.recordingResult = await recording.stop({ page: state.page })
console.log(`${state.recordingResult.executionTimestamps.length} timestamps captured`)
// Create demo video — idle gaps are sped up 4x (default) // Create demo video — idle gaps are sped up 6x by default
// Run this in a SEPARATE execute call with --timeout 120000 or higher
const demoPath = await createDemoVideo({ const demoPath = await createDemoVideo({
recordingPath: recordingResult.path, recordingPath: state.recordingResult.path,
durationMs: recordingResult.duration, durationMs: state.recordingResult.duration,
executionTimestamps: recordingResult.executionTimestamps, executionTimestamps: state.recordingResult.executionTimestamps,
speed: 5, // optional, default 5x for idle sections speed: 6, // optional, default 6x for idle sections
// outputFile: './demo.mp4', // optional, defaults to recording-demo.mp4 // outputFile: './demo.mp4', // optional, defaults to recording-demo.mp4
}) })
console.log('Demo video:', demoPath) console.log('Demo video:', demoPath)