@milkstraw/traceframe 0.1.0 → 0.2.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 +92 -38
- package/docs/recording-flows.md +123 -0
- package/examples/recipes-flow.mjs +27 -0
- package/examples/recipes.html +74 -0
- package/package.json +5 -2
- package/src/capture.mjs +4 -4
- package/src/cli.mjs +6 -4
- package/src/config.mjs +3 -0
- package/src/flow.mjs +80 -13
package/README.md
CHANGED
|
@@ -4,61 +4,63 @@ Traceframe records a scripted Chromium flow and renders an MP4 with a smooth cur
|
|
|
4
4
|
|
|
5
5
|
https://github.com/user-attachments/assets/38e02cd5-a9ba-4f3c-938c-159955353141
|
|
6
6
|
|
|
7
|
-
##
|
|
7
|
+
## Prerequisites
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
npm install @milkstraw/traceframe
|
|
11
|
-
```
|
|
12
|
-
|
|
13
|
-
Traceframe requires Node.js 22+, FFmpeg 5+ with `libx264` and `ffprobe` (for example, `brew install ffmpeg` or `sudo apt install ffmpeg`), and Playwright's Chromium:
|
|
9
|
+
Traceframe requires Node.js 22+ and system FFmpeg 5+ with `libx264` and `ffprobe` to render the MP4 (for example, `brew install ffmpeg` or `sudo apt install ffmpeg`). Playwright uses its own Chromium and FFmpeg builds to record the page. Install them with the exact Playwright version Traceframe uses; other cached versions are not used:
|
|
14
10
|
|
|
15
11
|
```sh
|
|
16
|
-
npx playwright@1.64.1 install --only-shell chromium
|
|
12
|
+
npx playwright@1.64.1 install --only-shell chromium ffmpeg
|
|
17
13
|
```
|
|
18
14
|
|
|
19
|
-
To use
|
|
15
|
+
To use system Chrome 150 or later, pass `--browser` or set `CHROME_PATH`, and install Playwright's recording FFmpeg with `npx playwright@1.64.1 install ffmpeg`. Older Chrome versions can fail to scroll targets inside panels.
|
|
16
|
+
|
|
17
|
+
In a sandbox, such as a coding agent's, allow Playwright's browser download and let Chromium start.
|
|
20
18
|
|
|
21
19
|
To record pages behind a login, pass a Playwright storage-state file with `--storage-state`. Keep it in `.auth/` or another ignored location.
|
|
22
20
|
|
|
23
21
|
## Record a video
|
|
24
22
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
```sh
|
|
28
|
-
git clone https://github.com/milkstrawai/traceframe.git
|
|
29
|
-
cd traceframe
|
|
30
|
-
npm ci
|
|
31
|
-
npx traceframe record \
|
|
32
|
-
--url "file://$PWD/examples/fixture.html" \
|
|
33
|
-
--flow ./examples/fixture-flow.mjs \
|
|
34
|
-
--out ./sample.mp4 \
|
|
35
|
-
--preset draft
|
|
36
|
-
```
|
|
37
|
-
|
|
38
|
-
For an installed package, pass your application URL and your own flow file:
|
|
23
|
+
Run Traceframe with `npx` without adding it to your project's dependencies. Pass your application URL and your own [flow file](#write-a-flow):
|
|
39
24
|
|
|
40
25
|
```sh
|
|
41
|
-
npx traceframe record \
|
|
26
|
+
npx @milkstraw/traceframe record \
|
|
42
27
|
--url http://localhost:3000 \
|
|
43
28
|
--flow ./flow.mjs \
|
|
44
29
|
--out ./recording.mp4
|
|
45
30
|
```
|
|
46
31
|
|
|
47
|
-
|
|
32
|
+
For regular use, you can install the command globally:
|
|
48
33
|
|
|
49
|
-
|
|
34
|
+
```sh
|
|
35
|
+
npm install -g @milkstraw/traceframe
|
|
36
|
+
traceframe record --url http://localhost:3000 --flow ./flow.mjs --out ./recording.mp4
|
|
37
|
+
```
|
|
50
38
|
|
|
51
|
-
|
|
39
|
+
To check installation before touching your application, save this as `smoke.mjs`:
|
|
52
40
|
|
|
53
|
-
|
|
41
|
+
```js
|
|
42
|
+
export default async function ({ wait }) {
|
|
43
|
+
await wait(0.2)
|
|
44
|
+
}
|
|
45
|
+
```
|
|
54
46
|
|
|
55
|
-
```
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
47
|
+
```sh
|
|
48
|
+
npx @milkstraw/traceframe record \
|
|
49
|
+
--url 'data:text/html,<h1>Traceframe smoke</h1>' \
|
|
50
|
+
--flow ./smoke.mjs --out ./smoke.mp4 \
|
|
51
|
+
--preset draft
|
|
60
52
|
```
|
|
61
53
|
|
|
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.
|
|
55
|
+
|
|
56
|
+
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
|
+
|
|
58
|
+
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
|
+
|
|
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.
|
|
61
|
+
|
|
62
|
+
## Write a flow
|
|
63
|
+
|
|
62
64
|
The flow module exports a default function. Timing values are seconds:
|
|
63
65
|
|
|
64
66
|
```js
|
|
@@ -72,14 +74,64 @@ export default async function ({ page, click, wait }) {
|
|
|
72
74
|
}
|
|
73
75
|
```
|
|
74
76
|
|
|
75
|
-
`ready({ page, signal })` runs
|
|
77
|
+
The optional `ready({ page, signal })` hook runs after initial navigation and before recording starts. Export `initialPointer = { x, y }` to choose where the cursor starts; by default it starts near the lower left.
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
### Helpers
|
|
78
80
|
|
|
79
|
-
|
|
81
|
+
The default function receives a Playwright `page`, an `AbortSignal` named `signal`, and five helpers:
|
|
82
|
+
|
|
83
|
+
| Helper | Use |
|
|
84
|
+
| --- | --- |
|
|
85
|
+
| `move({ x, y }, { duration })` | Move the pointer to viewport coordinates. |
|
|
86
|
+
| `hover(locator, { duration })` | Move the pointer to a locator. |
|
|
87
|
+
| `click(locator, { duration, dwell })` | Move, pause, and click a locator. |
|
|
88
|
+
| `scroll(locator)` | Center a target without moving the pointer. |
|
|
89
|
+
| `wait(seconds)` | Pause for reading time. |
|
|
90
|
+
|
|
91
|
+
A move takes 0.5 seconds by default. A click pauses on the target for 0.25 seconds (`dwell`) before pressing, and waits for a disabled target to become enabled. `click` and `hover` leave the pointer in place if it is already inside the target's box.
|
|
92
|
+
|
|
93
|
+
Error messages name the locator; give it a clearer name with Playwright's `locator.describe('Save settings')`.
|
|
94
|
+
|
|
95
|
+
### Scrolling
|
|
96
|
+
|
|
97
|
+
`click` and `hover` scroll automatically only when their aim point starts outside the viewport. They scroll smoothly through the page, scrollable containers, and iframes, and wait for scrolling to stop before moving the pointer.
|
|
98
|
+
|
|
99
|
+
`scroll(target)` smoothly centers the target vertically, even if it is already visible, and waits for scrolling to stop without moving the pointer. Use it to show a result or before clicking a target clipped by a scrollable panel or covered by a sticky header. Wait for a dialog or toast to close instead.
|
|
100
|
+
|
|
101
|
+
Centering can leave a target partly visible or covered, for example by a sticky bar taller than half the height of the viewport or its scrolling panel. It cannot reveal content hidden by `overflow: clip`. `click` fails with `FLOW_TARGET_OBSCURED` if the target is still covered when it presses.
|
|
102
|
+
|
|
103
|
+
### Timing and limits
|
|
104
|
+
|
|
105
|
+
`--ready-timeout` (default: 30 seconds) sets Playwright's default timeout during initial navigation and `ready`, for operations that accept a timeout. It does not set a deadline for the whole hook.
|
|
106
|
+
|
|
107
|
+
The default function may run for 120 seconds, including waits for your backend. For longer flows, pass `--flow-timeout 300` (`flowTimeout: 300` in the API). This does not change Playwright's action timeouts. Helper calls stop promptly when the flow timeout is reached.
|
|
108
|
+
|
|
109
|
+
A flow may contain at most 100 moves and 100 clicks, and each `duration`, `dwell`, and `wait` is at most 30 seconds.
|
|
110
|
+
|
|
111
|
+
### What appears in the video
|
|
112
|
+
|
|
113
|
+
Playwright typing and scrolling appear in the captured page. Direct `page.mouse`, `locator.click()`, and dragging calls bypass Traceframe's cursor trace and click pulses, so use its pointer helpers for visible interactions. Only the starting page is recorded; new tabs and popups do not appear in the video.
|
|
114
|
+
|
|
115
|
+
### Have an agent create the flow
|
|
116
|
+
|
|
117
|
+
You can ask a coding agent to inspect your running UI and write the flow from a journey:
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
Create a Traceframe flow for this user journey: open Settings, change the theme, and save it.
|
|
121
|
+
Follow the Traceframe recording guide: https://github.com/milkstrawai/traceframe/blob/main/docs/recording-flows.md
|
|
122
|
+
Inspect the running application, use stable role or test-id locators, add a ready hook,
|
|
123
|
+
and use Traceframe's click, hover, move, scroll, and wait helpers for visible interactions.
|
|
124
|
+
Write the result to flow.mjs and explain how to run it.
|
|
125
|
+
```
|
|
80
126
|
|
|
81
127
|
## Programmatic usage
|
|
82
128
|
|
|
129
|
+
To call Traceframe from JavaScript, install it in your project:
|
|
130
|
+
|
|
131
|
+
```sh
|
|
132
|
+
npm install @milkstraw/traceframe
|
|
133
|
+
```
|
|
134
|
+
|
|
83
135
|
```js
|
|
84
136
|
import { record } from '@milkstraw/traceframe'
|
|
85
137
|
|
|
@@ -97,13 +149,13 @@ Pass `signal` to stop a recording early. Traceframe closes the browser, stops FF
|
|
|
97
149
|
|
|
98
150
|
## Sample output
|
|
99
151
|
|
|
100
|
-
The committed sample is generated from the
|
|
152
|
+
The committed sample is generated from the [repository's fixture flow](examples/fixture-flow.mjs) with the `standard` preset (2560×1600 at 60 FPS) and is about 12 seconds long:
|
|
101
153
|
|
|
102
154
|
[Download the sample video](https://github.com/milkstrawai/traceframe/blob/main/examples/sample-output.mp4)
|
|
103
155
|
|
|
104
|
-
The cursor and click-pulse artwork and the
|
|
156
|
+
The cursor and click-pulse artwork and the fixture page's icons were made for Traceframe and are distributed under this repository's MIT license.
|
|
105
157
|
|
|
106
|
-
The
|
|
158
|
+
The fixture page uses the [Inter](https://github.com/rsms/inter) font, included under the SIL Open Font License in `examples/fonts/OFL.txt`.
|
|
107
159
|
|
|
108
160
|
## Development
|
|
109
161
|
|
|
@@ -115,6 +167,8 @@ TRACEFRAME_PRESET=standard npm run test:smoke
|
|
|
115
167
|
npm run test:package
|
|
116
168
|
```
|
|
117
169
|
|
|
170
|
+
To release, set the version in `package.json` and `package-lock.json`, push all release changes to `main`, and publish a GitHub Release with the tag `v<version>` on the final tested commit containing those changes. The Release workflow reruns CI and stages the package on npm. A maintainer downloads it with `npm stage download <stage-id>`, checks that its files match `npm pack` at the tag, and approves it on npmjs.com with 2FA.
|
|
171
|
+
|
|
118
172
|
After editing an SVG in `src/assets`, run `npm run build:assets` to rebuild its 1× and 2× PNGs.
|
|
119
173
|
|
|
120
174
|
To re-record the committed sample:
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
# Write a recording flow
|
|
2
|
+
|
|
3
|
+
Use this guide yourself or give it to a coding agent with the journey you want to show. Traceframe provides pointer and scrolling helpers; the supplied Playwright page handles typing, file selection, and application checks.
|
|
4
|
+
|
|
5
|
+
## Run the example
|
|
6
|
+
|
|
7
|
+
The package includes [a complete flow](../examples/recipes-flow.mjs) and [its local page](../examples/recipes.html). It types English and Arabic, waits for two simulated replies, selects a fake file, and clicks a final action in a panel below the fold, which `click` scrolls into view first. After a global installation and the [prerequisites](../README.md#prerequisites), run:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
TRACEFRAME_EXAMPLES="$(npm root -g)/@milkstraw/traceframe/examples"
|
|
11
|
+
traceframe record \
|
|
12
|
+
--url "file://$TRACEFRAME_EXAMPLES/recipes.html" \
|
|
13
|
+
--flow "$TRACEFRAME_EXAMPLES/recipes-flow.mjs" \
|
|
14
|
+
--preset draft --out ./recipes.mp4
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
In a repository clone, run `npx traceframe record --url "file://$PWD/examples/recipes.html" --flow ./examples/recipes-flow.mjs --preset draft --out ./recipes.mp4` instead. These shell commands use macOS/Linux path syntax. The flow itself has no application dependencies, external services, or credentials.
|
|
18
|
+
|
|
19
|
+
## Prepare the journey
|
|
20
|
+
|
|
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
|
+
|
|
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).
|
|
24
|
+
|
|
25
|
+
## Choose the right actions
|
|
26
|
+
|
|
27
|
+
Use Traceframe's `click`, `hover`, and `move` for visible pointer actions. Direct `locator.click()` and `page.mouse` calls move the browser pointer without updating Traceframe's overlay or click pulses. Use Playwright for the other actions below; their page changes appear in the video.
|
|
28
|
+
|
|
29
|
+
For a target clipped by a scrollable panel or covered by a sticky header, try `await scroll(target)` before the click. For a tooltip, a toast, or a closing dialog, wait until it is gone, or give the click a longer `dwell`. A covered click fails with an error that it "is covered at the click point" (`FLOW_TARGET_OBSCURED`); `hover` does not check for covers. See the [scrolling rules and limits](../README.md#scrolling).
|
|
30
|
+
|
|
31
|
+
Both automatic scrolling and `scroll(target)` can move the outer page, which closes some lists. For those lists, use their own scrolling container through Playwright, for example `await list.evaluate((element) => element.scrollTo({ top: element.scrollHeight, behavior: 'smooth' }))` to show the final options, then click the option. Wait for any opening animation before scrolling.
|
|
32
|
+
|
|
33
|
+
To show a result without a pointer action, use `await scroll(result)`. Centering a result taller than the view hides its start, so scroll to its heading or another element near its start instead.
|
|
34
|
+
|
|
35
|
+
Focus a field visibly, then type with a delay in milliseconds:
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
await click(message)
|
|
39
|
+
await message.pressSequentially('A short question', { delay: 45 })
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
For an upload, hover the visible control, set the actual file input, and wait for the application's selected-file feedback. The visible label or drop zone and the input are often different elements:
|
|
43
|
+
|
|
44
|
+
```js
|
|
45
|
+
const dropZone = page.getByText('Choose a file', { exact: true })
|
|
46
|
+
const fileInput = page.getByLabel('Choose a file')
|
|
47
|
+
await hover(dropZone)
|
|
48
|
+
await fileInput.setInputFiles('./sample.txt')
|
|
49
|
+
await page.getByText('sample.txt').waitFor()
|
|
50
|
+
await wait(0.6)
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
A relative path is resolved from the directory where you run Traceframe. File selection does not show an operating-system file picker. The complete example supplies an in-memory fake file so it can run on its own.
|
|
54
|
+
|
|
55
|
+
## Wait for the current result
|
|
56
|
+
|
|
57
|
+
Before a follow-up, wait for the application to show that the current step is complete. For a conversation that appends completed replies, the example waits for the next reply by index. For streamed answers, wait for that request's completion indicator instead; the first visible text or a previous reply is not completion.
|
|
58
|
+
|
|
59
|
+
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
|
+
|
|
61
|
+
## Optional startup settings
|
|
62
|
+
|
|
63
|
+
`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.
|
|
64
|
+
|
|
65
|
+
If the application needs request headers, a browser locale, or a color scheme from startup, record `--url about:blank` and open the application yourself in `ready`. Keep only the lines you need:
|
|
66
|
+
|
|
67
|
+
```js
|
|
68
|
+
export async function ready({ page }) {
|
|
69
|
+
await page.emulateMedia({ colorScheme: 'dark' })
|
|
70
|
+
await page.setExtraHTTPHeaders({ 'x-demo-access': 'demo' })
|
|
71
|
+
const cdp = await page.context().newCDPSession(page)
|
|
72
|
+
await cdp.send('Emulation.setLocaleOverride', { locale: 'ar-EG' })
|
|
73
|
+
await cdp.send('Emulation.setUserAgentOverride', { userAgent: '', acceptLanguage: 'ar-EG' })
|
|
74
|
+
await page.goto('http://localhost:3000')
|
|
75
|
+
await page.getByRole('heading', { name: 'Example workspace' }).waitFor()
|
|
76
|
+
await page.evaluate(() => document.fonts.ready)
|
|
77
|
+
}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
The two `Emulation` calls set the locale that `navigator.language`, `Intl`, and the `Accept-Language` header use; the empty `userAgent` keeps Chromium's own user agent and client hints. Traceframe waits for web fonts only on the `--url` page, so the last line waits for the application's fonts.
|
|
81
|
+
|
|
82
|
+
## Record, inspect, and refine
|
|
83
|
+
|
|
84
|
+
Use `--preset draft` while writing the flow and the default preset for the final video.
|
|
85
|
+
|
|
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.
|
|
87
|
+
|
|
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:
|
|
89
|
+
|
|
90
|
+
```sh
|
|
91
|
+
ffmpeg -v error -xerror -i ./recording.mp4 -f null -
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## Edit the finished video
|
|
95
|
+
|
|
96
|
+
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.
|
|
97
|
+
|
|
98
|
+
To speed up an idle stretch, such as a progress bar, you need its start and end times. Read them in a video player, or log them from the flow; logged times run up to about 0.1 seconds earlier than in the video:
|
|
99
|
+
|
|
100
|
+
```js
|
|
101
|
+
export default async function ({ page, click, wait }) {
|
|
102
|
+
const started = Date.now()
|
|
103
|
+
const mark = (label) => console.error(`${label}: ${((Date.now() - started) / 1000).toFixed(2)} s`)
|
|
104
|
+
// ...
|
|
105
|
+
mark('progress started')
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
Log to `console.error`, because Traceframe prints its JSON summary on standard output. This command plays seconds 2 to 9 at 4× speed. Set `fps` to the video's frame rate: 60 for the default preset, 30 for draft.
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
ffmpeg -i recording.mp4 -filter_complex "[0:v]trim=0:2,setpts=PTS-STARTPTS[a];[0:v]trim=2:9,setpts=(PTS-STARTPTS)/4[b];[0:v]trim=9,setpts=PTS-STARTPTS[c];[a][b][c]concat=n=3,fps=60[v]" -map "[v]" -c:v libx264 -crf 18 -movflags +faststart faster.mp4
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
For an animated preview where an MP4 does not play inline, make a WebP. Not every FFmpeg build includes the WebP encoder.
|
|
116
|
+
|
|
117
|
+
```sh
|
|
118
|
+
ffmpeg -i recording.mp4 -vf "fps=12,scale=1280:-1" -c:v libwebp_anim -quality 75 -loop 0 preview.webp
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
## Optional sharing
|
|
122
|
+
|
|
123
|
+
With push access to a repository, current [GitHub CLI](https://docs.github.com/en/github-cli/github-cli/attaching-files-with-github-cli) can attach the MP4 to a comment so it plays there; check `gh pr comment --help` for your version. An MP4 hosted elsewhere shows as a link, not a player, so use the WebP preview there. Upload only when asked and after checking that the recording suits the destination; recording itself uploads nothing.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export async function ready({ page }) {
|
|
2
|
+
await page.getByRole('heading', { name: 'Conversation demo' }).waitFor()
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export default async function ({ page, click, hover, wait }) {
|
|
6
|
+
const message = page.getByRole('textbox', { name: 'Message' })
|
|
7
|
+
const replies = page.getByRole('list', { name: 'Replies' }).getByRole('listitem')
|
|
8
|
+
for (const [index, text] of ['Give me a short summary.', 'هل يمكنك التوضيح؟'].entries()) {
|
|
9
|
+
await click(message)
|
|
10
|
+
await message.pressSequentially(text, { delay: 45 })
|
|
11
|
+
await click(page.getByRole('button', { name: 'Send', exact: true }))
|
|
12
|
+
await replies.nth(index).waitFor({ timeout: 60_000 })
|
|
13
|
+
await wait(0.6)
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const upload = page.getByText('Choose a sample file', { exact: true })
|
|
17
|
+
await hover(upload)
|
|
18
|
+
await page.getByLabel('Choose a sample file').setInputFiles({
|
|
19
|
+
name: 'sample.txt', mimeType: 'text/plain', buffer: Buffer.from('Example file for a local demo.\n'),
|
|
20
|
+
})
|
|
21
|
+
await page.getByText('Selected: sample.txt', { exact: true }).waitFor()
|
|
22
|
+
await wait(0.6)
|
|
23
|
+
|
|
24
|
+
await click(page.getByRole('button', { name: 'Finish demo', exact: true }))
|
|
25
|
+
await page.getByRole('button', { name: 'Demo complete', exact: true }).waitFor()
|
|
26
|
+
await wait(0.8)
|
|
27
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Traceframe recording recipes</title>
|
|
7
|
+
<style>
|
|
8
|
+
:root { color-scheme: light; font: 18px/1.5 system-ui, sans-serif; color: #172033; background: #f4f6fa; }
|
|
9
|
+
* { box-sizing: border-box; }
|
|
10
|
+
main { max-width: 900px; margin: 40px auto; padding: 32px; border: 1px solid #dce1eb; border-radius: 16px; background: #fff; }
|
|
11
|
+
h1 { margin: 0; font-size: 28px; }
|
|
12
|
+
h2 { margin: 20px 0 8px; font-size: 18px; }
|
|
13
|
+
p { margin: 8px 0 20px; }
|
|
14
|
+
form > label { display: block; margin-bottom: 8px; }
|
|
15
|
+
.compose { display: flex; gap: 12px; }
|
|
16
|
+
input, button { font: inherit; padding: 10px 16px; border: 1px solid #aeb7c8; border-radius: 8px; }
|
|
17
|
+
input { min-width: 0; flex: 1; }
|
|
18
|
+
button { color: #fff; background: #3454b5; cursor: pointer; }
|
|
19
|
+
button:hover { background: #284393; }
|
|
20
|
+
button:disabled { opacity: 0.6; }
|
|
21
|
+
#status { min-height: 27px; margin: 8px 0; }
|
|
22
|
+
#replies { min-height: 80px; padding-left: 28px; }
|
|
23
|
+
#replies li { margin: 8px 0; }
|
|
24
|
+
.upload { display: inline-block; padding: 12px 16px; border: 1px dashed #aeb7c8; border-radius: 8px; cursor: pointer; }
|
|
25
|
+
.upload:hover { background: #f4f6fa; }
|
|
26
|
+
.scroll-area { height: 160px; overflow: auto; border: 1px solid #dce1eb; border-radius: 8px; }
|
|
27
|
+
.scroll-content { height: 360px; display: flex; align-items: end; padding: 20px; }
|
|
28
|
+
.below-fold { margin-top: 480px; }
|
|
29
|
+
</style>
|
|
30
|
+
</head>
|
|
31
|
+
<body>
|
|
32
|
+
<main>
|
|
33
|
+
<h1>Conversation demo</h1>
|
|
34
|
+
<p>Replies are simulated locally. No messages or files leave this page.</p>
|
|
35
|
+
<form id="conversation">
|
|
36
|
+
<label for="message">Message</label>
|
|
37
|
+
<div class="compose"><input id="message" dir="auto" required autocomplete="off"><button id="send">Send</button></div>
|
|
38
|
+
</form>
|
|
39
|
+
<p id="status" role="status">Ready for your question.</p>
|
|
40
|
+
<ol id="replies" aria-label="Replies"></ol>
|
|
41
|
+
<h2>Attach a file</h2>
|
|
42
|
+
<label class="upload" for="file">Choose a sample file</label><input id="file" type="file" hidden>
|
|
43
|
+
<p id="selected-file">No file selected.</p>
|
|
44
|
+
<h2 class="below-fold">Scroll to the last action</h2>
|
|
45
|
+
<section class="scroll-area" aria-label="More actions" tabindex="0">
|
|
46
|
+
<div class="scroll-content"><button id="finish" type="button">Finish demo</button></div>
|
|
47
|
+
</section>
|
|
48
|
+
</main>
|
|
49
|
+
<script>
|
|
50
|
+
const message = document.querySelector('#message')
|
|
51
|
+
const send = document.querySelector('#send')
|
|
52
|
+
const status = document.querySelector('#status')
|
|
53
|
+
const replies = document.querySelector('#replies')
|
|
54
|
+
document.querySelector('#conversation').addEventListener('submit', async (event) => {
|
|
55
|
+
event.preventDefault()
|
|
56
|
+
const text = message.value
|
|
57
|
+
message.value = ''
|
|
58
|
+
send.disabled = true
|
|
59
|
+
status.textContent = 'Preparing a reply…'
|
|
60
|
+
await new Promise((resolve) => setTimeout(resolve, 1000))
|
|
61
|
+
const reply = document.createElement('li')
|
|
62
|
+
reply.dir = 'auto'
|
|
63
|
+
reply.textContent = `Reply ${replies.children.length + 1}: ${text}`
|
|
64
|
+
replies.append(reply)
|
|
65
|
+
status.textContent = 'Reply complete.'
|
|
66
|
+
send.disabled = false
|
|
67
|
+
})
|
|
68
|
+
document.querySelector('#file').addEventListener('change', (event) => {
|
|
69
|
+
document.querySelector('#selected-file').textContent = `Selected: ${event.target.files[0]?.name ?? 'none'}`
|
|
70
|
+
})
|
|
71
|
+
document.querySelector('#finish').addEventListener('click', (event) => { event.currentTarget.textContent = 'Demo complete' })
|
|
72
|
+
</script>
|
|
73
|
+
</body>
|
|
74
|
+
</html>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@milkstraw/traceframe",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.2.0",
|
|
4
4
|
"description": "Record scripted browser flows and render smooth cursor overlays into video.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"playwright",
|
|
@@ -24,7 +24,10 @@
|
|
|
24
24
|
".": "./src/index.mjs"
|
|
25
25
|
},
|
|
26
26
|
"files": [
|
|
27
|
-
"src"
|
|
27
|
+
"src",
|
|
28
|
+
"docs/recording-flows.md",
|
|
29
|
+
"examples/recipes.html",
|
|
30
|
+
"examples/recipes-flow.mjs"
|
|
28
31
|
],
|
|
29
32
|
"engines": {
|
|
30
33
|
"node": ">=22"
|
package/src/capture.mjs
CHANGED
|
@@ -4,7 +4,7 @@ import { setTimeout as delay } from 'node:timers/promises'
|
|
|
4
4
|
import { chromium } from 'playwright'
|
|
5
5
|
|
|
6
6
|
import { TraceframeError } from './errors.mjs'
|
|
7
|
-
import { createFlowApi
|
|
7
|
+
import { createFlowApi } from './flow.mjs'
|
|
8
8
|
|
|
9
9
|
const PLAYWRIGHT_DEFAULT_TIMEOUT = 30_000
|
|
10
10
|
const FIRST_FRAME_TIMEOUT = 10_000
|
|
@@ -32,8 +32,8 @@ export async function captureFlow({ config, flow, directory, signal: callerSigna
|
|
|
32
32
|
)
|
|
33
33
|
await withDeadline(
|
|
34
34
|
(flowSignal) => flow.run({ page, signal: flowSignal, ...createFlowApi({ page, trace, clock, viewport: config.viewport, signal: flowSignal }) }),
|
|
35
|
-
|
|
36
|
-
new TraceframeError(`Flow exceeded the ${
|
|
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
37
|
signal,
|
|
38
38
|
)
|
|
39
39
|
trace.end = (Date.now() - clock) / 1000
|
|
@@ -62,7 +62,7 @@ async function launchBrowser(config, { handleSignals }) {
|
|
|
62
62
|
if (!/Executable doesn't exist/i.test(error.message)) throw error
|
|
63
63
|
const message = config.browser
|
|
64
64
|
? `Configured browser executable is unavailable: ${config.browser}`
|
|
65
|
-
: 'Playwright Chromium is not installed. Run `npx playwright@1.64.1 install --only-shell chromium
|
|
65
|
+
: 'Playwright Chromium is not installed. Run `npx playwright@1.64.1 install --only-shell chromium`, or pass --browser to a Chrome executable and run `npx playwright@1.64.1 install ffmpeg`.'
|
|
66
66
|
throw new TraceframeError(message, { code: 'PLAYWRIGHT_RUNTIME_MISSING', cause: error })
|
|
67
67
|
}
|
|
68
68
|
}
|
package/src/cli.mjs
CHANGED
|
@@ -21,9 +21,10 @@ const OPTION_KEYS = {
|
|
|
21
21
|
fps: 'fps',
|
|
22
22
|
'post-roll': 'postRoll',
|
|
23
23
|
'ready-timeout': 'readyTimeout',
|
|
24
|
+
'flow-timeout': 'flowTimeout',
|
|
24
25
|
'run-dir': 'runDirectory',
|
|
25
26
|
}
|
|
26
|
-
const NUMBER_KEYS = new Set(['viewportWidth', 'viewportHeight', 'scale', 'fps', 'postRoll', 'readyTimeout'])
|
|
27
|
+
const NUMBER_KEYS = new Set(['viewportWidth', 'viewportHeight', 'scale', 'fps', 'postRoll', 'readyTimeout', 'flowTimeout'])
|
|
27
28
|
const HELP = `traceframe ${VERSION}
|
|
28
29
|
|
|
29
30
|
Record a scripted Chromium flow and render a smooth cursor and click pulse.
|
|
@@ -41,7 +42,8 @@ Options:
|
|
|
41
42
|
--scale <1|2> Device scale factor; the video is the viewport times this
|
|
42
43
|
--fps <30|60> Encoded frame rate
|
|
43
44
|
--post-roll <seconds> Tail after the flow (default: 0.3)
|
|
44
|
-
--ready-timeout <seconds> Playwright timeout
|
|
45
|
+
--ready-timeout <seconds> Playwright default timeout during navigation and ready() (default: 30)
|
|
46
|
+
--flow-timeout <seconds> Time limit for the flow, including waits (default: 120)
|
|
45
47
|
--run-dir <path> Keep the raw capture and FFmpeg filter in this directory
|
|
46
48
|
-h, --help Show this help
|
|
47
49
|
-v, --version Show the version
|
|
@@ -49,8 +51,8 @@ Options:
|
|
|
49
51
|
The flow module must export a default function. It may also export
|
|
50
52
|
ready({ page, signal }) to establish state before capture and initialPointer
|
|
51
53
|
{ x, y } to set where the cursor starts.
|
|
52
|
-
The flow helpers are move(), hover(), click(), and wait(). A flow
|
|
53
|
-
at most ${LIMITS.maxMoves} move events
|
|
54
|
+
The flow helpers are move(), hover(), click(), scroll(), and wait(). A flow
|
|
55
|
+
may contain at most ${LIMITS.maxMoves} move events and ${LIMITS.maxClicks} click events.
|
|
54
56
|
Each duration, dwell, and wait is at most ${LIMITS.maxActionDuration} seconds.
|
|
55
57
|
`
|
|
56
58
|
|
package/src/config.mjs
CHANGED
|
@@ -36,6 +36,8 @@ export function createConfig(options = {}) {
|
|
|
36
36
|
check(Number.isFinite(postRoll) && postRoll >= 0, 'Post-roll must be 0 or more seconds.')
|
|
37
37
|
const readyTimeout = options.readyTimeout ?? 30
|
|
38
38
|
check(Number.isFinite(readyTimeout) && readyTimeout > 0, 'Ready timeout must be more than 0 seconds.')
|
|
39
|
+
const flowTimeout = options.flowTimeout ?? 120
|
|
40
|
+
check(Number.isFinite(flowTimeout) && flowTimeout > 0, 'Flow timeout must be more than 0 seconds.')
|
|
39
41
|
|
|
40
42
|
return {
|
|
41
43
|
url: options.url,
|
|
@@ -48,6 +50,7 @@ export function createConfig(options = {}) {
|
|
|
48
50
|
scale,
|
|
49
51
|
postRoll,
|
|
50
52
|
readyTimeout,
|
|
53
|
+
flowTimeout,
|
|
51
54
|
runDirectory: options.runDirectory ? resolve(options.runDirectory) : undefined,
|
|
52
55
|
}
|
|
53
56
|
}
|
package/src/flow.mjs
CHANGED
|
@@ -4,7 +4,6 @@ import { pathToFileURL } from 'node:url'
|
|
|
4
4
|
import { TraceframeError } from './errors.mjs'
|
|
5
5
|
|
|
6
6
|
export const LIMITS = {
|
|
7
|
-
maxDuration: 120,
|
|
8
7
|
maxMoves: 100,
|
|
9
8
|
maxClicks: 100,
|
|
10
9
|
maxActionDuration: 30,
|
|
@@ -56,14 +55,14 @@ export function createFlowApi({ page, trace, clock, viewport, signal }) {
|
|
|
56
55
|
|
|
57
56
|
const moveTo = async (locator, duration, name) => {
|
|
58
57
|
const seconds = durationSeconds(duration ?? MOVE_SECONDS, `${name} duration`)
|
|
59
|
-
await locator
|
|
60
|
-
|
|
61
|
-
if (
|
|
62
|
-
|
|
63
|
-
if (pointInsideBox(pointer, box)) return pointer
|
|
64
|
-
const point = await aimPoint(locator, box)
|
|
58
|
+
const target = await findTarget(locator)
|
|
59
|
+
// A person does not chase a target that shifts under a pointer already on it.
|
|
60
|
+
if (pointInsideBox(pointer, target.box)) return pointer
|
|
61
|
+
let point = await aimPoint(target)
|
|
65
62
|
if (!insideViewport(point, viewport)) {
|
|
66
|
-
|
|
63
|
+
const aim = await reveal(target.element, locator)
|
|
64
|
+
if (!aim.visible) throw new TraceframeError(`${locator} could not be revealed by scrolling.`, { code: 'FLOW_TARGET_OUTSIDE_VIEWPORT' })
|
|
65
|
+
point = aim.point
|
|
67
66
|
}
|
|
68
67
|
await movePointer(point, seconds)
|
|
69
68
|
const after = await locator.boundingBox()
|
|
@@ -109,6 +108,10 @@ export function createFlowApi({ page, trace, clock, viewport, signal }) {
|
|
|
109
108
|
}
|
|
110
109
|
await movePointer(point, durationSeconds(options.duration ?? MOVE_SECONDS, 'move duration'))
|
|
111
110
|
},
|
|
111
|
+
async scroll(locator) {
|
|
112
|
+
const { shown } = await reveal((await findTarget(locator)).element, locator)
|
|
113
|
+
if (!shown) throw new TraceframeError(`${locator} could not be revealed by scrolling.`, { code: 'FLOW_TARGET_OUTSIDE_VIEWPORT' })
|
|
114
|
+
},
|
|
112
115
|
async wait(seconds) {
|
|
113
116
|
await sleep(durationSeconds(seconds, 'wait', { allowZero: true }) * 1000)
|
|
114
117
|
},
|
|
@@ -142,21 +145,85 @@ function assertBelowLimit(events, limit, type) {
|
|
|
142
145
|
}
|
|
143
146
|
}
|
|
144
147
|
|
|
148
|
+
async function findTarget(locator) {
|
|
149
|
+
await locator.waitFor()
|
|
150
|
+
const element = await locator.elementHandle()
|
|
151
|
+
const box = await element.boundingBox()
|
|
152
|
+
if (!box) throw new TraceframeError(`Could not find ${locator}.`, { code: 'FLOW_TARGET_MISSING' })
|
|
153
|
+
return { element, box }
|
|
154
|
+
}
|
|
155
|
+
|
|
145
156
|
function pointInsideBox(point, box) {
|
|
146
157
|
return point.x >= box.x && point.x <= box.x + box.width && point.y >= box.y && point.y <= box.y + box.height
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
// Aim at the middle of the first line box, as Playwright does, so a link that wraps onto two lines is not missed.
|
|
150
|
-
async function aimPoint(
|
|
151
|
-
const offset = await
|
|
152
|
-
const bounds =
|
|
153
|
-
const first =
|
|
161
|
+
async function aimPoint({ element, box }) {
|
|
162
|
+
const offset = await element.evaluate((target) => {
|
|
163
|
+
const bounds = target.getBoundingClientRect()
|
|
164
|
+
const first = target.getClientRects()[0] ?? bounds
|
|
154
165
|
// As fractions of the element's size, because a scaled iframe makes the page box a different size than the element's own.
|
|
155
166
|
return { x: (first.left + first.width / 2 - bounds.left) / bounds.width, y: (first.top + first.height / 2 - bounds.top) / bounds.height }
|
|
156
167
|
})
|
|
157
168
|
return { x: box.x + offset.x * box.width, y: box.y + offset.y * box.height }
|
|
158
169
|
}
|
|
159
170
|
|
|
171
|
+
async function reveal(element, locator) {
|
|
172
|
+
// Content that loads above the target during the scroll can push it out again.
|
|
173
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
174
|
+
const target = await scrollIntoView(element, locator)
|
|
175
|
+
const point = await aimPoint(target)
|
|
176
|
+
const visibility = await target.element.evaluate((node, offset) => new Promise((resolve) => {
|
|
177
|
+
new IntersectionObserver(([entry], observer) => {
|
|
178
|
+
observer.disconnect()
|
|
179
|
+
const bounds = node.getBoundingClientRect()
|
|
180
|
+
const x = bounds.left + offset.x * bounds.width
|
|
181
|
+
const y = bounds.top + offset.y * bounds.height
|
|
182
|
+
const rect = entry.intersectionRect
|
|
183
|
+
const shown = entry.intersectionRatio > 0
|
|
184
|
+
resolve({ shown, visible: shown && x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom })
|
|
185
|
+
}).observe(node)
|
|
186
|
+
}), relativePoint(point, target.box))
|
|
187
|
+
if (visibility.visible || attempt === 1) return { point, ...visibility }
|
|
188
|
+
element = target.element
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Awaiting the scroll waits for a scrolling panel. Chromium resolves a page scroll before it stops, and a sticky target
|
|
193
|
+
// or iframe can stay put while its page scrolls, so also wait for every enclosing page to stop.
|
|
194
|
+
async function scrollIntoView(element, locator) {
|
|
195
|
+
await element.evaluate((target) => target.scrollIntoView({ behavior: 'smooth', block: 'center' }))
|
|
196
|
+
try {
|
|
197
|
+
await element.waitForElementState('stable')
|
|
198
|
+
let frame = await element.ownerFrame()
|
|
199
|
+
while (frame) {
|
|
200
|
+
await frame.evaluate(() => new Promise((resolve) => {
|
|
201
|
+
let last
|
|
202
|
+
const check = () => {
|
|
203
|
+
const now = `${scrollX},${scrollY}`
|
|
204
|
+
if (now === last) resolve()
|
|
205
|
+
else requestAnimationFrame(check)
|
|
206
|
+
last = now
|
|
207
|
+
}
|
|
208
|
+
requestAnimationFrame(check)
|
|
209
|
+
}))
|
|
210
|
+
if (!frame.parentFrame()) break
|
|
211
|
+
await (await frame.frameElement()).waitForElementState('stable')
|
|
212
|
+
frame = frame.parentFrame()
|
|
213
|
+
}
|
|
214
|
+
// 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) => {
|
|
216
|
+
throw error.name === 'TimeoutError' ? new TraceframeError(`${locator} disappeared when it was scrolled into view.`, { code: 'FLOW_TARGET_MISSING', cause: error }) : error
|
|
217
|
+
})
|
|
218
|
+
await target.element.waitForElementState('stable')
|
|
219
|
+
const box = await target.element.boundingBox()
|
|
220
|
+
if (!box) throw new TraceframeError(`Could not find ${locator}.`, { code: 'FLOW_TARGET_MISSING' })
|
|
221
|
+
return { element: target.element, box }
|
|
222
|
+
} catch (error) {
|
|
223
|
+
throw error.name === 'TimeoutError' ? new TraceframeError(`${locator} kept moving after it was scrolled into view.`, { code: 'FLOW_TARGET_MOVED', cause: error }) : error
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
160
227
|
async function assertReceivesPointer(element, locator, point) {
|
|
161
228
|
const box = await element.boundingBox()
|
|
162
229
|
if (!box || !pointInsideBox(point, box)) {
|
|
@@ -172,7 +239,7 @@ async function assertReceivesPointer(element, locator, point) {
|
|
|
172
239
|
return false
|
|
173
240
|
}, relativePoint(point, box))
|
|
174
241
|
if (!receivesPointer || !(await framesReceivePointer(element, point))) {
|
|
175
|
-
throw new TraceframeError(`${locator} is covered at the click point.`, { code: 'FLOW_TARGET_OBSCURED' })
|
|
242
|
+
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' })
|
|
176
243
|
}
|
|
177
244
|
}
|
|
178
245
|
|