@milkstraw/traceframe 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,6 +2,8 @@
2
2
 
3
3
  Traceframe records a scripted Chromium flow and renders an MP4 with a smooth cursor and click pulse. Everything runs locally: pages, flows, videos, and authentication state stay on your machine.
4
4
 
5
+ Recordings are silent; audio is not captured.
6
+
5
7
  https://github.com/user-attachments/assets/38e02cd5-a9ba-4f3c-938c-159955353141
6
8
 
7
9
  ## Prerequisites
@@ -16,7 +18,7 @@ To use system Chrome 150 or later, pass `--browser` or set `CHROME_PATH`, and in
16
18
 
17
19
  In a sandbox, such as a coding agent's, allow Playwright's browser download and let Chromium start.
18
20
 
19
- To record pages behind a login, pass a Playwright storage-state file with `--storage-state`. Keep it in `.auth/` or another ignored location.
21
+ To record pages behind a login, pass a Playwright storage-state file with `--storage-state`. See [creating saved login state and switching accounts](docs/recording-flows.md#save-and-switch-login-state), and keep state in `.auth/` or another ignored location.
20
22
 
21
23
  ## Record a video
22
24
 
@@ -51,13 +53,15 @@ npx @milkstraw/traceframe record \
51
53
  --preset draft
52
54
  ```
53
55
 
54
- The [recording guide](docs/recording-flows.md) includes a runnable example and recipes for form input, asynchronous results, browser settings, and editing or sharing a recording.
56
+ The [recording guide](docs/recording-flows.md) includes a runnable example and recipes for authentication, form input, asynchronous results, captions, browser settings, and inspecting, editing or sharing a recording.
55
57
 
56
58
  Both presets use a 1280×800 CSS viewport. `draft` produces 1280×800 at 30 FPS; `standard` (the default) produces 2560×1600 at 60 FPS. The output dimensions are the viewport times `--scale`, so `--preset draft --scale 2` gives 2560×1600 at 30 FPS. The command prints a JSON summary when it finishes.
57
59
 
58
60
  The cursor and click pulses are always drawn at the full frame rate. Chromium may capture page changes, such as scrolling and animations, at a lower rate at 2560×1600, depending on the machine. The video repeats the last page frame until the next one arrives.
59
61
 
60
- Use `--run-dir .traceframe/runs/name` (`runDirectory` in the API) to keep the raw capture and FFmpeg filter file, including a partial capture if the flow fails during recording. Keep that directory out of Git, because the raw capture shows everything the page showed.
62
+ Use `--run-dir .traceframe/runs/name` (`runDirectory` in the API) to keep the raw capture and FFmpeg filter file, including a partial capture if the flow fails during recording. On a preparation or recording failure, Traceframe also tries to save a viewport screenshot as `failure.png`. Keep that directory out of Git, because captures and screenshots can contain private page data.
63
+
64
+ When available, failure output includes the error code, flow-file locations, elapsed recording time, and paths to artifacts saved for that attempt. Without `--run-dir`, temporary captures are removed and no failure screenshot is saved. See [diagnosing a failed take](docs/recording-flows.md#diagnose-a-failed-take).
61
65
 
62
66
  ## Write a flow
63
67
 
@@ -143,9 +147,59 @@ await record({
143
147
  })
144
148
  ```
145
149
 
146
- Traceframe's own errors are `TraceframeError` instances (exported by the package) with a `code`, such as `READY_FAILED` or `FLOW_TARGET_OBSCURED`.
150
+ ### Record a page you already opened
151
+
152
+ Use `recordPage` when your script already owns a Playwright page, including a page in a persistent extension context. Prepare navigation, authentication, application readiness and fonts before calling it. With a prepared `page`:
153
+
154
+ ```js
155
+ import { recordPage } from '@milkstraw/traceframe'
156
+
157
+ const save = page.getByRole('button', { name: 'Save', exact: true })
158
+ await recordPage({
159
+ page,
160
+ output: './recording.mp4',
161
+ flow: async ({ click, wait, signal }) => {
162
+ await click(save)
163
+ await page.getByText('Saved', { exact: true }).waitFor({ signal })
164
+ await wait(3)
165
+ },
166
+ })
167
+ ```
168
+
169
+ The callback receives the same `page`, `signal` and five helpers as a flow file. It can close over your script's locators and state. See the [runnable existing-page example](examples/existing-page.mjs); in a clone, run `node examples/existing-page.mjs` to save `.traceframe/existing-page.mp4` using synthetic local content.
170
+
171
+ Use Playwright 1.64.1 with Chromium, a fixed viewport, 100% browser zoom and an unscaled page. Traceframe checks the page's inner dimensions and visual viewport before starting capture, rejecting unsupported geometry with `CAPTURE_SIZE_MISMATCH`. `recordPage` keeps the page, context and browser open on success, failure and cancellation. It does not navigate, resize, run a `ready` hook, or change page timeouts. Your script owns setup and shutdown. The CLI and `record` continue to create and close their own browser.
172
+
173
+ `page` and a function `flow` are required. `output`, `postRoll`, `flowTimeout`, `runDirectory` and `signal` have the same meaning as in `record`, and the return value is the same recording summary. Set `initialPointer: { x, y }` to choose the starting cursor position. The page's viewport determines the dimensions; `scale` defaults to 1 and accepts 1 or 2, and `fps` defaults to 30 and accepts 30 or 60. Use these explicit settings instead of a preset. Browser setup options such as `url`, `browser`, `storageState`, viewport dimensions and `readyTimeout` are rejected.
174
+
175
+ Traceframe moves the caller's pointer only after validating the first frame. To show the starting hover state from frame 0, move the page's mouse to your chosen `initialPointer` before calling `recordPage`.
176
+
177
+ For 2× frames, launch the caller's browser with `chromium.launch({ args: ['--force-device-scale-factor=2'] })`, create its context with `deviceScaleFactor: 2`, and pass `scale: 2` to `recordPage`. The same launch argument works with `launchPersistentContext`. Setting `deviceScaleFactor` alone does not ensure 2× screencast frames. Traceframe checks the first JPEG's actual dimensions before running the callback and rejects mismatches with `CAPTURE_SIZE_MISMATCH`.
178
+
179
+ Keep the viewport, zoom and visual scale fixed and the page open until `recordPage` settles; geometry is checked only at startup. Only that page is recorded; a tab closed during a take may lose its capture. Use the page exclusively for recording: concurrent screencasts, context `recordVideo` and tracing with screenshots can change frame dimensions or supply stale frames. A size check cannot detect every conflict. Overlapping `recordPage` calls on the same page reject with `PAGE_RECORDING_ACTIVE`.
180
+
181
+ Await every helper. Helpers observe cancellation before further pointer or scroll actions, and release a mouse button that was already pressed; an already-issued action may still complete. Arbitrary callback code must cooperate with `signal`, including passing it to supported Playwright operations. A flow timeout does not forcibly terminate that code. Sequential takes are supported after a successful screencast stop, including ordinary flow failures and cooperative cancellation. If Playwright's FFmpeg is missing, Traceframe stops the partial capture before rejecting. Other startup or stop failures may need a new page or context; Traceframe leaves that recovery to the caller and does not stop a pre-existing caller screencast.
182
+
183
+ Traceframe removes its own temporary outputs unless `runDirectory` is supplied. Playwright keeps original WebMs in its browser artifacts directory until the caller closes the browser, even after a take has been saved. For extension pages, use full Chromium; the headless shell alone does not load extensions. This path was checked on Linux with Playwright 1.64.1 and its bundled full Chromium using a synthetic extension.
184
+
185
+ ### Errors and cancellation
186
+
187
+ Traceframe's own errors are `TraceframeError` instances (exported by the package) with a `code`, such as `READY_FAILED` or `FLOW_TARGET_OBSCURED`. Other exceptions thrown by the flow become `FLOW_FAILED`, with the original thrown value in `cause`; existing Traceframe error codes and causes are preserved.
188
+
189
+ When available, `error.diagnostics` contains:
190
+
191
+ | Field | Meaning |
192
+ | --- | --- |
193
+ | `flowLocations` | Locations in the supplied flow file, as `{ file, line, column }` objects with an absolute path and 1-based line/column. Empty for a callback or when the error stack has no matching frame. |
194
+ | `recordingTime` | Elapsed seconds from the first captured frame for a capture-stage failure, measured before collecting failure artifacts. Omitted before the first frame and for later rendering failures. |
195
+ | `capturePath` | Absolute path to the retained raw capture, only when `runDirectory` was supplied and saving succeeded. |
196
+ | `screenshotPath` | Absolute path to the saved failure screenshot, only when `runDirectory` was supplied and the screenshot succeeded. |
197
+
198
+ A flow timeout may have no flow location because it is raised outside the blocked operation. Screenshots are best effort and have a five-second collection budget. Partial captures are saved before browser shutdown, with no fixed time limit so slower encoders can finish. Diagnostic failures do not replace the original error.
199
+
200
+ Pass `signal` to stop a recording early. Traceframe stops its recording/rendering work and removes its temporary files before rejecting with the signal's reason. `record` also closes its browser; `recordPage` leaves the caller's browser open. When you pass a signal, Traceframe leaves Ctrl-C and other termination signals to you. The CLI does this for you: the first Ctrl-C stops and cleans up, and a second one exits at once.
147
201
 
148
- Pass `signal` to stop a recording early. Traceframe closes the browser, stops FFmpeg, and removes its temporary files before rejecting with the signal's reason. When you pass a signal, Traceframe leaves Ctrl-C and other termination signals to you. The CLI does this for you: the first Ctrl-C stops and cleans up, and a second one exits at once.
202
+ A stuck recording encoder can delay saving and cancellation indefinitely. A second Ctrl-C forces the CLI to exit and can discard the unfinished capture. API callers that need a hard shutdown deadline should run recording in a separate process they can terminate.
149
203
 
150
204
  ## Sample output
151
205
 
@@ -20,7 +20,48 @@ In a repository clone, run `npx traceframe record --url "file://$PWD/examples/re
20
20
 
21
21
  Inspect the running application at the recording viewport. Pick a short journey with a visible result and known demo data. Use role, label, or test-id locators; `locator.describe()` can make errors clearer.
22
22
 
23
- Put setup and page readiness in `ready({ page, signal })`. This runs after initial navigation and before recording. Use `--storage-state` for an existing login, and keep that file in an ignored directory. For headers, locale, or themes, see [optional startup settings](#optional-startup-settings).
23
+ Put setup and page readiness in `ready({ page, signal })`. This runs after initial navigation and before recording. Use `--storage-state` for an existing login, as below. For headers, locale, or themes, see [optional startup settings](#optional-startup-settings).
24
+
25
+ If your script already owns the page or an extension context, use [`recordPage`](../README.md#record-a-page-you-already-opened) with an inline callback. Prepare the page before calling it; the caller owns navigation and browser cleanup.
26
+
27
+ ## Save and switch login state
28
+
29
+ Add `.auth/` to your project's `.gitignore`; saved state can grant access to the account. To create it interactively, install full Chromium and open Playwright's login window:
30
+
31
+ ```sh
32
+ mkdir -p .auth
33
+ npx playwright@1.64.1 install chromium
34
+ npx playwright@1.64.1 codegen --save-storage=.auth/first-account.json http://localhost:3000
35
+ ```
36
+
37
+ Sign in normally, wait for the authenticated screen, then close the browser window to save the state. This requires a desktop/display; the headless-shell-only installation used for recording is insufficient. Record with the same application origin, including its port:
38
+
39
+ ```sh
40
+ npx @milkstraw/traceframe record \
41
+ --url http://localhost:3000 --flow ./flow.mjs \
42
+ --storage-state .auth/first-account.json --out ./recording.mp4
43
+ ```
44
+
45
+ If a Playwright script already signs in, save state after checking that login finished and before closing its browser:
46
+
47
+ ```js
48
+ await page.getByRole('heading', { name: 'Example workspace' }).waitFor()
49
+ await page.context().storageState({ path: '.auth/first-account.json', indexedDB: true })
50
+ ```
51
+
52
+ Storage state includes cookies and local storage. The scripted `indexedDB: true` also saves IndexedDB, which some apps use for authentication; the codegen command above does not include it.
53
+
54
+ For a second account, save a separate file from a fresh login. To switch during a flow, first navigate the recorded page to `about:blank` to release its open IndexedDB connections. Apply the second account with Playwright 1.64.1's `setStorageState`, then return to the app on that same page and verify the account:
55
+
56
+ ```js
57
+ await page.goto('about:blank')
58
+ await page.context().setStorageState('.auth/second-account.json')
59
+ await page.goto('http://localhost:3000')
60
+ await page.getByRole('heading', { name: 'Second account' }).waitFor()
61
+ await page.evaluate(() => document.fonts.ready)
62
+ ```
63
+
64
+ Replace the URL and headings with your application's account-specific checks. `setStorageState` replaces cookies and origin storage, including local storage and IndexedDB, rather than just adding cookies. Neither saving nor replacing storage state handles `sessionStorage`; if the app uses it for authentication, restore or clear it separately for the relevant origins and follow the app's login requirements. Opening a replacement page would leave Traceframe recording the original page.
24
65
 
25
66
  ## Choose the right actions
26
67
 
@@ -58,6 +99,42 @@ Before a follow-up, wait for the application to show that the current step is co
58
99
 
59
100
  Set a Playwright timeout explicitly when an individual operation may exceed its usual 30 seconds, such as `await newReply.waitFor({ timeout: 60_000 })`. Use Traceframe's `wait(seconds)` for reading time. Each helper wait is limited to 30 seconds, and the whole flow has a 120-second timeout that `--flow-timeout` (`flowTimeout` in the API) raises.
60
101
 
102
+ ## Add captions and reading time
103
+
104
+ For a review video, explain what the viewer should notice. Show the caption, pause before moving the pointer, perform the action, then hold the result. Start with about 12 characters per second and a 2.5-second minimum for reading, followed by a 2.5–4 second result hold.
105
+
106
+ Use Playwright's [`page.screencast.showOverlay()`](https://playwright.dev/docs/api/class-screencast#screencast-show-overlay) inside the flow. Keep the caption compact, centered, and clear of the controls and results being shown. Traceframe's covered-click check ignores this pointer-transparent caption; use `top: 32px` instead of `bottom: 32px` when it would hide controls or results near the bottom. Keep its handle and dispose of it after the result hold:
107
+
108
+ ```js
109
+ const text = 'Open Actions to inspect the available options.'
110
+ const caption = await page.screencast.showOverlay(`
111
+ <div style="position: fixed; left: 24px; right: 24px; bottom: 32px;
112
+ display: flex; justify-content: center; pointer-events: none;">
113
+ <div dir="auto" style="max-width: 720px; padding: 12px 20px; border-radius: 10px;
114
+ background: #18181b; color: #fff;
115
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16);
116
+ font: 500 20px/1.45 system-ui, sans-serif;
117
+ text-align: center; text-wrap: balance;">
118
+ ${text}
119
+ </div>
120
+ </div>
121
+ `)
122
+ try {
123
+ await wait(Math.max(2.5, Array.from(text).length / 12))
124
+ await click(page.getByRole('button', { name: 'Actions', exact: true }))
125
+ await page.getByRole('menu').waitFor()
126
+ await wait(3)
127
+ } finally {
128
+ await caption.dispose()
129
+ }
130
+ ```
131
+
132
+ Omit `duration` when actions should happen while the caption is visible. Playwright uses milliseconds for `duration`: awaiting `showOverlay(html, { duration: 3000 })` waits until the overlay is removed. The example uses fixed text; escape dynamic text before inserting it into HTML.
133
+
134
+ Playwright restores overlays after a full-page navigation, but they can briefly disappear while the new document loads. Opening a native modal `<dialog>` can also briefly cover an existing caption. For a clean caption transition, finish the reading pause and dispose of the current caption before the action that navigates or opens the dialog, then show the next caption after the destination or dialog is ready.
135
+
136
+ Keep captions short. Split longer text into separate captions so each reading pause stays within the helper's 30-second limit. Reading pauses and result holds count toward the flow timeout; for a longer video, raise it with, for example, `--flow-timeout 240` (seconds).
137
+
61
138
  ## Optional startup settings
62
139
 
63
140
  `page.emulateMedia({ colorScheme: 'dark' })` in `ready` updates CSS themes that follow the browser's color scheme. If the app reads the setting only at startup, set it before opening the app, as below.
@@ -83,14 +160,54 @@ The two `Emulation` calls set the locale that `navigator.language`, `Intl`, and
83
160
 
84
161
  Use `--preset draft` while writing the flow and the default preset for the final video.
85
162
 
86
- While writing the flow, pass `--run-dir` with an ignored folder, such as `.traceframe/runs/demo`. A failed take then keeps its partial `capture.webm`, which has no cursor overlay and stops where the flow failed.
163
+ While writing the flow, pass `--run-dir` with an ignored folder, such as `.traceframe/runs/demo`. Use a separate directory for each take. A failed take keeps its partial `capture.webm` when saving succeeds and attempts a viewport screenshot, `failure.png`. The raw capture has no added cursor overlay and may include repeated final frames after the failure time.
87
164
 
88
- A failed take may already have submitted real jobs, so reset demo data before recording again. Check the important transitions and the final result. You can also check the file for decode errors:
165
+ A failed take may already have submitted real jobs, so reset demo data before recording again. Check the finished file for decode errors:
89
166
 
90
167
  ```sh
91
168
  ffmpeg -v error -xerror -i ./recording.mp4 -f null -
92
169
  ```
93
170
 
171
+ For a visual overview of the whole recording, sample roughly every two seconds into numbered contact sheets. Add `.traceframe/` to your project's `.gitignore` and use a new inspection directory for each take:
172
+
173
+ ```sh
174
+ mkdir -p .traceframe/review/take-1
175
+ ffmpeg -i ./recording.mp4 \
176
+ -vf "select='isnan(prev_selected_t)+gte(t-prev_selected_t,2)',scale=640:-1,drawtext=text='%{pts\:hms}':x=8:y=8:fontsize=20:fontcolor=white:box=1:boxcolor=black@0.6,tile=4x5:padding=4" \
177
+ -fps_mode vfr .traceframe/review/take-1/sheet-%03d.png
178
+ ```
179
+
180
+ Each sheet holds 20 timestamped samples, covering about 40 seconds. The command continues through the entire input; the last sheet may have empty cells. Do not add `-frames:v 1`, which would stop after the first sheet.
181
+
182
+ On FFmpeg 5.0, replace `-fps_mode vfr` with `-vsync vfr`.
183
+
184
+ Timestamp labels require FFmpeg's `drawtext` filter and an available font. Homebrew's current standard `ffmpeg` omits that filter; run `brew install ffmpeg-full` and use `"$(brew --prefix ffmpeg-full)/bin/ffmpeg"` in place of `ffmpeg` in the command above. If `drawtext` is available but no default font is found, add `fontfile=/path/to/font.ttf:` before `text=`.
185
+
186
+ Inspect every sheet, then extract important results, account changes and transitions at the video's full resolution. For example, inspect the frame at 12.4 seconds:
187
+
188
+ ```sh
189
+ ffmpeg -ss 00:00:12.400 -i ./recording.mp4 \
190
+ -frames:v 1 -update 1 .traceframe/review/take-1/detail-12.400.png
191
+ ```
192
+
193
+ Small text, focus rings and brief glitches can disappear in the reduced samples. Extract nearby frames around a suspicious transition and check caption reading pauses, pointer motion and result holds against the flow's timing. Contact sheets and successful decoding alone do not establish readable pacing or smooth motion. An agent preparing a demonstration should complete these checks before presenting it for the human's feature review.
194
+
195
+ ## Diagnose a failed take
196
+
197
+ The CLI keeps the original error message and adds available context on standard error. For example:
198
+
199
+ ```text
200
+ traceframe: [FLOW_FAILED] Expected the Saved status.
201
+ Flow: /project/review.mjs:18:9
202
+ Recording time: 12.35 s
203
+ Capture: /project/.traceframe/runs/demo/capture.webm
204
+ Screenshot: /project/.traceframe/runs/demo/failure.png
205
+ ```
206
+
207
+ Inspect the reported screenshot, then the capture near the reported time if needed. The time is measured from the first captured frame before diagnostic collection; it is not the raw file's duration. Correct the locator or application check and reset demo data before retrying.
208
+
209
+ Only paths saved for the current attempt are reported. A closed or unresponsive page may prevent a screenshot or capture from being saved. Errors before the first frame have no recording time, and a flow timeout may have no source location. Without `--run-dir`, flow locations and timing are still reported when available, but temporary captures are removed and no failure screenshot is saved. The API exposes the same context through [`error.diagnostics`](../README.md#errors-and-cancellation).
210
+
94
211
  ## Edit the finished video
95
212
 
96
213
  Edit the finished MP4 with FFmpeg rather than changing the flow. Editing finished frames keeps the page and cursor together. Keep the original, and say when a clip's pacing was edited.
@@ -0,0 +1,30 @@
1
+ import { chromium } from 'playwright'
2
+ import { recordPage } from '@milkstraw/traceframe'
3
+
4
+ const browser = await chromium.launch()
5
+ try {
6
+ const page = await browser.newPage({ viewport: { width: 1280, height: 800 } })
7
+ await page.goto(new URL('./recipes.html', import.meta.url).href)
8
+ await page.getByRole('heading', { name: 'Conversation demo' }).waitFor()
9
+ await page.evaluate(() => document.fonts.ready)
10
+
11
+ // These locators belong to the caller's existing script.
12
+ const message = page.getByRole('textbox', { name: 'Message' })
13
+ const replies = page.getByRole('list', { name: 'Replies' }).getByRole('listitem')
14
+ const summary = await recordPage({
15
+ page,
16
+ output: process.argv[2] ?? '.traceframe/existing-page.mp4',
17
+ flow: async ({ click, wait, signal }) => {
18
+ await click(message)
19
+ await message.pressSequentially('Record this existing page.', { delay: 45, signal })
20
+ await click(page.getByRole('button', { name: 'Send', exact: true }))
21
+ await replies.first().waitFor({ signal })
22
+ await wait(3)
23
+ },
24
+ })
25
+ // The page is still usable after recording.
26
+ await page.getByRole('status').filter({ hasText: 'Reply complete.' }).waitFor()
27
+ console.log(JSON.stringify(summary))
28
+ } finally {
29
+ await browser.close()
30
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milkstraw/traceframe",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Record scripted browser flows and render smooth cursor overlays into video.",
5
5
  "keywords": [
6
6
  "playwright",
@@ -26,6 +26,7 @@
26
26
  "files": [
27
27
  "src",
28
28
  "docs/recording-flows.md",
29
+ "examples/existing-page.mjs",
29
30
  "examples/recipes.html",
30
31
  "examples/recipes-flow.mjs"
31
32
  ],
package/src/capture.mjs CHANGED
@@ -1,51 +1,139 @@
1
1
  import { addAbortListener } from 'node:events'
2
+ import { writeFile } from 'node:fs/promises'
2
3
  import { join } from 'node:path'
3
4
  import { setTimeout as delay } from 'node:timers/promises'
4
5
  import { chromium } from 'playwright'
5
6
 
6
7
  import { TraceframeError } from './errors.mjs'
8
+ import { addDiagnostics, retainedFile } from './diagnostics.mjs'
7
9
  import { createFlowApi } from './flow.mjs'
8
10
 
9
11
  const PLAYWRIGHT_DEFAULT_TIMEOUT = 30_000
10
12
  const FIRST_FRAME_TIMEOUT = 10_000
13
+ const FAILURE_SCREENSHOT_TIMEOUT = 5_000
11
14
 
12
- export async function captureFlow({ config, flow, directory, signal: callerSignal }) {
15
+ export async function captureFlow({ config, flow, directory, page, signal: callerSignal }) {
13
16
  const signal = callerSignal ?? new AbortController().signal
14
17
  const rawPath = join(directory, 'capture.webm')
15
18
  const trace = { initialPointer: flow.initialPointer, moves: [], clicks: [] }
16
19
 
17
20
  signal.throwIfAborted()
18
- const browser = await launchBrowser(config, { handleSignals: !callerSignal })
19
- let page
21
+ const browser = page ? undefined : await launchBrowser(config, { handleSignals: !callerSignal })
22
+ let clock
23
+ let recording = false
24
+ let failed = false
20
25
  try {
21
- page = await browser.newPage({ viewport: config.viewport, deviceScaleFactor: config.scale, storageState: config.storageState })
22
- await raceAbort(waitForReady(page, flow, config, signal), signal)
23
- await page.mouse.move(trace.initialPointer.x, trace.initialPointer.y)
26
+ if (browser) {
27
+ page = await browser.newPage({ viewport: config.viewport, deviceScaleFactor: config.scale, storageState: config.storageState })
28
+ await raceAbort(waitForReady(page, flow, config, signal), signal)
29
+ signal.throwIfAborted()
30
+ await page.mouse.move(trace.initialPointer.x, trace.initialPointer.y)
31
+ } else {
32
+ await assertPageGeometry(page, config, signal)
33
+ }
24
34
 
25
35
  const firstFrame = Promise.withResolvers()
26
- await startScreencast(page, rawPath, config, ({ timestamp }) => firstFrame.resolve(timestamp))
27
- const clock = await withDeadline(
36
+ signal.throwIfAborted()
37
+ await startScreencast(page, rawPath, config, firstFrame.resolve)
38
+ recording = true
39
+ const frame = await withDeadline(
28
40
  () => firstFrame.promise,
29
41
  FIRST_FRAME_TIMEOUT,
30
42
  new TraceframeError(`No screencast frame arrived within ${FIRST_FRAME_TIMEOUT / 1000} seconds.`, { code: 'FIRST_FRAME_TIMEOUT' }),
31
43
  signal,
32
44
  )
33
- await withDeadline(
34
- (flowSignal) => flow.run({ page, signal: flowSignal, ...createFlowApi({ page, trace, clock, viewport: config.viewport, signal: flowSignal }) }),
35
- config.flowTimeout * 1000,
36
- new TraceframeError(`Flow exceeded the ${config.flowTimeout}-second flow timeout. Raise it with the flowTimeout option, or --flow-timeout.`, { code: 'FLOW_TOO_LONG' }),
37
- signal,
38
- )
45
+ clock = frame.timestamp
46
+ if (!browser) {
47
+ assertFrameSize(frame, config)
48
+ signal.throwIfAborted()
49
+ // Rejected caller-page attempts must leave the caller's pointer alone.
50
+ await page.mouse.move(trace.initialPointer.x, trace.initialPointer.y)
51
+ }
52
+ try {
53
+ await withDeadline(
54
+ (flowSignal) => flow.run({ page, signal: flowSignal, ...createFlowApi({ page, trace, clock, viewport: config.viewport, signal: flowSignal }) }),
55
+ config.flowTimeout * 1000,
56
+ new TraceframeError(`Flow exceeded the ${config.flowTimeout}-second flow timeout. Raise it with the flowTimeout option, or --flow-timeout.`, { code: 'FLOW_TOO_LONG' }),
57
+ signal,
58
+ )
59
+ } catch (error) {
60
+ if (signal.aborted || error instanceof TraceframeError) throw error
61
+ throw new TraceframeError(error instanceof Error ? error.message : String(error), { code: 'FLOW_FAILED', cause: error })
62
+ }
39
63
  trace.end = (Date.now() - clock) / 1000
40
64
  await delay(config.postRoll * 1000, undefined, { signal })
65
+ // A failed stop must not be retried: Playwright may still be waiting to finish its artifact.
66
+ recording = false
41
67
  await page.screencast.stop()
42
68
  return { rawPath, trace }
43
69
  } catch (error) {
44
- // Closing the browser alone discards the screencast. Stop it so a run directory keeps what was recorded.
45
- await page?.screencast.stop().catch(() => {})
46
- throw error
70
+ failed = true
71
+ const recordingTime = clock === undefined ? undefined : Math.max(0, (Date.now() - clock) / 1000)
72
+ // Let the encoder finish saving before closing the browser, including on slower machines.
73
+ const [capturePath, screenshotPath] = await Promise.all([
74
+ recording ? page.screencast.stop()
75
+ .then(() => config.runDirectory ? retainedFile(rawPath) : undefined)
76
+ .catch(() => undefined) : undefined,
77
+ config.runDirectory && page && !signal.aborted ? saveFailureScreenshot(page, directory, signal) : undefined,
78
+ ])
79
+ if (signal.aborted) throw error
80
+ throw await addDiagnostics(error, { flow: config.flow, recordingTime, capturePath, screenshotPath })
47
81
  } finally {
48
- await browser.close()
82
+ // A shutdown error must not hide the flow failure.
83
+ if (browser) {
84
+ if (failed) await browser.close().catch(() => {})
85
+ else await browser.close()
86
+ }
87
+ }
88
+ }
89
+
90
+ async function assertPageGeometry(page, config, signal) {
91
+ const [width, height, scale, left, top] = await withDeadline(
92
+ () => page.evaluate(() => [innerWidth, innerHeight, visualViewport.scale, visualViewport.offsetLeft, visualViewport.offsetTop]),
93
+ FIRST_FRAME_TIMEOUT,
94
+ new TraceframeError(`Could not verify page geometry within ${FIRST_FRAME_TIMEOUT / 1000} seconds. Make sure the page is responsive.`, { code: 'CAPTURE_SIZE_MISMATCH' }),
95
+ signal,
96
+ )
97
+ if (width !== config.viewport.width || height !== config.viewport.height || scale !== 1 || left !== 0 || top !== 0) {
98
+ throw new TraceframeError(`Page geometry is ${width}×${height} with visual scale ${scale} and offset ${left},${top}; expected ${config.viewport.width}×${config.viewport.height} with scale 1 and no offset. Use 100% browser zoom and an unscaled page, and keep its geometry fixed during recording.`, { code: 'CAPTURE_SIZE_MISMATCH' })
99
+ }
100
+ }
101
+
102
+ function assertFrameSize(frame, config) {
103
+ // Playwright may pad the saved WebM. Check the actual JPEG, not DPR or the container size.
104
+ const size = jpegSize(frame.data)
105
+ if (size?.width !== config.video.width || size?.height !== config.video.height ||
106
+ frame.viewportWidth !== config.viewport.width || frame.viewportHeight !== config.viewport.height) {
107
+ const scaleHint = config.scale === 2 ? ' For scale 2, launch Chromium with --force-device-scale-factor=2; deviceScaleFactor alone is insufficient.' : ''
108
+ throw new TraceframeError(`Screencast frames are ${size ? `${size.width}×${size.height}` : 'not a readable JPEG'} for a ${frame.viewportWidth}×${frame.viewportHeight} viewport; expected ${config.video.width}×${config.video.height} for a ${config.viewport.width}×${config.viewport.height} viewport. Keep the viewport fixed and use the page exclusively for this recording.${scaleHint}`, { code: 'CAPTURE_SIZE_MISMATCH' })
109
+ }
110
+ }
111
+
112
+ function jpegSize(data) {
113
+ if (data.length < 2 || data.readUInt16BE(0) !== 0xffd8) return undefined
114
+ for (let offset = 2; offset + 8 < data.length;) {
115
+ const marker = data.readUInt16BE(offset)
116
+ // Chromium emits baseline JPEG; accept the other SOF encodings as well.
117
+ if (marker >= 0xffc0 && marker <= 0xffcf && ![0xffc4, 0xffc8, 0xffcc].includes(marker)) {
118
+ return { width: data.readUInt16BE(offset + 7), height: data.readUInt16BE(offset + 5) }
119
+ }
120
+ const length = data.readUInt16BE(offset + 2)
121
+ if (length < 2) return undefined
122
+ offset += length + 2
123
+ }
124
+ }
125
+
126
+ async function saveFailureScreenshot(page, directory, signal) {
127
+ try {
128
+ return await withDeadline(async (diagnosticSignal) => {
129
+ const screenshot = await page.screenshot({ timeout: FAILURE_SCREENSHOT_TIMEOUT })
130
+ diagnosticSignal.throwIfAborted()
131
+ const path = join(directory, 'failure.png')
132
+ await writeFile(path, screenshot, { signal: diagnosticSignal })
133
+ return path
134
+ }, FAILURE_SCREENSHOT_TIMEOUT, new Error('Failure screenshot timed out.'), signal)
135
+ } catch {
136
+ return undefined
49
137
  }
50
138
  }
51
139
 
@@ -84,6 +172,8 @@ async function startScreencast(page, path, config, onFrame) {
84
172
  await page.screencast.start({ path, size: { width: config.video.width, height: config.video.height }, fps: config.video.fps, quality: 95, onFrame })
85
173
  } catch (error) {
86
174
  if (!/Executable doesn't exist/i.test(error.message)) throw error
175
+ // Playwright added our frame client before looking for FFmpeg; no caller recorder was active.
176
+ await page.screencast.stop().catch(() => {})
87
177
  throw new TraceframeError("Playwright's FFmpeg is not installed. It is needed to record, even with --browser. Run `npx playwright@1.64.1 install ffmpeg`.", { code: 'PLAYWRIGHT_RUNTIME_MISSING', cause: error })
88
178
  }
89
179
  }
@@ -94,6 +184,7 @@ async function withDeadline(task, milliseconds, error, signal) {
94
184
  const timer = setTimeout(() => controller.abort(error), milliseconds)
95
185
  const deadline = AbortSignal.any([signal, controller.signal])
96
186
  try {
187
+ deadline.throwIfAborted()
97
188
  return await raceAbort(task(deadline), deadline)
98
189
  } finally {
99
190
  clearTimeout(timer)
package/src/cli.mjs CHANGED
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module'
4
4
  import { parseArgs } from 'node:util'
5
5
 
6
6
  import { LIMITS } from './flow.mjs'
7
+ import { formatFailure } from './diagnostics.mjs'
7
8
  import { record } from './record.mjs'
8
9
 
9
10
  const { version: VERSION } = createRequire(import.meta.url)('../package.json')
@@ -44,7 +45,7 @@ Options:
44
45
  --post-roll <seconds> Tail after the flow (default: 0.3)
45
46
  --ready-timeout <seconds> Playwright default timeout during navigation and ready() (default: 30)
46
47
  --flow-timeout <seconds> Time limit for the flow, including waits (default: 120)
47
- --run-dir <path> Keep the raw capture and FFmpeg filter in this directory
48
+ --run-dir <path> Keep the raw capture, failure screenshot and FFmpeg filter
48
49
  -h, --help Show this help
49
50
  -v, --version Show the version
50
51
 
@@ -74,7 +75,7 @@ try {
74
75
  process.stderr.write('traceframe: Recording was interrupted.\n')
75
76
  process.exitCode = SIGNAL_EXIT_CODES[interruptedBy]
76
77
  } else {
77
- process.stderr.write(`traceframe: ${error instanceof Error ? error.message : String(error)}\n`)
78
+ process.stderr.write(formatFailure(error))
78
79
  process.exitCode = 1
79
80
  }
80
81
  }
package/src/config.mjs CHANGED
@@ -16,40 +16,59 @@ export function createConfig(options = {}) {
16
16
  width: options.viewportWidth ?? preset.viewport.width,
17
17
  height: options.viewportHeight ?? preset.viewport.height,
18
18
  }
19
+ check(options.flow, 'A flow file is required (the flow option, or --flow).')
20
+ check(options.url, 'A URL is required (the url option, or --url).')
21
+ const readyTimeout = options.readyTimeout ?? 30
22
+ check(Number.isFinite(readyTimeout) && readyTimeout > 0, 'Ready timeout must be more than 0 seconds.')
23
+
24
+ return {
25
+ ...recordingConfig(options, viewport, preset),
26
+ url: options.url,
27
+ flow: resolve(options.flow),
28
+ storageState: options.storageState,
29
+ browser: options.browser ?? process.env.CHROME_PATH,
30
+ readyTimeout,
31
+ }
32
+ }
33
+
34
+ export function createPageConfig(options = {}) {
35
+ const { page } = options
36
+ check(typeof page?.viewportSize === 'function' && page.screencast, 'A Playwright Page with screencast support is required.')
37
+ check(!page.isClosed(), 'The supplied page is closed.')
38
+ const viewport = page.viewportSize()
39
+ check(viewport, 'recordPage requires a fixed viewport. Set it when creating the page or context; viewport: null is not supported.')
40
+ for (const key of ['url', 'browser', 'storageState', 'readyTimeout', 'viewportWidth', 'viewportHeight', 'preset']) {
41
+ check(options[key] === undefined, `recordPage does not accept ${key}; prepare the page before recording and use scale/fps for video settings.`)
42
+ }
43
+ return recordingConfig(options, viewport, { scale: 1, fps: 30 })
44
+ }
45
+
46
+ function recordingConfig(options, viewport, defaults) {
19
47
  check(Number.isInteger(viewport.width) && viewport.width > 0, 'Viewport width must be a positive integer.')
20
48
  check(Number.isInteger(viewport.height) && viewport.height > 0, 'Viewport height must be a positive integer.')
21
- const scale = options.scale ?? preset.scale
49
+ const scale = options.scale ?? defaults.scale
22
50
  check(scale === 1 || scale === 2, 'Scale must be 1 or 2.')
23
51
  const video = {
24
52
  width: viewport.width * scale,
25
53
  height: viewport.height * scale,
26
- fps: options.fps ?? preset.fps,
54
+ fps: options.fps ?? defaults.fps,
27
55
  }
28
56
  check(video.fps === 30 || video.fps === 60, 'FPS must be 30 or 60.')
29
57
  check(video.width % 2 === 0 && video.height % 2 === 0, 'At scale 1, the viewport width and height must be even.')
30
58
 
31
- check(options.flow, 'A flow file is required (the flow option, or --flow).')
32
- check(options.url, 'A URL is required (the url option, or --url).')
33
59
  const output = resolve(options.output ?? 'traceframe.mp4')
34
60
  check(output.toLowerCase().endsWith('.mp4'), 'Output path must use the .mp4 extension.')
35
61
  const postRoll = options.postRoll ?? 0.3
36
62
  check(Number.isFinite(postRoll) && postRoll >= 0, 'Post-roll must be 0 or more seconds.')
37
- const readyTimeout = options.readyTimeout ?? 30
38
- check(Number.isFinite(readyTimeout) && readyTimeout > 0, 'Ready timeout must be more than 0 seconds.')
39
63
  const flowTimeout = options.flowTimeout ?? 120
40
64
  check(Number.isFinite(flowTimeout) && flowTimeout > 0, 'Flow timeout must be more than 0 seconds.')
41
65
 
42
66
  return {
43
- url: options.url,
44
- flow: resolve(options.flow),
45
67
  output,
46
- storageState: options.storageState,
47
- browser: options.browser ?? process.env.CHROME_PATH,
48
- viewport,
68
+ viewport: { ...viewport },
49
69
  video,
50
70
  scale,
51
71
  postRoll,
52
- readyTimeout,
53
72
  flowTimeout,
54
73
  runDirectory: options.runDirectory ? resolve(options.runDirectory) : undefined,
55
74
  }
@@ -0,0 +1,52 @@
1
+ import { realpath, stat } from 'node:fs/promises'
2
+ import { pathToFileURL } from 'node:url'
3
+
4
+ import { TraceframeError } from './errors.mjs'
5
+
6
+ // Only report the supplied flow's locations, never the rest of a stack or error object.
7
+ export async function addDiagnostics(error, { flow, ...details }) {
8
+ if (!(error instanceof Error)) return error
9
+ try {
10
+ const flowLocations = []
11
+ if (flow) {
12
+ const paths = new Set([flow, await realpath(flow).catch(() => flow)])
13
+ const names = [...paths].flatMap((path) => [path, pathToFileURL(path).href])
14
+ const escaped = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
15
+ const location = new RegExp(`^\\s+at (?:async )?(?:.+ \\()?(${escaped.join('|')}):(\\d+):(\\d+)\\)?$`)
16
+ const seenErrors = new Set()
17
+ const seenLocations = new Set()
18
+ for (let current = error; current instanceof Error && !seenErrors.has(current); current = current.cause) {
19
+ seenErrors.add(current)
20
+ for (const frame of String(current.stack ?? '').split('\n')) {
21
+ const match = frame.match(location)
22
+ if (!match || seenLocations.has(`${match[2]}:${match[3]}`)) continue
23
+ seenLocations.add(`${match[2]}:${match[3]}`)
24
+ flowLocations.push({ file: flow, line: Number(match[2]), column: Number(match[3]) })
25
+ }
26
+ }
27
+ }
28
+ error.diagnostics = { flowLocations, ...Object.fromEntries(Object.entries(details).filter(([, value]) => value !== undefined)) }
29
+ } catch {
30
+ // A frozen error or an unavailable stack must not replace the original failure.
31
+ }
32
+ return error
33
+ }
34
+
35
+ export async function retainedFile(path) {
36
+ const info = await stat(path).catch(() => undefined)
37
+ return info?.isFile() && info.size > 0 ? path : undefined
38
+ }
39
+
40
+ export function formatFailure(error) {
41
+ const message = error instanceof Error ? error.message : String(error)
42
+ const code = error instanceof TraceframeError && error.code ? `[${error.code}] ` : ''
43
+ const lines = [`traceframe: ${code}${message.trimEnd()}`]
44
+ const diagnostics = error?.diagnostics
45
+ for (const { file, line, column } of diagnostics?.flowLocations ?? []) {
46
+ lines.push(` Flow: ${file}:${line}:${column}`)
47
+ }
48
+ if (Number.isFinite(diagnostics?.recordingTime)) lines.push(` Recording time: ${diagnostics.recordingTime.toFixed(2)} s`)
49
+ if (diagnostics?.capturePath) lines.push(` Capture: ${diagnostics.capturePath}`)
50
+ if (diagnostics?.screenshotPath) lines.push(` Screenshot: ${diagnostics.screenshotPath}`)
51
+ return `${lines.join('\n')}\n`
52
+ }
package/src/flow.mjs CHANGED
@@ -22,11 +22,16 @@ export async function loadFlow(path, viewport) {
22
22
  throw new TraceframeError(`Could not load flow: ${path}: ${error instanceof Error ? error.message : error}`, { code: 'INVALID_FLOW', cause: error })
23
23
  }
24
24
  if (typeof module.default !== 'function') throw new TraceframeError('Flow must export a default function.', { code: 'INVALID_FLOW' })
25
- const { x, y } = module.initialPointer ?? { x: Math.round(viewport.width / 4), y: Math.max(0, viewport.height - 80) }
25
+ return { ...createFlow(module.default, viewport, module.initialPointer), ready: module.ready }
26
+ }
27
+
28
+ export function createFlow(run, viewport, initialPointer) {
29
+ if (typeof run !== 'function') throw new TraceframeError('Flow must be a function.', { code: 'INVALID_FLOW' })
30
+ const { x, y } = initialPointer ?? { x: Math.round(viewport.width / 4), y: Math.max(0, viewport.height - 80) }
26
31
  if (!insideViewport({ x, y }, viewport)) {
27
32
  throw new TraceframeError('Flow initialPointer must be a point inside the viewport.', { code: 'INVALID_FLOW' })
28
33
  }
29
- return { run: module.default, ready: module.ready, initialPointer: { x, y } }
34
+ return { run, initialPointer: { x, y } }
30
35
  }
31
36
 
32
37
  // Events are timed in seconds from `clock`, the epoch milliseconds of the first video frame.
@@ -35,6 +40,7 @@ export function createFlowApi({ page, trace, clock, viewport, signal }) {
35
40
  const sleep = (milliseconds) => delay(milliseconds, undefined, { signal })
36
41
 
37
42
  const movePointer = async (point, duration) => {
43
+ signal?.throwIfAborted()
38
44
  assertBelowLimit(trace.moves, LIMITS.maxMoves, 'move')
39
45
  const from = pointer
40
46
  const to = { x: point.x, y: point.y }
@@ -48,24 +54,25 @@ export function createFlowApi({ page, trace, clock, viewport, signal }) {
48
54
  await sleep(Math.min(MOVE_SAMPLE_MS, total - elapsed))
49
55
  elapsed = Math.min(total, performance.now() - startedAt)
50
56
  const eased = easeOutCubic(elapsed / total)
57
+ signal?.throwIfAborted()
51
58
  await page.mouse.move(from.x + (to.x - from.x) * eased, from.y + (to.y - from.y) * eased)
52
59
  }
53
60
  pointer = to
54
61
  }
55
62
 
56
- const moveTo = async (locator, duration, name) => {
63
+ const moveTo = async (locator, duration, name, handles) => {
57
64
  const seconds = durationSeconds(duration ?? MOVE_SECONDS, `${name} duration`)
58
- const target = await findTarget(locator)
65
+ const target = await findTarget(locator, signal, handles)
59
66
  // A person does not chase a target that shifts under a pointer already on it.
60
67
  if (pointInsideBox(pointer, target.box)) return pointer
61
68
  let point = await aimPoint(target)
62
69
  if (!insideViewport(point, viewport)) {
63
- const aim = await reveal(target.element, locator)
70
+ const aim = await reveal(target.element, locator, signal, handles)
64
71
  if (!aim.visible) throw new TraceframeError(`${locator} could not be revealed by scrolling.`, { code: 'FLOW_TARGET_OUTSIDE_VIEWPORT' })
65
72
  point = aim.point
66
73
  }
67
74
  await movePointer(point, seconds)
68
- const after = await locator.boundingBox()
75
+ const after = await locator.boundingBox({ signal })
69
76
  if (!after || !pointInsideBox(point, after)) {
70
77
  throw new TraceframeError(`${locator} moved while the pointer was arriving.`, { code: 'FLOW_TARGET_MOVED' })
71
78
  }
@@ -73,21 +80,23 @@ export function createFlowApi({ page, trace, clock, viewport, signal }) {
73
80
  }
74
81
 
75
82
  return {
76
- async hover(locator, options = {}) {
77
- await moveTo(locator, options.duration, 'hover')
78
- },
79
- async click(locator, options = {}) {
83
+ hover: withHandles(async (handles, locator, options = {}) => {
84
+ await moveTo(locator, options.duration, 'hover', handles)
85
+ }),
86
+ click: withHandles(async (handles, locator, options = {}) => {
80
87
  assertBelowLimit(trace.clicks, LIMITS.maxClicks, 'click')
81
88
  const dwell = durationSeconds(options.dwell ?? DWELL_SECONDS, 'click dwell', { allowZero: true })
82
- const point = await moveTo(locator, options.duration, 'click')
89
+ const point = await moveTo(locator, options.duration, 'click', handles)
83
90
  await sleep(dwell * 1000)
84
91
  // Hold on to this element: a control may rename or hide itself once it is pressed.
85
- const element = await locator.elementHandle()
86
- await element.waitForElementState('enabled').catch((error) => {
92
+ const element = await locator.elementHandle({ signal })
93
+ handles.push(element)
94
+ await element.waitForElementState('enabled', { signal }).catch((error) => {
87
95
  throw error.name === 'TimeoutError' ? new TraceframeError(`${locator} stayed disabled.`, { code: 'FLOW_TARGET_DISABLED', cause: error }) : error
88
96
  })
89
- await assertReceivesPointer(element, locator, point)
97
+ await assertReceivesPointer(element, locator, point, handles)
90
98
  const at = secondsSince(clock)
99
+ signal?.throwIfAborted()
91
100
  await page.mouse.down()
92
101
  try {
93
102
  await sleep(PRESS_HOLD_MS)
@@ -101,23 +110,35 @@ export function createFlowApi({ page, trace, clock, viewport, signal }) {
101
110
  await page.mouse.up()
102
111
  }
103
112
  trace.clicks.push({ at, point })
104
- },
113
+ }),
105
114
  async move(point, options = {}) {
106
115
  if (!insideViewport(point, viewport)) {
107
116
  throw new TraceframeError('move requires an { x, y } point inside the viewport.', { code: 'INVALID_FLOW' })
108
117
  }
109
118
  await movePointer(point, durationSeconds(options.duration ?? MOVE_SECONDS, 'move duration'))
110
119
  },
111
- async scroll(locator) {
112
- const { shown } = await reveal((await findTarget(locator)).element, locator)
120
+ scroll: withHandles(async (handles, locator) => {
121
+ const { shown } = await reveal((await findTarget(locator, signal, handles)).element, locator, signal, handles)
113
122
  if (!shown) throw new TraceframeError(`${locator} could not be revealed by scrolling.`, { code: 'FLOW_TARGET_OUTSIDE_VIEWPORT' })
114
- },
123
+ }),
115
124
  async wait(seconds) {
116
125
  await sleep(durationSeconds(seconds, 'wait', { allowZero: true }) * 1000)
117
126
  },
118
127
  }
119
128
  }
120
129
 
130
+ // A helper may outlive a cancelled recording; release its handles when that helper actually settles.
131
+ function withHandles(run) {
132
+ return async (...args) => {
133
+ const handles = []
134
+ try {
135
+ return await run(handles, ...args)
136
+ } finally {
137
+ await Promise.all(handles.map(handle => handle.dispose().catch(() => {})))
138
+ }
139
+ }
140
+ }
141
+
121
142
  function insideViewport(point, viewport) {
122
143
  return point?.x >= 0 && point?.y >= 0 && point.x <= viewport.width && point.y <= viewport.height
123
144
  }
@@ -145,9 +166,10 @@ function assertBelowLimit(events, limit, type) {
145
166
  }
146
167
  }
147
168
 
148
- async function findTarget(locator) {
149
- await locator.waitFor()
150
- const element = await locator.elementHandle()
169
+ async function findTarget(locator, signal, handles) {
170
+ await locator.waitFor({ signal })
171
+ const element = await locator.elementHandle({ signal })
172
+ handles.push(element)
151
173
  const box = await element.boundingBox()
152
174
  if (!box) throw new TraceframeError(`Could not find ${locator}.`, { code: 'FLOW_TARGET_MISSING' })
153
175
  return { element, box }
@@ -168,10 +190,10 @@ async function aimPoint({ element, box }) {
168
190
  return { x: box.x + offset.x * box.width, y: box.y + offset.y * box.height }
169
191
  }
170
192
 
171
- async function reveal(element, locator) {
193
+ async function reveal(element, locator, signal, handles) {
172
194
  // Content that loads above the target during the scroll can push it out again.
173
195
  for (let attempt = 0; attempt < 2; attempt++) {
174
- const target = await scrollIntoView(element, locator)
196
+ const target = await scrollIntoView(element, locator, signal, handles)
175
197
  const point = await aimPoint(target)
176
198
  const visibility = await target.element.evaluate((node, offset) => new Promise((resolve) => {
177
199
  new IntersectionObserver(([entry], observer) => {
@@ -191,10 +213,11 @@ async function reveal(element, locator) {
191
213
 
192
214
  // Awaiting the scroll waits for a scrolling panel. Chromium resolves a page scroll before it stops, and a sticky target
193
215
  // or iframe can stay put while its page scrolls, so also wait for every enclosing page to stop.
194
- async function scrollIntoView(element, locator) {
216
+ async function scrollIntoView(element, locator, signal, handles) {
217
+ signal?.throwIfAborted()
195
218
  await element.evaluate((target) => target.scrollIntoView({ behavior: 'smooth', block: 'center' }))
196
219
  try {
197
- await element.waitForElementState('stable')
220
+ await element.waitForElementState('stable', { signal })
198
221
  let frame = await element.ownerFrame()
199
222
  while (frame) {
200
223
  await frame.evaluate(() => new Promise((resolve) => {
@@ -208,14 +231,16 @@ async function scrollIntoView(element, locator) {
208
231
  requestAnimationFrame(check)
209
232
  }))
210
233
  if (!frame.parentFrame()) break
211
- await (await frame.frameElement()).waitForElementState('stable')
234
+ const frameElement = await frame.frameElement()
235
+ handles.push(frameElement)
236
+ await frameElement.waitForElementState('stable', { signal })
212
237
  frame = frame.parentFrame()
213
238
  }
214
239
  // The page may replace the target while it scrolls, or remove or hide it, as a menu that closes on scroll does.
215
- const target = await findTarget(locator).catch((error) => {
240
+ const target = await findTarget(locator, signal, handles).catch((error) => {
216
241
  throw error.name === 'TimeoutError' ? new TraceframeError(`${locator} disappeared when it was scrolled into view.`, { code: 'FLOW_TARGET_MISSING', cause: error }) : error
217
242
  })
218
- await target.element.waitForElementState('stable')
243
+ await target.element.waitForElementState('stable', { signal })
219
244
  const box = await target.element.boundingBox()
220
245
  if (!box) throw new TraceframeError(`Could not find ${locator}.`, { code: 'FLOW_TARGET_MISSING' })
221
246
  return { element: target.element, box }
@@ -224,7 +249,7 @@ async function scrollIntoView(element, locator) {
224
249
  }
225
250
  }
226
251
 
227
- async function assertReceivesPointer(element, locator, point) {
252
+ async function assertReceivesPointer(element, locator, point, handles) {
228
253
  const box = await element.boundingBox()
229
254
  if (!box || !pointInsideBox(point, box)) {
230
255
  throw new TraceframeError(`${locator} moved before the click.`, { code: 'FLOW_TARGET_MOVED' })
@@ -238,15 +263,16 @@ async function assertReceivesPointer(element, locator, point) {
238
263
  }
239
264
  return false
240
265
  }, relativePoint(point, box))
241
- if (!receivesPointer || !(await framesReceivePointer(element, point))) {
266
+ if (!receivesPointer || !(await framesReceivePointer(element, point, handles))) {
242
267
  throw new TraceframeError(`${locator} is covered at the click point. If a dialog or toast covers it, wait until it closes; if a panel clips it or a sticky header or bar covers it, call scroll() on the target first.`, { code: 'FLOW_TARGET_OBSCURED' })
243
268
  }
244
269
  }
245
270
 
246
- async function framesReceivePointer(element, point) {
271
+ async function framesReceivePointer(element, point, handles) {
247
272
  let frame = await element.ownerFrame()
248
273
  while (frame?.parentFrame()) {
249
274
  const frameElement = await frame.frameElement()
275
+ handles.push(frameElement)
250
276
  const box = await frameElement.boundingBox()
251
277
  if (!box) return false
252
278
  const hit = await frameElement.evaluate((target, { x, y }) => {
package/src/index.mjs CHANGED
@@ -1,2 +1,2 @@
1
- export { record } from './record.mjs'
1
+ export { record, recordPage } from './record.mjs'
2
2
  export { TraceframeError } from './errors.mjs'
package/src/record.mjs CHANGED
@@ -2,24 +2,52 @@ import { mkdir, mkdtemp, rm } from 'node:fs/promises'
2
2
  import { tmpdir } from 'node:os'
3
3
  import { dirname, join } from 'node:path'
4
4
 
5
- import { createConfig } from './config.mjs'
5
+ import { createConfig, createPageConfig } from './config.mjs'
6
6
  import { captureFlow } from './capture.mjs'
7
+ import { addDiagnostics, retainedFile } from './diagnostics.mjs'
7
8
  import { probeFfmpeg } from './ffmpeg.mjs'
8
- import { loadFlow } from './flow.mjs'
9
+ import { createFlow, loadFlow } from './flow.mjs'
9
10
  import { renderVideo } from './render.mjs'
11
+ import { TraceframeError } from './errors.mjs'
12
+
13
+ const activePages = new WeakSet()
10
14
 
11
15
  export async function record(options = {}) {
12
16
  const { signal } = options
13
17
  signal?.throwIfAborted()
14
18
  const config = createConfig(options)
15
19
  const ffmpeg = await probeFfmpeg()
16
- const flow = await loadFlow(config.flow, config.viewport)
20
+ const flow = await loadFlow(config.flow, config.viewport).catch(async (error) => {
21
+ throw await addDiagnostics(error, { flow: config.flow })
22
+ })
23
+ return recordFlow({ config, flow, ffmpeg, signal })
24
+ }
25
+
26
+ export async function recordPage(options = {}) {
27
+ const { page, signal } = options
28
+ signal?.throwIfAborted()
29
+ const config = createPageConfig(options)
30
+ const flow = createFlow(options.flow, config.viewport, options.initialPointer)
31
+ if (activePages.has(page)) throw new TraceframeError('This page is already being recorded by Traceframe.', { code: 'PAGE_RECORDING_ACTIVE' })
32
+ activePages.add(page)
33
+ try {
34
+ const ffmpeg = await probeFfmpeg()
35
+ return await recordFlow({ config, flow, ffmpeg, signal, page })
36
+ } finally {
37
+ activePages.delete(page)
38
+ }
39
+ }
40
+
41
+ async function recordFlow({ config, flow, ffmpeg, signal, page }) {
17
42
  await mkdir(dirname(config.output), { recursive: true })
18
43
  const directory = config.runDirectory ?? await mkdtemp(join(tmpdir(), 'traceframe-'))
19
44
  await mkdir(directory, { recursive: true })
20
45
 
46
+ let capture
47
+ let failed = false
21
48
  try {
22
- const { rawPath, trace } = await captureFlow({ config, flow, directory, signal })
49
+ capture = await captureFlow({ config, flow, directory, signal, page })
50
+ const { rawPath, trace } = capture
23
51
  const duration = await renderVideo({ rawPath, trace, directory, config, ffmpeg, signal })
24
52
  return {
25
53
  output: config.output,
@@ -32,8 +60,17 @@ export async function record(options = {}) {
32
60
  runDirectory: config.runDirectory,
33
61
  }
34
62
  } catch (error) {
63
+ failed = true
64
+ if (signal?.aborted) throw signal.reason
65
+ if (capture && config.runDirectory) {
66
+ await addDiagnostics(error, { flow: config.flow, capturePath: await retainedFile(capture.rawPath) })
67
+ }
35
68
  throw signal?.aborted ? signal.reason : error
36
69
  } finally {
37
- if (!config.runDirectory) await rm(directory, { recursive: true, force: true })
70
+ if (!config.runDirectory) {
71
+ const cleanup = rm(directory, { recursive: true, force: true })
72
+ if (failed) await cleanup.catch(() => {})
73
+ else await cleanup
74
+ }
38
75
  }
39
76
  }