@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 +17 -2
- package/docs/recording-flows.md +53 -1
- package/package.json +1 -1
- package/src/capture.mjs +49 -11
- package/src/cli.mjs +3 -2
- package/src/diagnostics.mjs +50 -0
- package/src/record.mjs +18 -3
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
|
|
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:
|
package/docs/recording-flows.md
CHANGED
|
@@ -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
|
|
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
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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)
|
|
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
|
}
|