browser-commander 0.18.0 → 0.19.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/.jscpd-baseline.json +8 -0
- package/CHANGELOG.md +34 -0
- package/README.md +50 -2
- package/package.json +2 -1
- package/src/downloads/manager.js +31 -8
- package/src/downloads/sources.js +56 -6
- package/src/downloads/staging.js +146 -0
- package/src/exports.js +14 -0
- package/src/traces/bundle.js +16 -1
- package/src/traces/deadline.js +33 -0
- package/src/traces/identity.js +127 -0
- package/src/traces/init-script.js +95 -0
- package/src/traces/links.js +623 -0
- package/src/traces/mutation-stream.js +232 -0
- package/src/traces/observers.js +29 -3
- package/src/traces/page-capture.js +251 -15
- package/src/traces/recorder.js +117 -100
- package/src/traces/schema.js +65 -1
- package/src/traces/viewer.js +157 -11
- package/tests/e2e/downloads.e2e.test.js +47 -0
- package/tests/e2e/traces.e2e.test.js +296 -11
- package/tests/helpers/download-server.js +26 -6
- package/tests/helpers/trace-fixtures.js +79 -3
- package/tests/helpers/trace-server.js +57 -1
- package/tests/unit/downloads/manager.test.js +131 -0
- package/tests/unit/downloads/staging.test.js +117 -0
- package/tests/unit/traces/links.test.js +557 -0
- package/tests/unit/traces/recorder.test.js +169 -1
- package/tests/unit/traces/viewer.test.js +5 -0
package/.jscpd-baseline.json
CHANGED
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"266b9cb95f783cdf": 1,
|
|
47
47
|
"26bb09996580437c": 1,
|
|
48
48
|
"26ebb2e251c9a39e": 1,
|
|
49
|
+
"29edfb9e08094991": 1,
|
|
49
50
|
"2a78ee4b05679c8d": 1,
|
|
50
51
|
"2b1a759e2cfd21a2": 1,
|
|
51
52
|
"2c0f8b5c6f28420b": 1,
|
|
@@ -55,6 +56,7 @@
|
|
|
55
56
|
"2f9e15467cc2f12a": 1,
|
|
56
57
|
"3101802d3ea65339": 1,
|
|
57
58
|
"33357ce94e07cc4b": 1,
|
|
59
|
+
"35cf2769645f3234": 1,
|
|
58
60
|
"37bd4114d0089f49": 1,
|
|
59
61
|
"389df6fb4b8315a1": 1,
|
|
60
62
|
"39eaada6d1879f4a": 1,
|
|
@@ -97,6 +99,7 @@
|
|
|
97
99
|
"608e1dd27138ec6f": 1,
|
|
98
100
|
"6098544c77ff17a2": 1,
|
|
99
101
|
"60d0c58a01fc8746": 1,
|
|
102
|
+
"61ae5da6cc003836": 1,
|
|
100
103
|
"61bcadcd8dc9f63e": 1,
|
|
101
104
|
"63ff613b1780c67a": 1,
|
|
102
105
|
"642cc89ed49f8a21": 1,
|
|
@@ -110,7 +113,9 @@
|
|
|
110
113
|
"6af3c307939ef75e": 1,
|
|
111
114
|
"6b35b48512756192": 1,
|
|
112
115
|
"6cd00d6b6a039022": 1,
|
|
116
|
+
"6da3fe1d15b66ae0": 1,
|
|
113
117
|
"6e2457cd3e1f69e7": 1,
|
|
118
|
+
"6f30e86d1407e88c": 1,
|
|
114
119
|
"6fa62e2642cc73c3": 1,
|
|
115
120
|
"7009c5e9bd61dbe8": 1,
|
|
116
121
|
"709348ea44394b52": 1,
|
|
@@ -168,10 +173,12 @@
|
|
|
168
173
|
"acc95d694eb2f581": 1,
|
|
169
174
|
"ad4469f2e8131535": 1,
|
|
170
175
|
"ad4cfa76daa1b6fc": 1,
|
|
176
|
+
"ad50b48938af8aef": 1,
|
|
171
177
|
"adae1329361a81a1": 1,
|
|
172
178
|
"ae477ba64bafba3f": 1,
|
|
173
179
|
"aee1e5c65f783848": 1,
|
|
174
180
|
"aee45e8b58c2d87f": 1,
|
|
181
|
+
"af702b20ebd45ef2": 1,
|
|
175
182
|
"af9d6a2f4eba146e": 1,
|
|
176
183
|
"b010686af23efa90": 1,
|
|
177
184
|
"b2a0686231220c8a": 1,
|
|
@@ -182,6 +189,7 @@
|
|
|
182
189
|
"b8c2d21f2a26f77d": 1,
|
|
183
190
|
"bb269582b94ed42d": 1,
|
|
184
191
|
"bb3e6d0f7e7edccd": 1,
|
|
192
|
+
"bc9115be3a462930": 1,
|
|
185
193
|
"bccf0d5cc57c08df": 1,
|
|
186
194
|
"bd0ae42c13d5b4f9": 1,
|
|
187
195
|
"bd6c62a6e1c6a071": 1,
|
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.19.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 4401d63: Record traces continuously across navigation and live state, and export them as
|
|
8
|
+
Links Notation.
|
|
9
|
+
|
|
10
|
+
`startTrace({mode: 'continuous'})` now keeps recording when the page navigates,
|
|
11
|
+
changes route or creates a frame: the observers are reinstalled from an init
|
|
12
|
+
script that runs before the page's own code, so nothing that happens during a
|
|
13
|
+
load is lost. What a person changes but the DOM does not show - typing,
|
|
14
|
+
checking, selecting, focus and scroll - is recorded as semantic live-state
|
|
15
|
+
records and applied on replay, child-list records carry the position a node was
|
|
16
|
+
inserted at or removed from, and every record carries stable trace, browser
|
|
17
|
+
context, page, navigation, frame and action identities. `initialCheckpoint` (on
|
|
18
|
+
by default) captures the page before the first action so the first interval has
|
|
19
|
+
a base to replay onto, and the viewer says what it is: a diagnostic replay of
|
|
20
|
+
what was recorded, not a re-execution of the session.
|
|
21
|
+
|
|
22
|
+
`writeTraceLinks(trace, './run.lino', {include})` writes a portable Links
|
|
23
|
+
Notation view of a bundle, and `startTrace({links: {output}})` writes the same
|
|
24
|
+
view incrementally as the run happens. One link per line: one per ordered
|
|
25
|
+
timeline event with its sequence, time, kind, owner ids, actor, action, target
|
|
26
|
+
and outcome; one per checkpoint naming its HTML, state and screenshot members by
|
|
27
|
+
their path inside the bundle; one per control that changed between two
|
|
28
|
+
checkpoints. Dropped and partial records are explicit, binary content is never
|
|
29
|
+
duplicated, and redaction is whatever the bundle already decided. The JSON
|
|
30
|
+
bundle stays authoritative - this is an adapter, not a replacement.
|
|
31
|
+
|
|
32
|
+
A managed download whose CDP completion arrives before the file does is no
|
|
33
|
+
longer reported as a missing file: the staged bytes are waited for, and a
|
|
34
|
+
timeout or a genuinely missing file is still a failure rather than a path that
|
|
35
|
+
does not exist.
|
|
36
|
+
|
|
3
37
|
## 0.18.0
|
|
4
38
|
|
|
5
39
|
### Minor Changes
|
package/README.md
CHANGED
|
@@ -657,8 +657,56 @@ await writeTraceViewer(bundle, '/tmp/traces/checkout/viewer.html');
|
|
|
657
657
|
```
|
|
658
658
|
|
|
659
659
|
Redaction runs before anything reaches disk, and a run cut short by a closed
|
|
660
|
-
page or a size limit still leaves a readable partial trace.
|
|
661
|
-
|
|
660
|
+
page or a size limit still leaves a readable partial trace. In `continuous`
|
|
661
|
+
mode the observers are reinstalled from an init script before the page's own
|
|
662
|
+
code runs, so a trace keeps recording across navigations, SPA route changes and
|
|
663
|
+
same-document updates, and it records what a person cannot see in the DOM -
|
|
664
|
+
typing, checking, selecting, focus and scroll - as semantic live-state records
|
|
665
|
+
(issue #93). `initialCheckpoint` (on by default) captures the page as it was
|
|
666
|
+
before the first action, so the first interval has a base to replay onto. The
|
|
667
|
+
viewer seeks, diffs and replays the captured DOM with scripts and network
|
|
668
|
+
disabled; it is a diagnostic replay of what was recorded, not a pixel-accurate
|
|
669
|
+
re-execution of the session.
|
|
670
|
+
|
|
671
|
+
#### Links Notation Export
|
|
672
|
+
|
|
673
|
+
The bundle stays authoritative, and `writeTraceLinks()` writes a second,
|
|
674
|
+
portable view of it for tools that keep semantic, actor-aware histories - audit
|
|
675
|
+
graphs, agent memory, cross-run diffing (issue #94):
|
|
676
|
+
|
|
677
|
+
```javascript
|
|
678
|
+
import { readTrace, writeTraceLinks } from 'browser-commander';
|
|
679
|
+
|
|
680
|
+
const trace = await readTrace('./run.bc-trace');
|
|
681
|
+
await writeTraceLinks(trace, './run.lino', {
|
|
682
|
+
include: ['timeline', 'checkpoints', 'control-diffs'],
|
|
683
|
+
});
|
|
684
|
+
```
|
|
685
|
+
|
|
686
|
+
A long-lived agent can have the same export written as the run happens, so a
|
|
687
|
+
process that is killed still leaves everything it had recorded:
|
|
688
|
+
|
|
689
|
+
```javascript
|
|
690
|
+
const trace = await commander.startTrace({
|
|
691
|
+
output: './run.bc-trace',
|
|
692
|
+
links: { output: './run.lino' },
|
|
693
|
+
});
|
|
694
|
+
// trace.links === './run.lino'
|
|
695
|
+
```
|
|
696
|
+
|
|
697
|
+
One link per line: one `(timeline: ...)` link per ordered event carrying its
|
|
698
|
+
sequence, time, kind, owner ids, actor, action, target and outcome; one
|
|
699
|
+
`(checkpoint: ...)` link naming its HTML, state and screenshot members by their
|
|
700
|
+
path inside the bundle; one `(control-diff: ...)` link per control that changed
|
|
701
|
+
between two checkpoints, with `path`, `before`, `after` and who changed it.
|
|
702
|
+
Dropped and partial records are written as such rather than left out, no binary
|
|
703
|
+
content is ever copied into the export, and redaction is whatever the bundle
|
|
704
|
+
already decided - the export only reads what was written there. The
|
|
705
|
+
representation is pinned by a golden test against
|
|
706
|
+
[`links-notation`](https://www.npmjs.com/package/links-notation) 0.20, whose
|
|
707
|
+
Python and Rust implementations read the same file;
|
|
708
|
+
`experiments/trace-links-export.mjs` records a real run and parses its export
|
|
709
|
+
from Python to prove it.
|
|
662
710
|
|
|
663
711
|
### Truthful Click Results
|
|
664
712
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "browser-commander",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.19.0",
|
|
4
4
|
"description": "Universal browser automation library that supports both Playwright and Puppeteer with a unified API",
|
|
5
5
|
"main": "src/index.js",
|
|
6
6
|
"type": "module",
|
|
@@ -68,6 +68,7 @@
|
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
70
|
"better-sqlite3": "^12.11.1",
|
|
71
|
+
"links-notation": "^0.20.0",
|
|
71
72
|
"log-lazy": "^1.0.4",
|
|
72
73
|
"test-anywhere": "^0.9.1"
|
|
73
74
|
},
|
package/src/downloads/manager.js
CHANGED
|
@@ -85,6 +85,9 @@ function playwrightDownloadContext({ browser, page }) {
|
|
|
85
85
|
* @param {string} [options.conflict='rename'] - 'rename', 'overwrite' or 'error'
|
|
86
86
|
* @param {Function} [options.filename] - Naming callback for every download
|
|
87
87
|
* @param {Function} [options.validate] - Validation for every download
|
|
88
|
+
* @param {number} [options.stagingTimeout] - How long a completed download has
|
|
89
|
+
* to become readable on disk before it is reported as failed (issue #92)
|
|
90
|
+
* @param {number} [options.stagingPollInterval] - Milliseconds between readings
|
|
88
91
|
* @param {Object} [options.log] - Logger
|
|
89
92
|
* @returns {Promise<Object>} Download manager
|
|
90
93
|
*/
|
|
@@ -99,6 +102,8 @@ export async function createDownloadManager(options = {}) {
|
|
|
99
102
|
conflict = DOWNLOAD_CONFLICT.RENAME,
|
|
100
103
|
filename,
|
|
101
104
|
validate,
|
|
105
|
+
stagingTimeout,
|
|
106
|
+
stagingPollInterval,
|
|
102
107
|
log,
|
|
103
108
|
} = options;
|
|
104
109
|
|
|
@@ -194,20 +199,30 @@ export async function createDownloadManager(options = {}) {
|
|
|
194
199
|
},
|
|
195
200
|
|
|
196
201
|
/**
|
|
197
|
-
*
|
|
202
|
+
* Register work a download is not finished without.
|
|
203
|
+
*
|
|
204
|
+
* Engine events are fire-and-forget, so anything a source starts in one -
|
|
205
|
+
* saving the bytes, or waiting for them to land on disk at all - has to be
|
|
206
|
+
* registered here: otherwise `dispose()` could return while a download is
|
|
207
|
+
* still in flight and leave a `.partial` file as its only trace.
|
|
198
208
|
*
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
-
|
|
209
|
+
* @param {Promise} work - Work to wait for in `idle()` and `dispose()`
|
|
210
|
+
* @returns {Promise<void>} The same work, tracked
|
|
211
|
+
*/
|
|
212
|
+
track(work) {
|
|
213
|
+
inFlight.add(work);
|
|
214
|
+
return work.finally(() => inFlight.delete(work));
|
|
215
|
+
},
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Save a download whose bytes the engine has finished writing.
|
|
202
219
|
*
|
|
203
220
|
* @param {Object} artifact - Artifact record
|
|
204
221
|
* @param {Object} source - `{path}` or `{stream}` to read the bytes from
|
|
205
222
|
* @returns {Promise<void>} Resolves once the artifact has been published
|
|
206
223
|
*/
|
|
207
224
|
finished(artifact, source) {
|
|
208
|
-
|
|
209
|
-
inFlight.add(work);
|
|
210
|
-
return work.finally(() => inFlight.delete(work));
|
|
225
|
+
return sink.track(save(artifact, source));
|
|
211
226
|
},
|
|
212
227
|
|
|
213
228
|
/**
|
|
@@ -293,7 +308,15 @@ export async function createDownloadManager(options = {}) {
|
|
|
293
308
|
try {
|
|
294
309
|
cdpSession = await openBrowserCdpSession({ engine, browser, page });
|
|
295
310
|
if (cdpSession) {
|
|
296
|
-
attached.push(
|
|
311
|
+
attached.push(
|
|
312
|
+
await attachCdpSource({
|
|
313
|
+
session: cdpSession,
|
|
314
|
+
root,
|
|
315
|
+
sink,
|
|
316
|
+
stagingTimeout,
|
|
317
|
+
stagingPollInterval,
|
|
318
|
+
})
|
|
319
|
+
);
|
|
297
320
|
}
|
|
298
321
|
} catch (error) {
|
|
299
322
|
cdpSession = undefined;
|
package/src/downloads/sources.js
CHANGED
|
@@ -11,6 +11,12 @@ import path from 'node:path';
|
|
|
11
11
|
import fs from 'node:fs/promises';
|
|
12
12
|
|
|
13
13
|
import { ARTIFACT_DIRECTORY_MODE } from './destination.js';
|
|
14
|
+
import {
|
|
15
|
+
DEFAULT_STAGING_POLL_INTERVAL,
|
|
16
|
+
DEFAULT_STAGING_TIMEOUT,
|
|
17
|
+
describeStagingTimeout,
|
|
18
|
+
waitForStagedFile,
|
|
19
|
+
} from './staging.js';
|
|
14
20
|
|
|
15
21
|
/** Why a download ended without a file. */
|
|
16
22
|
export const DOWNLOAD_FAILURE = Object.freeze({
|
|
@@ -85,10 +91,18 @@ export function attachPlaywrightSource({ context, sink }) {
|
|
|
85
91
|
* @param {Object} options - Source options
|
|
86
92
|
* @param {Object} options.session - CDP session with Browser domain access
|
|
87
93
|
* @param {string} options.root - Managed download directory
|
|
88
|
-
* @param {Object} options.sink - `{started, finished, failed}` callbacks
|
|
94
|
+
* @param {Object} options.sink - `{started, finished, failed, track}` callbacks
|
|
95
|
+
* @param {number} [options.stagingTimeout] - Budget for the bytes to land on disk
|
|
96
|
+
* @param {number} [options.stagingPollInterval] - Milliseconds between readings
|
|
89
97
|
* @returns {Promise<Object>} Handle with `detach()` and `stagingDirectory`
|
|
90
98
|
*/
|
|
91
|
-
export async function attachCdpSource({
|
|
99
|
+
export async function attachCdpSource({
|
|
100
|
+
session,
|
|
101
|
+
root,
|
|
102
|
+
sink,
|
|
103
|
+
stagingTimeout,
|
|
104
|
+
stagingPollInterval,
|
|
105
|
+
}) {
|
|
92
106
|
const stagingDirectory = path.join(root, STAGING_DIRECTORY);
|
|
93
107
|
await fs.mkdir(stagingDirectory, {
|
|
94
108
|
recursive: true,
|
|
@@ -117,6 +131,43 @@ export async function attachCdpSource({ session, root, sink }) {
|
|
|
117
131
|
);
|
|
118
132
|
};
|
|
119
133
|
|
|
134
|
+
/**
|
|
135
|
+
* Publish a completed download once its staged bytes are readable.
|
|
136
|
+
*
|
|
137
|
+
* `state: "completed"` is the browser's word, not the filesystem's. Chromium
|
|
138
|
+
* writes `<guid>.crdownload` and renames it, so opening `<guid>` the instant
|
|
139
|
+
* the event arrives could - and did, in issue #92 - fail with `ENOENT` on a
|
|
140
|
+
* download that had in fact succeeded. Waiting for the file turns that race
|
|
141
|
+
* into either a saved artifact or a named failure, never a lost one.
|
|
142
|
+
*
|
|
143
|
+
* @param {Object} record - Artifact record
|
|
144
|
+
* @param {string} stagedPath - Path Chromium staged the bytes under
|
|
145
|
+
* @returns {Promise<void>}
|
|
146
|
+
*/
|
|
147
|
+
const publishWhenStaged = async (record, stagedPath) => {
|
|
148
|
+
const budget = stagingTimeout ?? DEFAULT_STAGING_TIMEOUT;
|
|
149
|
+
const settled = await waitForStagedFile({
|
|
150
|
+
path: stagedPath,
|
|
151
|
+
timeout: budget,
|
|
152
|
+
interval: stagingPollInterval ?? DEFAULT_STAGING_POLL_INTERVAL,
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
if (!settled.ready) {
|
|
156
|
+
sink.failed(
|
|
157
|
+
record,
|
|
158
|
+
DOWNLOAD_FAILURE.FAILED,
|
|
159
|
+
describeStagingTimeout({
|
|
160
|
+
path: stagedPath,
|
|
161
|
+
timeout: budget,
|
|
162
|
+
reason: settled.reason,
|
|
163
|
+
})
|
|
164
|
+
);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
await sink.finished(record, { path: stagedPath, removeSource: true });
|
|
169
|
+
};
|
|
170
|
+
|
|
120
171
|
const onProgress = ({ guid, state }) => {
|
|
121
172
|
const record = byGuid.get(guid);
|
|
122
173
|
if (!record || state === 'inProgress') {
|
|
@@ -125,10 +176,9 @@ export async function attachCdpSource({ session, root, sink }) {
|
|
|
125
176
|
|
|
126
177
|
byGuid.delete(guid);
|
|
127
178
|
if (state === 'completed') {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
});
|
|
179
|
+
// Tracked before it is awaited: the wait is part of the download's
|
|
180
|
+
// lifecycle, so `idle()` and `dispose()` must not return while it runs.
|
|
181
|
+
sink.track(publishWhenStaged(record, path.join(stagingDirectory, guid)));
|
|
132
182
|
return;
|
|
133
183
|
}
|
|
134
184
|
sink.failed(
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Waiting for a staged download to actually be on disk (issue #92).
|
|
3
|
+
*
|
|
4
|
+
* `Browser.downloadProgress` with `state: "completed"` is the browser saying it
|
|
5
|
+
* has stopped downloading, not the filesystem saying the bytes are there under
|
|
6
|
+
* the name we are about to open. Chromium writes into `<guid>.crdownload` and
|
|
7
|
+
* renames it, and the event can reach us before that rename is visible - which
|
|
8
|
+
* is how a completed download became
|
|
9
|
+
* `ENOENT: no such file or directory, open '<root>/.browser-commander-staging/<guid>'`.
|
|
10
|
+
*
|
|
11
|
+
* This module turns "the browser said completed" into "the file is readable and
|
|
12
|
+
* has stopped growing", within a bounded budget. Running out of that budget is
|
|
13
|
+
* an explicit failure, never a silent success: issue #88's rule that "a
|
|
14
|
+
* download UI entry is not completion evidence" is exactly the rule being
|
|
15
|
+
* applied one level lower down.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs/promises';
|
|
19
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
20
|
+
|
|
21
|
+
import { createDeadline } from '../core/readiness.js';
|
|
22
|
+
|
|
23
|
+
/** How long to wait for a completed download's bytes to appear, by default. */
|
|
24
|
+
export const DEFAULT_STAGING_TIMEOUT = 10000;
|
|
25
|
+
|
|
26
|
+
/** How often the staged file is looked at while waiting. */
|
|
27
|
+
export const DEFAULT_STAGING_POLL_INTERVAL = 25;
|
|
28
|
+
|
|
29
|
+
/** Suffixes Chromium uses for a file it is still writing. */
|
|
30
|
+
export const STAGING_IN_PROGRESS_SUFFIXES = Object.freeze([
|
|
31
|
+
'.crdownload',
|
|
32
|
+
'.partial',
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Look at a staged file once.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} filePath - Path the engine staged the download under
|
|
39
|
+
* @param {string[]} suffixes - Suffixes that mark a file still being written
|
|
40
|
+
* @returns {Promise<{size: number|null, reason: string|null}>} What was seen
|
|
41
|
+
*/
|
|
42
|
+
async function observeStagedFile(filePath, suffixes) {
|
|
43
|
+
for (const suffix of suffixes) {
|
|
44
|
+
try {
|
|
45
|
+
await fs.stat(`${filePath}${suffix}`);
|
|
46
|
+
return { size: null, reason: `a ${suffix} file is still being written` };
|
|
47
|
+
} catch {
|
|
48
|
+
// Absent is what we want: the rename to the final staged name has
|
|
49
|
+
// already happened, or Chromium never used this suffix at all.
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
let handle;
|
|
54
|
+
try {
|
|
55
|
+
// Opening rather than stat'ing is deliberate: "readable" is the property
|
|
56
|
+
// `saveDownload()` needs, and a name that resolves is not the same thing
|
|
57
|
+
// as a file this process is allowed to read.
|
|
58
|
+
handle = await fs.open(filePath, 'r');
|
|
59
|
+
} catch (error) {
|
|
60
|
+
return {
|
|
61
|
+
size: null,
|
|
62
|
+
reason:
|
|
63
|
+
error.code === 'ENOENT'
|
|
64
|
+
? 'the file has not appeared yet'
|
|
65
|
+
: `the file could not be opened: ${error.message}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
try {
|
|
70
|
+
const stats = await handle.stat();
|
|
71
|
+
return { size: stats.size, reason: null };
|
|
72
|
+
} finally {
|
|
73
|
+
await handle.close();
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Wait until a staged download is readable and has stopped growing.
|
|
79
|
+
*
|
|
80
|
+
* Stability is decided by two consecutive readings of the same size, the same
|
|
81
|
+
* evidence the Selenium directory watcher uses: a file that merely exists is
|
|
82
|
+
* not evidence that its last byte was written.
|
|
83
|
+
*
|
|
84
|
+
* @param {Object} options - Wait options
|
|
85
|
+
* @param {string} options.path - Path the engine staged the download under
|
|
86
|
+
* @param {number} [options.timeout] - Total budget in milliseconds
|
|
87
|
+
* @param {number} [options.interval] - Milliseconds between readings
|
|
88
|
+
* @param {string[]} [options.inProgressSuffixes] - Suffixes of a partial file
|
|
89
|
+
* @returns {Promise<{ready: boolean, bytes: number|null, reason: string|null, waitedMs: number}>} Outcome
|
|
90
|
+
*/
|
|
91
|
+
export async function waitForStagedFile(options = {}) {
|
|
92
|
+
const {
|
|
93
|
+
path: filePath,
|
|
94
|
+
timeout = DEFAULT_STAGING_TIMEOUT,
|
|
95
|
+
interval = DEFAULT_STAGING_POLL_INTERVAL,
|
|
96
|
+
inProgressSuffixes = STAGING_IN_PROGRESS_SUFFIXES,
|
|
97
|
+
} = options;
|
|
98
|
+
|
|
99
|
+
const deadline = createDeadline({ timeout });
|
|
100
|
+
let previousSize;
|
|
101
|
+
// Always set by the reading below before the deadline can be checked.
|
|
102
|
+
let reason;
|
|
103
|
+
|
|
104
|
+
for (;;) {
|
|
105
|
+
const seen = await observeStagedFile(filePath, inProgressSuffixes);
|
|
106
|
+
if (seen.reason !== null) {
|
|
107
|
+
reason = seen.reason;
|
|
108
|
+
// A file that vanished has to prove itself stable again from scratch.
|
|
109
|
+
previousSize = undefined;
|
|
110
|
+
} else if (seen.size === previousSize) {
|
|
111
|
+
return {
|
|
112
|
+
ready: true,
|
|
113
|
+
bytes: seen.size,
|
|
114
|
+
reason: null,
|
|
115
|
+
waitedMs: deadline.elapsedMs(),
|
|
116
|
+
};
|
|
117
|
+
} else {
|
|
118
|
+
previousSize = seen.size;
|
|
119
|
+
reason = `the file was still growing at ${seen.size} bytes`;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const remaining = deadline.remainingMs();
|
|
123
|
+
if (remaining === 0) {
|
|
124
|
+
return {
|
|
125
|
+
ready: false,
|
|
126
|
+
bytes: null,
|
|
127
|
+
reason,
|
|
128
|
+
waitedMs: deadline.elapsedMs(),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
await delay(Math.min(interval, remaining));
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* The failure text a staged download that never materialized is reported under.
|
|
137
|
+
*
|
|
138
|
+
* @param {Object} options - Failure details
|
|
139
|
+
* @param {string} options.path - Path the engine staged the download under
|
|
140
|
+
* @param {number} options.timeout - Budget that was exhausted
|
|
141
|
+
* @param {string} options.reason - What the last reading saw
|
|
142
|
+
* @returns {string} Message kept verbatim on the artifact
|
|
143
|
+
*/
|
|
144
|
+
export function describeStagingTimeout({ path: filePath, timeout, reason }) {
|
|
145
|
+
return `the browser reported the download as completed, but ${filePath} was not readable within ${timeout}ms: ${reason}`;
|
|
146
|
+
}
|
package/src/exports.js
CHANGED
|
@@ -237,6 +237,20 @@ export {
|
|
|
237
237
|
} from './traces/schema.js';
|
|
238
238
|
export { diffControlState, parseNdjson, readTrace } from './traces/reader.js';
|
|
239
239
|
export { renderTraceViewer, writeTraceViewer } from './traces/viewer.js';
|
|
240
|
+
// A Links Notation view of the same bundle, for consumers that keep semantic,
|
|
241
|
+
// actor-aware histories of a run (issue #94).
|
|
242
|
+
export {
|
|
243
|
+
decodeLinkText,
|
|
244
|
+
encodeLinkText,
|
|
245
|
+
formatTraceLinks,
|
|
246
|
+
traceLinks,
|
|
247
|
+
TRACE_LINK_IDS,
|
|
248
|
+
TRACE_LINK_OUTCOME,
|
|
249
|
+
TRACE_LINKS_FILE,
|
|
250
|
+
TRACE_LINKS_SECTIONS,
|
|
251
|
+
TRACE_LINKS_VERSION,
|
|
252
|
+
writeTraceLinks,
|
|
253
|
+
} from './traces/links.js';
|
|
240
254
|
export {
|
|
241
255
|
DEFAULT_REDACT_ATTRIBUTES,
|
|
242
256
|
DEFAULT_REDACT_SELECTORS,
|
package/src/traces/bundle.js
CHANGED
|
@@ -32,7 +32,7 @@ export const TRACE_DIRECTORY_MODE = 0o700;
|
|
|
32
32
|
/**
|
|
33
33
|
* Open a bundle for writing.
|
|
34
34
|
*
|
|
35
|
-
* @param {Object} options - `{output, limits, strict, now}`
|
|
35
|
+
* @param {Object} options - `{output, limits, strict, now, onEvent}`
|
|
36
36
|
* @returns {Promise<Object>} The bundle writer
|
|
37
37
|
*/
|
|
38
38
|
export async function openTraceBundle(options = {}) {
|
|
@@ -41,6 +41,7 @@ export async function openTraceBundle(options = {}) {
|
|
|
41
41
|
strict = false,
|
|
42
42
|
limits = {},
|
|
43
43
|
now = () => Date.now(),
|
|
44
|
+
onEvent = null,
|
|
44
45
|
} = options;
|
|
45
46
|
|
|
46
47
|
if (typeof output !== 'string' || output === '') {
|
|
@@ -138,6 +139,20 @@ export async function openTraceBundle(options = {}) {
|
|
|
138
139
|
await pending;
|
|
139
140
|
written += bytes;
|
|
140
141
|
counts.events += 1;
|
|
142
|
+
if (onEvent) {
|
|
143
|
+
try {
|
|
144
|
+
await onEvent(record);
|
|
145
|
+
} catch (error) {
|
|
146
|
+
// A side export that fails is a gap in that export, not in the
|
|
147
|
+
// bundle. Reporting it through `drop` would write an event and call
|
|
148
|
+
// this hook again, so it is noted where a caller can still see it.
|
|
149
|
+
problems.push({
|
|
150
|
+
reason: TRACE_DROP_REASON.WRITE_FAILED,
|
|
151
|
+
member: 'links',
|
|
152
|
+
detail: error.message,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
}
|
|
141
156
|
return record;
|
|
142
157
|
} catch (error) {
|
|
143
158
|
if (retry) {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A budget for one capture (issue #87).
|
|
3
|
+
*
|
|
4
|
+
* A page that stopped answering must cost the trace one record, not the run.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run a capture with its own deadline.
|
|
9
|
+
*
|
|
10
|
+
* @param {Promise} work - The capture
|
|
11
|
+
* @param {number} timeoutMs - Budget in milliseconds; falsy means no deadline
|
|
12
|
+
* @param {string} what - Name used in the timeout message
|
|
13
|
+
* @returns {Promise<*>} The capture's result
|
|
14
|
+
*/
|
|
15
|
+
export async function withDeadline(work, timeoutMs, what) {
|
|
16
|
+
if (!timeoutMs) {
|
|
17
|
+
return work;
|
|
18
|
+
}
|
|
19
|
+
let timer;
|
|
20
|
+
try {
|
|
21
|
+
return await Promise.race([
|
|
22
|
+
work,
|
|
23
|
+
new Promise((_resolve, reject) => {
|
|
24
|
+
timer = setTimeout(
|
|
25
|
+
() => reject(new Error(`${what} timed out after ${timeoutMs}ms`)),
|
|
26
|
+
timeoutMs
|
|
27
|
+
);
|
|
28
|
+
}),
|
|
29
|
+
]);
|
|
30
|
+
} finally {
|
|
31
|
+
clearTimeout(timer);
|
|
32
|
+
}
|
|
33
|
+
}
|