@milkstraw/traceframe 0.2.0 → 0.3.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
@@ -57,7 +57,9 @@ Both presets use a 1280×800 CSS viewport. `draft` produces 1280×800 at 30 FPS;
57
57
 
58
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
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.
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. 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.
61
+
62
+ 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
63
 
62
64
  ## Write a flow
63
65
 
@@ -143,10 +145,23 @@ await record({
143
145
  })
144
146
  ```
145
147
 
146
- Traceframe's own errors are `TraceframeError` instances (exported by the package) with a `code`, such as `READY_FAILED` or `FLOW_TARGET_OBSCURED`.
148
+ 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
+
150
+ When available, `error.diagnostics` contains:
151
+
152
+ | Field | Meaning |
153
+ | --- | --- |
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. |
155
+ | `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
+ | `capturePath` | Absolute path to the retained raw capture, only when `runDirectory` was supplied and saving succeeded. |
157
+ | `screenshotPath` | Absolute path to the saved failure screenshot, only when `runDirectory` was supplied and the screenshot succeeded. |
158
+
159
+ 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.
147
160
 
148
161
  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.
149
162
 
163
+ 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
+
150
165
  ## Sample output
151
166
 
152
167
  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:
@@ -58,6 +58,42 @@ Before a follow-up, wait for the application to show that the current step is co
58
58
 
59
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
60
 
61
+ ## Add captions and reading time
62
+
63
+ 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.
64
+
65
+ 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:
66
+
67
+ ```js
68
+ const text = 'Open Actions to inspect the available options.'
69
+ const caption = await page.screencast.showOverlay(`
70
+ <div style="position: fixed; left: 24px; right: 24px; bottom: 32px;
71
+ display: flex; justify-content: center; pointer-events: none;">
72
+ <div dir="auto" style="max-width: 720px; padding: 12px 20px; border-radius: 10px;
73
+ background: #18181b; color: #fff;
74
+ box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16);
75
+ font: 500 20px/1.45 system-ui, sans-serif;
76
+ text-align: center; text-wrap: balance;">
77
+ ${text}
78
+ </div>
79
+ </div>
80
+ `)
81
+ try {
82
+ await wait(Math.max(2.5, Array.from(text).length / 12))
83
+ await click(page.getByRole('button', { name: 'Actions', exact: true }))
84
+ await page.getByRole('menu').waitFor()
85
+ await wait(3)
86
+ } finally {
87
+ await caption.dispose()
88
+ }
89
+ ```
90
+
91
+ 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.
92
+
93
+ 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.
94
+
95
+ 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).
96
+
61
97
  ## Optional startup settings
62
98
 
63
99
  `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,7 +119,7 @@ The two `Emulation` calls set the locale that `navigator.language`, `Intl`, and
83
119
 
84
120
  Use `--preset draft` while writing the flow and the default preset for the final video.
85
121
 
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.
122
+ 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
123
 
88
124
  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
125
 
@@ -91,6 +127,22 @@ A failed take may already have submitted real jobs, so reset demo data before re
91
127
  ffmpeg -v error -xerror -i ./recording.mp4 -f null -
92
128
  ```
93
129
 
130
+ ## Diagnose a failed take
131
+
132
+ The CLI keeps the original error message and adds available context on standard error. For example:
133
+
134
+ ```text
135
+ traceframe: [FLOW_FAILED] Expected the Saved status.
136
+ Flow: /project/review.mjs:18:9
137
+ Recording time: 12.35 s
138
+ Capture: /project/.traceframe/runs/demo/capture.webm
139
+ Screenshot: /project/.traceframe/runs/demo/failure.png
140
+ ```
141
+
142
+ 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
+
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#programmatic-usage).
145
+
94
146
  ## Edit the finished video
95
147
 
96
148
  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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@milkstraw/traceframe",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Record scripted browser flows and render smooth cursor overlays into video.",
