@milkstraw/traceframe 0.3.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 +43 -4
- package/docs/recording-flows.md +68 -3
- package/examples/existing-page.mjs +30 -0
- package/package.json +2 -1
- package/src/capture.mjs +63 -10
- package/src/config.mjs +31 -12
- package/src/diagnostics.mjs +15 -13
- package/src/flow.mjs +57 -31
- package/src/index.mjs +1 -1
- package/src/record.mjs +25 -3
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`.
|
|
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,7 +53,7 @@ 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
|
|
|
@@ -145,20 +147,57 @@ await record({
|
|
|
145
147
|
})
|
|
146
148
|
```
|
|
147
149
|
|
|
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
|
+
|
|
148
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.
|
|
149
188
|
|
|
150
189
|
When available, `error.diagnostics` contains:
|
|
151
190
|
|
|
152
191
|
| Field | Meaning |
|
|
153
192
|
| --- | --- |
|
|
154
|
-
| `flowLocations` | Locations in the supplied flow file, as `{ file, line, column }` objects with an absolute path and 1-based line/column. Empty when the error stack has no matching frame. |
|
|
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. |
|
|
155
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. |
|
|
156
195
|
| `capturePath` | Absolute path to the retained raw capture, only when `runDirectory` was supplied and saving succeeded. |
|
|
157
196
|
| `screenshotPath` | Absolute path to the saved failure screenshot, only when `runDirectory` was supplied and the screenshot succeeded. |
|
|
158
197
|
|
|
159
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.
|
|
160
199
|
|
|
161
|
-
Pass `signal` to stop a recording early. Traceframe
|
|
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.
|
|
162
201
|
|
|
163
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.
|
|
164
203
|
|
package/docs/recording-flows.md
CHANGED
|
@@ -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,
|
|
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
|
|
|
@@ -121,12 +162,36 @@ Use `--preset draft` while writing the flow and the default preset for the final
|
|
|
121
162
|
|
|
122
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.
|
|
123
164
|
|
|
124
|
-
A failed take may already have submitted real jobs, so reset demo data before recording again. Check the
|
|
165
|
+
A failed take may already have submitted real jobs, so reset demo data before recording again. Check the finished file for decode errors:
|
|
125
166
|
|
|
126
167
|
```sh
|
|
127
168
|
ffmpeg -v error -xerror -i ./recording.mp4 -f null -
|
|
128
169
|
```
|
|
129
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
|
+
|
|
130
195
|
## Diagnose a failed take
|
|
131
196
|
|
|
132
197
|
The CLI keeps the original error message and adds available context on standard error. For example:
|
|
@@ -141,7 +206,7 @@ traceframe: [FLOW_FAILED] Expected the Saved status.
|
|
|
141
206
|
|
|
142
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.
|
|
143
208
|
|
|
144
|
-
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#
|
|
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).
|
|
145
210
|
|
|
146
211
|
## Edit the finished video
|
|
147
212
|
|
|
@@ -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.
|
|
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
|
@@ -12,31 +12,43 @@ const PLAYWRIGHT_DEFAULT_TIMEOUT = 30_000
|
|
|
12
12
|
const FIRST_FRAME_TIMEOUT = 10_000
|
|
13
13
|
const FAILURE_SCREENSHOT_TIMEOUT = 5_000
|
|
14
14
|
|
|
15
|
-
export async function captureFlow({ config, flow, directory, signal: callerSignal }) {
|
|
15
|
+
export async function captureFlow({ config, flow, directory, page, signal: callerSignal }) {
|
|
16
16
|
const signal = callerSignal ?? new AbortController().signal
|
|
17
17
|
const rawPath = join(directory, 'capture.webm')
|
|
18
18
|
const trace = { initialPointer: flow.initialPointer, moves: [], clicks: [] }
|
|
19
19
|
|
|
20
20
|
signal.throwIfAborted()
|
|
21
|
-
const browser = await launchBrowser(config, { handleSignals: !callerSignal })
|
|
22
|
-
let page
|
|
21
|
+
const browser = page ? undefined : await launchBrowser(config, { handleSignals: !callerSignal })
|
|
23
22
|
let clock
|
|
24
23
|
let recording = false
|
|
25
24
|
let failed = false
|
|
26
25
|
try {
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
}
|
|
30
34
|
|
|
31
35
|
const firstFrame = Promise.withResolvers()
|
|
32
|
-
|
|
36
|
+
signal.throwIfAborted()
|
|
37
|
+
await startScreencast(page, rawPath, config, firstFrame.resolve)
|
|
33
38
|
recording = true
|
|
34
|
-
|
|
39
|
+
const frame = await withDeadline(
|
|
35
40
|
() => firstFrame.promise,
|
|
36
41
|
FIRST_FRAME_TIMEOUT,
|
|
37
42
|
new TraceframeError(`No screencast frame arrived within ${FIRST_FRAME_TIMEOUT / 1000} seconds.`, { code: 'FIRST_FRAME_TIMEOUT' }),
|
|
38
43
|
signal,
|
|
39
44
|
)
|
|
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
|
+
}
|
|
40
52
|
try {
|
|
41
53
|
await withDeadline(
|
|
42
54
|
(flowSignal) => flow.run({ page, signal: flowSignal, ...createFlowApi({ page, trace, clock, viewport: config.viewport, signal: flowSignal }) }),
|
|
@@ -68,8 +80,46 @@ export async function captureFlow({ config, flow, directory, signal: callerSigna
|
|
|
68
80
|
throw await addDiagnostics(error, { flow: config.flow, recordingTime, capturePath, screenshotPath })
|
|
69
81
|
} finally {
|
|
70
82
|
// A shutdown error must not hide the flow failure.
|
|
71
|
-
if (
|
|
72
|
-
|
|
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
|
|
73
123
|
}
|
|
74
124
|
}
|
|
75
125
|
|
|
@@ -122,6 +172,8 @@ async function startScreencast(page, path, config, onFrame) {
|
|
|
122
172
|
await page.screencast.start({ path, size: { width: config.video.width, height: config.video.height }, fps: config.video.fps, quality: 95, onFrame })
|
|
123
173
|
} catch (error) {
|
|
124
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(() => {})
|
|
125
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 })
|
|
126
178
|
}
|
|
127
179
|
}
|
|
@@ -132,6 +184,7 @@ async function withDeadline(task, milliseconds, error, signal) {
|
|
|
132
184
|
const timer = setTimeout(() => controller.abort(error), milliseconds)
|
|
133
185
|
const deadline = AbortSignal.any([signal, controller.signal])
|
|
134
186
|
try {
|
|
187
|
+
deadline.throwIfAborted()
|
|
135
188
|
return await raceAbort(task(deadline), deadline)
|
|
136
189
|
} finally {
|
|
137
190
|
clearTimeout(timer)
|
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 ??
|
|
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 ??
|
|
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
|
-
|
|
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
|
}
|
package/src/diagnostics.mjs
CHANGED
|
@@ -7,20 +7,22 @@ import { TraceframeError } from './errors.mjs'
|
|
|
7
7
|
export async function addDiagnostics(error, { flow, ...details }) {
|
|
8
8
|
if (!(error instanceof Error)) return error
|
|
9
9
|
try {
|
|
10
|
-
const paths = new Set([flow, await realpath(flow).catch(() => flow)])
|
|
11
|
-
const names = [...paths].flatMap((path) => [path, pathToFileURL(path).href])
|
|
12
|
-
const escaped = names.map((name) => name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
|
|
13
|
-
const location = new RegExp(`^\\s+at (?:async )?(?:.+ \\()?(${escaped.join('|')}):(\\d+):(\\d+)\\)?$`)
|
|
14
10
|
const flowLocations = []
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
+
}
|
|
24
26
|
}
|
|
25
27
|
}
|
|
26
28
|
error.diagnostics = { flowLocations, ...Object.fromEntries(Object.entries(details).filter(([, value]) => value !== undefined)) }
|
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
|
-
|
|
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
|
|
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
|
|
77
|
-
await moveTo(locator, options.duration, 'hover')
|
|
78
|
-
},
|
|
79
|
-
async
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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,12 +2,15 @@ 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
7
|
import { addDiagnostics, retainedFile } from './diagnostics.mjs'
|
|
8
8
|
import { probeFfmpeg } from './ffmpeg.mjs'
|
|
9
|
-
import { loadFlow } from './flow.mjs'
|
|
9
|
+
import { createFlow, loadFlow } from './flow.mjs'
|
|
10
10
|
import { renderVideo } from './render.mjs'
|
|
11
|
+
import { TraceframeError } from './errors.mjs'
|
|
12
|
+
|
|
13
|
+
const activePages = new WeakSet()
|
|
11
14
|
|
|
12
15
|
export async function record(options = {}) {
|
|
13
16
|
const { signal } = options
|
|
@@ -17,6 +20,25 @@ export async function record(options = {}) {
|
|
|
17
20
|
const flow = await loadFlow(config.flow, config.viewport).catch(async (error) => {
|
|
18
21
|
throw await addDiagnostics(error, { flow: config.flow })
|
|
19
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 }) {
|
|
20
42
|
await mkdir(dirname(config.output), { recursive: true })
|
|
21
43
|
const directory = config.runDirectory ?? await mkdtemp(join(tmpdir(), 'traceframe-'))
|
|
22
44
|
await mkdir(directory, { recursive: true })
|
|
@@ -24,7 +46,7 @@ export async function record(options = {}) {
|
|
|
24
46
|
let capture
|
|
25
47
|
let failed = false
|
|
26
48
|
try {
|
|
27
|
-
capture = await captureFlow({ config, flow, directory, signal })
|
|
49
|
+
capture = await captureFlow({ config, flow, directory, signal, page })
|
|
28
50
|
const { rawPath, trace } = capture
|
|
29
51
|
const duration = await renderVideo({ rawPath, trace, directory, config, ffmpeg, signal })
|
|
30
52
|
return {
|