5
5
  "keywords": [
6
6
  "playwright",
package/src/capture.mjs CHANGED
@@ -1,13 +1,16 @@
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
15
  export async function captureFlow({ config, flow, directory, signal: callerSignal }) {
13
16
  const signal = callerSignal ?? new AbortController().signal
@@ -17,6 +20,9 @@ export async function captureFlow({ config, flow, directory, signal: callerSigna
17
20
  signal.throwIfAborted()
18
21
  const browser = await launchBrowser(config, { handleSignals: !callerSignal })
19
22
  let page
23
+ let clock
24
+ let recording = false
25
+ let failed = false
20
26
  try {
21
27
  page = await browser.newPage({ viewport: config.viewport, deviceScaleFactor: config.scale, storageState: config.storageState })
22
28
  await raceAbort(waitForReady(page, flow, config, signal), signal)
@@ -24,28 +30,60 @@ export async function captureFlow({ config, flow, directory, signal: callerSigna
24
30
 
25
31
  const firstFrame = Promise.withResolvers()
26
32
  await startScreencast(page, rawPath, config, ({ timestamp }) => firstFrame.resolve(timestamp))
27
- const clock = await withDeadline(
33
+ recording = true
34
+ clock = await withDeadline(
28
35
  () => firstFrame.promise,
29
36
  FIRST_FRAME_TIMEOUT,
30
37
  new TraceframeError(`No screencast frame arrived within ${FIRST_FRAME_TIMEOUT / 1000} seconds.`, { code: 'FIRST_FRAME_TIMEOUT' }),
31
38
  signal,
32
39
  )
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
- )
40
+ try {
41
+ await withDeadline(
42
+ (flowSignal) => flow.run({ page, signal: flowSignal, ...createFlowApi({ page, trace, clock, viewport: config.viewport, signal: flowSignal }) }),
43
+ config.flowTimeout * 1000,
44
+ new TraceframeError(`Flow exceeded the ${config.flowTimeout}-second flow timeout. Raise it with the flowTimeout option, or --flow-timeout.`, { code: 'FLOW_TOO_LONG' }),
45
+ signal,
46
+ )
47
+ } catch (error) {
48
+ if (signal.aborted || error instanceof TraceframeError) throw error
49
+ throw new TraceframeError(error instanceof Error ? error.message : String(error), { code: 'FLOW_FAILED', cause: error })
50
+ }
39
51
  trace.end = (Date.now() - clock) / 1000
40
52
  await delay(config.postRoll * 1000, undefined, { signal })
53
+ // A failed stop must not be retried: Playwright may still be waiting to finish its artifact.
54
+ recording = false
41
55
  await page.screencast.stop()
42
56
  return { rawPath, trace }
43
57
  } 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
58
+ failed = true
59
+ const recordingTime = clock === undefined ? undefined : Math.max(0, (Date.now() - clock) / 1000)
60
+ // Let the encoder finish saving before closing the browser, including on slower machines.
61
+ const [capturePath, screenshotPath] = await Promise.all([
62
+ recording ? page.screencast.stop()
63
+ .then(() => config.runDirectory ? retainedFile(rawPath) : undefined)
64
+ .catch(() => undefined) : undefined,
65
+ config.runDirectory && page && !signal.aborted ? saveFailureScreenshot(page, directory, signal) : undefined,
66
+ ])
67
+ if (signal.aborted) throw error
68
+ throw await addDiagnostics(error, { flow: config.flow, recordingTime, capturePath, screenshotPath })
47
69
  } finally {
48
- await browser.close()
70
+ // A shutdown error must not hide the flow failure.
71
+ if (failed) await browser.close().catch(() => {})
72
+ else await browser.close()
73
+ }
74
+ }
75
+
76
+ async function saveFailureScreenshot(page, directory, signal) {
77
+ try {
78
+ return await withDeadline(async (diagnosticSignal) => {
79
+ const screenshot = await page.screenshot({ timeout: FAILURE_SCREENSHOT_TIMEOUT })
80
+ diagnosticSignal.throwIfAborted()
81
+ const path = join(directory, 'failure.png')
82
+ await writeFile(path, screenshot, { signal: diagnosticSignal })
83
+ return path
84
+ }, FAILURE_SCREENSHOT_TIMEOUT, new Error('Failure screenshot timed out.'), signal)
85
+ } catch {
86
+ return undefined
49
87
  }
50
88
  }
51
89
 
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
  }
@@ -0,0 +1,50 @@
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 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
+ const flowLocations = []
15
+ const seenErrors = new Set()
16
+ const seenLocations = new Set()
17
+ for (let current = error; current instanceof Error && !seenErrors.has(current); current = current.cause) {
18
+ seenErrors.add(current)
19
+ for (const frame of String(current.stack ?? '').split('\n')) {
20
+ const match = frame.match(location)
21
+ if (!match || seenLocations.has(`${match[2]}:${match[3]}`)) continue
22
+ seenLocations.add(`${match[2]}:${match[3]}`)
23
+ flowLocations.push({ file: flow, line: Number(match[2]), column: Number(match[3]) })
24
+ }
25
+ }
26
+ error.diagnostics = { flowLocations, ...Object.fromEntries(Object.entries(details).filter(([, value]) => value !== undefined)) }
27
+ } catch {
28
+ // A frozen error or an unavailable stack must not replace the original failure.
29
+ }
30
+ return error
31
+ }
32
+
33
+ export async function retainedFile(path) {
34
+ const info = await stat(path).catch(() => undefined)
35
+ return info?.isFile() && info.size > 0 ? path : undefined
36
+ }
37
+
38
+ export function formatFailure(error) {
39
+ const message = error instanceof Error ? error.message : String(error)
40
+ const code = error instanceof TraceframeError && error.code ? `[${error.code}] ` : ''
41
+ const lines = [`traceframe: ${code}${message.trimEnd()}`]
42
+ const diagnostics = error?.diagnostics
43
+ for (const { file, line, column } of diagnostics?.flowLocations ?? []) {
44
+ lines.push(` Flow: ${file}:${line}:${column}`)
45
+ }
46
+ if (Number.isFinite(diagnostics?.recordingTime)) lines.push(` Recording time: ${diagnostics.recordingTime.toFixed(2)} s`)
47
+ if (diagnostics?.capturePath) lines.push(` Capture: ${diagnostics.capturePath}`)
48
+ if (diagnostics?.screenshotPath) lines.push(` Screenshot: ${diagnostics.screenshotPath}`)
49
+ return `${lines.join('\n')}\n`
50
+ }
package/src/record.mjs CHANGED
@@ -4,6 +4,7 @@ import { dirname, join } from 'node:path'
4
4
 
5
5
  import { createConfig } 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
9
  import { loadFlow } from './flow.mjs'
9
10
  import { renderVideo } from './render.mjs'
@@ -13,13 +14,18 @@ export async function record(options = {}) {
13
14
  signal?.throwIfAborted()
14
15
  const config = createConfig(options)
15
16
  const ffmpeg = await probeFfmpeg()
16
- const flow = await loadFlow(config.flow, config.viewport)
17
+ const flow = await loadFlow(config.flow, config.viewport).catch(async (error) => {
18
+ throw await addDiagnostics(error, { flow: config.flow })
19
+ })
17
20
  await mkdir(dirname(config.output), { recursive: true })
18
21
  const directory = config.runDirectory ?? await mkdtemp(join(tmpdir(), 'traceframe-'))
19
22
  await mkdir(directory, { recursive: true })
20
23
 
24
+ let capture
25
+ let failed = false
21
26
  try {
22
- const { rawPath, trace } = await captureFlow({ config, flow, directory, signal })
27
+ capture = await captureFlow({ config, flow, directory, signal })
28
+ const { rawPath, trace } = capture
23
29
  const duration = await renderVideo({ rawPath, trace, directory, config, ffmpeg, signal })
24
30
  return {
25
31
  output: config.output,
@@ -32,8 +38,17 @@ export async function record(options = {}) {
32
38
  runDirectory: config.runDirectory,
33
39
  }
34
40
  } catch (error) {
41
+ failed = true
42
+ if (signal?.aborted) throw signal.reason
43
+ if (capture && config.runDirectory) {
44
+ await addDiagnostics(error, { flow: config.flow, capturePath: await retainedFile(capture.rawPath) })
45
+ }
35
46
  throw signal?.aborted ? signal.reason : error
36
47
  } finally {
37
- if (!config.runDirectory) await rm(directory, { recursive: true, force: true })
48
+ if (!config.runDirectory) {
49
+ const cleanup = rm(directory, { recursive: true, force: true })
50
+ if (failed) await cleanup.catch(() => {})
51
+ else await cleanup
52
+ }
38
53
  }
39
54
  }