browser-commander 0.18.0 → 0.20.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 +45 -0
- package/README.md +50 -2
- package/package.json +8 -3
- package/scripts/run-tests.mjs +4 -2
- package/src/downloads/manager.js +40 -8
- package/src/downloads/sources.js +127 -6
- package/src/downloads/staging.js +158 -0
- package/src/exports.js +14 -0
- package/src/traces/bundle.js +26 -3
- 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 +124 -94
- package/src/traces/schema.js +65 -1
- package/src/traces/viewer.js +157 -11
- package/tests/e2e/downloads.e2e.test.js +126 -2
- package/tests/e2e/traces.e2e.test.js +296 -11
- package/tests/helpers/download-server.js +26 -6
- package/tests/helpers/trace-fixtures.js +97 -3
- package/tests/helpers/trace-server.js +57 -1
- package/tests/unit/downloads/manager.test.js +213 -9
- package/tests/unit/downloads/staging.test.js +139 -0
- package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
- package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
- package/tests/unit/scripts/pre-commit-config.test.js +2 -1
- package/tests/unit/traces/bundle.test.js +24 -3
- package/tests/unit/traces/links.test.js +564 -0
- package/tests/unit/traces/reader.test.js +10 -4
- package/tests/unit/traces/recorder.test.js +172 -1
- package/tests/unit/traces/viewer.test.js +9 -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,50 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.20.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- Persist managed downloads from Playwright pages in non-default Chromium browser
|
|
8
|
+
contexts. Browser Commander now resolves the page target's `browserContextId`
|
|
9
|
+
and applies its staging directory to that context while retaining browser-wide
|
|
10
|
+
download lifecycle events.
|
|
11
|
+
|
|
12
|
+
Wait for a real quiet period before accepting staged download bytes, close unfinished trace bundles explicitly in tests, and move the supported Node.js floor to 22 with the maintained `better-sqlite3` fallback.
|
|
13
|
+
|
|
14
|
+
## 0.19.0
|
|
15
|
+
|
|
16
|
+
### Minor Changes
|
|
17
|
+
|
|
18
|
+
- 4401d63: Record traces continuously across navigation and live state, and export them as
|
|
19
|
+
Links Notation.
|
|
20
|
+
|
|
21
|
+
`startTrace({mode: 'continuous'})` now keeps recording when the page navigates,
|
|
22
|
+
changes route or creates a frame: the observers are reinstalled from an init
|
|
23
|
+
script that runs before the page's own code, so nothing that happens during a
|
|
24
|
+
load is lost. What a person changes but the DOM does not show - typing,
|
|
25
|
+
checking, selecting, focus and scroll - is recorded as semantic live-state
|
|
26
|
+
records and applied on replay, child-list records carry the position a node was
|
|
27
|
+
inserted at or removed from, and every record carries stable trace, browser
|
|
28
|
+
context, page, navigation, frame and action identities. `initialCheckpoint` (on
|
|
29
|
+
by default) captures the page before the first action so the first interval has
|
|
30
|
+
a base to replay onto, and the viewer says what it is: a diagnostic replay of
|
|
31
|
+
what was recorded, not a re-execution of the session.
|
|
32
|
+
|
|
33
|
+
`writeTraceLinks(trace, './run.lino', {include})` writes a portable Links
|
|
34
|
+
Notation view of a bundle, and `startTrace({links: {output}})` writes the same
|
|
35
|
+
view incrementally as the run happens. One link per line: one per ordered
|
|
36
|
+
timeline event with its sequence, time, kind, owner ids, actor, action, target
|
|
37
|
+
and outcome; one per checkpoint naming its HTML, state and screenshot members by
|
|
38
|
+
their path inside the bundle; one per control that changed between two
|
|
39
|
+
checkpoints. Dropped and partial records are explicit, binary content is never
|
|
40
|
+
duplicated, and redaction is whatever the bundle already decided. The JSON
|
|
41
|
+
bundle stays authoritative - this is an adapter, not a replacement.
|
|
42
|
+
|
|
43
|
+
A managed download whose CDP completion arrives before the file does is no
|
|
44
|
+
longer reported as a missing file: the staged bytes are waited for, and a
|
|
45
|
+
timeout or a genuinely missing file is still a failure rather than a path that
|
|
46
|
+
does not exist.
|
|
47
|
+
|
|
3
48
|
## 0.18.0
|
|
4
49
|
|
|
5
50
|
### 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.20.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",
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
},
|
|
53
53
|
"homepage": "https://github.com/link-foundation/browser-commander#readme",
|
|
54
54
|
"engines": {
|
|
55
|
-
"node": ">=
|
|
55
|
+
"node": ">=22.0.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependencies": {
|
|
58
58
|
"playwright": ">=1.40.0",
|
|
@@ -67,7 +67,8 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"better-sqlite3": "^
|
|
70
|
+
"better-sqlite3": "^13.0.3",
|
|
71
|
+
"links-notation": "^0.20.0",
|
|
71
72
|
"log-lazy": "^1.0.4",
|
|
72
73
|
"test-anywhere": "^0.9.1"
|
|
73
74
|
},
|
|
@@ -82,5 +83,9 @@
|
|
|
82
83
|
"playwright": "^1.62.1",
|
|
83
84
|
"prettier": "^3.9.6",
|
|
84
85
|
"puppeteer": "^25.10.0"
|
|
86
|
+
},
|
|
87
|
+
"allowScripts": {
|
|
88
|
+
"better-sqlite3@13.0.3": true,
|
|
89
|
+
"puppeteer": false
|
|
85
90
|
}
|
|
86
91
|
}
|
package/scripts/run-tests.mjs
CHANGED
|
@@ -147,13 +147,15 @@ export function buildNodeTestArgs(
|
|
|
147
147
|
testFiles,
|
|
148
148
|
nodeVersion = process.versions.node
|
|
149
149
|
) {
|
|
150
|
-
|
|
150
|
+
// A resource leak that is a warning today becomes a runtime failure in a
|
|
151
|
+
// later Node release. Make deprecations fail at the point that creates them.
|
|
152
|
+
const args = ['--test', '--throw-deprecation'];
|
|
151
153
|
|
|
152
154
|
// Node's default process isolation serializes each test file's results over
|
|
153
155
|
// a child-process channel. Node 24 on macOS can intermittently corrupt that
|
|
154
156
|
// stream (ERR_TEST_FAILURE: "Unable to deserialize cloned data"). Running
|
|
155
157
|
// supported runtimes in-process removes that transport while keeping the
|
|
156
|
-
// Node
|
|
158
|
+
// Node 22-compatible fallback for consumers on the minimum engine version.
|
|
157
159
|
if (supportsTestIsolation(nodeVersion)) {
|
|
158
160
|
args.push('--test-isolation=none');
|
|
159
161
|
}
|
package/src/downloads/manager.js
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
import {
|
|
23
23
|
attachCdpSource,
|
|
24
24
|
attachPlaywrightSource,
|
|
25
|
+
browserContextIdForPage,
|
|
25
26
|
classifyFailure,
|
|
26
27
|
DOWNLOAD_FAILURE,
|
|
27
28
|
openBrowserCdpSession,
|
|
@@ -85,6 +86,9 @@ function playwrightDownloadContext({ browser, page }) {
|
|
|
85
86
|
* @param {string} [options.conflict='rename'] - 'rename', 'overwrite' or 'error'
|
|
86
87
|
* @param {Function} [options.filename] - Naming callback for every download
|
|
87
88
|
* @param {Function} [options.validate] - Validation for every download
|
|
89
|
+
* @param {number} [options.stagingTimeout] - How long a completed download has
|
|
90
|
+
* to become readable on disk before it is reported as failed (issue #92)
|
|
91
|
+
* @param {number} [options.stagingPollInterval] - Milliseconds between readings
|
|
88
92
|
* @param {Object} [options.log] - Logger
|
|
89
93
|
* @returns {Promise<Object>} Download manager
|
|
90
94
|
*/
|
|
@@ -99,6 +103,8 @@ export async function createDownloadManager(options = {}) {
|
|
|
99
103
|
conflict = DOWNLOAD_CONFLICT.RENAME,
|
|
100
104
|
filename,
|
|
101
105
|
validate,
|
|
106
|
+
stagingTimeout,
|
|
107
|
+
stagingPollInterval,
|
|
102
108
|
log,
|
|
103
109
|
} = options;
|
|
104
110
|
|
|
@@ -194,20 +200,30 @@ export async function createDownloadManager(options = {}) {
|
|
|
194
200
|
},
|
|
195
201
|
|
|
196
202
|
/**
|
|
197
|
-
*
|
|
203
|
+
* Register work a download is not finished without.
|
|
198
204
|
*
|
|
199
|
-
* Engine events are fire-and-forget, so
|
|
200
|
-
*
|
|
201
|
-
*
|
|
205
|
+
* Engine events are fire-and-forget, so anything a source starts in one -
|
|
206
|
+
* saving the bytes, or waiting for them to land on disk at all - has to be
|
|
207
|
+
* registered here: otherwise `dispose()` could return while a download is
|
|
208
|
+
* still in flight and leave a `.partial` file as its only trace.
|
|
209
|
+
*
|
|
210
|
+
* @param {Promise} work - Work to wait for in `idle()` and `dispose()`
|
|
211
|
+
* @returns {Promise<void>} The same work, tracked
|
|
212
|
+
*/
|
|
213
|
+
track(work) {
|
|
214
|
+
inFlight.add(work);
|
|
215
|
+
return work.finally(() => inFlight.delete(work));
|
|
216
|
+
},
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Save a download whose bytes the engine has finished writing.
|
|
202
220
|
*
|
|
203
221
|
* @param {Object} artifact - Artifact record
|
|
204
222
|
* @param {Object} source - `{path}` or `{stream}` to read the bytes from
|
|
205
223
|
* @returns {Promise<void>} Resolves once the artifact has been published
|
|
206
224
|
*/
|
|
207
225
|
finished(artifact, source) {
|
|
208
|
-
|
|
209
|
-
inFlight.add(work);
|
|
210
|
-
return work.finally(() => inFlight.delete(work));
|
|
226
|
+
return sink.track(save(artifact, source));
|
|
211
227
|
},
|
|
212
228
|
|
|
213
229
|
/**
|
|
@@ -293,9 +309,25 @@ export async function createDownloadManager(options = {}) {
|
|
|
293
309
|
try {
|
|
294
310
|
cdpSession = await openBrowserCdpSession({ engine, browser, page });
|
|
295
311
|
if (cdpSession) {
|
|
296
|
-
|
|
312
|
+
const browserContextId = await browserContextIdForPage({
|
|
313
|
+
engine,
|
|
314
|
+
browser,
|
|
315
|
+
page,
|
|
316
|
+
browserSession: cdpSession,
|
|
317
|
+
});
|
|
318
|
+
attached.push(
|
|
319
|
+
await attachCdpSource({
|
|
320
|
+
session: cdpSession,
|
|
321
|
+
root,
|
|
322
|
+
sink,
|
|
323
|
+
browserContextId,
|
|
324
|
+
stagingTimeout,
|
|
325
|
+
stagingPollInterval,
|
|
326
|
+
})
|
|
327
|
+
);
|
|
297
328
|
}
|
|
298
329
|
} catch (error) {
|
|
330
|
+
await cdpSession?.detach?.().catch?.(() => {});
|
|
299
331
|
cdpSession = undefined;
|
|
300
332
|
log?.warn?.(`manual downloads are not observable: ${error.message}`);
|
|
301
333
|
}
|
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,20 @@ 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 {string} [options.browserContextId] - Chromium context to configure
|
|
96
|
+
* @param {number} [options.stagingTimeout] - Budget for the bytes to land on disk
|
|
97
|
+
* @param {number} [options.stagingPollInterval] - Milliseconds between readings
|
|
89
98
|
* @returns {Promise<Object>} Handle with `detach()` and `stagingDirectory`
|
|
90
99
|
*/
|
|
91
|
-
export async function attachCdpSource({
|
|
100
|
+
export async function attachCdpSource({
|
|
101
|
+
session,
|
|
102
|
+
root,
|
|
103
|
+
sink,
|
|
104
|
+
browserContextId,
|
|
105
|
+
stagingTimeout,
|
|
106
|
+
stagingPollInterval,
|
|
107
|
+
}) {
|
|
92
108
|
const stagingDirectory = path.join(root, STAGING_DIRECTORY);
|
|
93
109
|
await fs.mkdir(stagingDirectory, {
|
|
94
110
|
recursive: true,
|
|
@@ -102,6 +118,7 @@ export async function attachCdpSource({ session, root, sink }) {
|
|
|
102
118
|
behavior: 'allowAndName',
|
|
103
119
|
downloadPath: stagingDirectory,
|
|
104
120
|
eventsEnabled: true,
|
|
121
|
+
...(browserContextId ? { browserContextId } : {}),
|
|
105
122
|
});
|
|
106
123
|
|
|
107
124
|
const byGuid = new Map();
|
|
@@ -117,6 +134,43 @@ export async function attachCdpSource({ session, root, sink }) {
|
|
|
117
134
|
);
|
|
118
135
|
};
|
|
119
136
|
|
|
137
|
+
/**
|
|
138
|
+
* Publish a completed download once its staged bytes are readable.
|
|
139
|
+
*
|
|
140
|
+
* `state: "completed"` is the browser's word, not the filesystem's. Chromium
|
|
141
|
+
* writes `<guid>.crdownload` and renames it, so opening `<guid>` the instant
|
|
142
|
+
* the event arrives could - and did, in issue #92 - fail with `ENOENT` on a
|
|
143
|
+
* download that had in fact succeeded. Waiting for the file turns that race
|
|
144
|
+
* into either a saved artifact or a named failure, never a lost one.
|
|
145
|
+
*
|
|
146
|
+
* @param {Object} record - Artifact record
|
|
147
|
+
* @param {string} stagedPath - Path Chromium staged the bytes under
|
|
148
|
+
* @returns {Promise<void>}
|
|
149
|
+
*/
|
|
150
|
+
const publishWhenStaged = async (record, stagedPath) => {
|
|
151
|
+
const budget = stagingTimeout ?? DEFAULT_STAGING_TIMEOUT;
|
|
152
|
+
const settled = await waitForStagedFile({
|
|
153
|
+
path: stagedPath,
|
|
154
|
+
timeout: budget,
|
|
155
|
+
interval: stagingPollInterval ?? DEFAULT_STAGING_POLL_INTERVAL,
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
if (!settled.ready) {
|
|
159
|
+
sink.failed(
|
|
160
|
+
record,
|
|
161
|
+
DOWNLOAD_FAILURE.FAILED,
|
|
162
|
+
describeStagingTimeout({
|
|
163
|
+
path: stagedPath,
|
|
164
|
+
timeout: budget,
|
|
165
|
+
reason: settled.reason,
|
|
166
|
+
})
|
|
167
|
+
);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
await sink.finished(record, { path: stagedPath, removeSource: true });
|
|
172
|
+
};
|
|
173
|
+
|
|
120
174
|
const onProgress = ({ guid, state }) => {
|
|
121
175
|
const record = byGuid.get(guid);
|
|
122
176
|
if (!record || state === 'inProgress') {
|
|
@@ -125,10 +179,9 @@ export async function attachCdpSource({ session, root, sink }) {
|
|
|
125
179
|
|
|
126
180
|
byGuid.delete(guid);
|
|
127
181
|
if (state === 'completed') {
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
});
|
|
182
|
+
// Tracked before it is awaited: the wait is part of the download's
|
|
183
|
+
// lifecycle, so `idle()` and `dispose()` must not return while it runs.
|
|
184
|
+
sink.track(publishWhenStaged(record, path.join(stagingDirectory, guid)));
|
|
132
185
|
return;
|
|
133
186
|
}
|
|
134
187
|
sink.failed(
|
|
@@ -150,6 +203,74 @@ export async function attachCdpSource({ session, root, sink }) {
|
|
|
150
203
|
};
|
|
151
204
|
}
|
|
152
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Read the Chromium browser-context id that owns a page.
|
|
208
|
+
*
|
|
209
|
+
* `Browser.setDownloadBehavior` is sent over a browser-wide session so its
|
|
210
|
+
* events cover the whole browser, but omitting `browserContextId` configures
|
|
211
|
+
* only Chromium's default context. Playwright's `browser.newPage()` convenience
|
|
212
|
+
* API creates a non-default context, whose id is exposed by its target.
|
|
213
|
+
*
|
|
214
|
+
* @param {Object} options - Browser handles
|
|
215
|
+
* @param {string} options.engine - Engine name
|
|
216
|
+
* @param {Object} [options.browser] - Browser or persistent context
|
|
217
|
+
* @param {Object} [options.page] - Page whose context downloads belong to
|
|
218
|
+
* @param {Object} [options.browserSession] - Browser-wide CDP session
|
|
219
|
+
* @returns {Promise<string|undefined>} Chromium context id, when non-default
|
|
220
|
+
*/
|
|
221
|
+
export async function browserContextIdForPage({
|
|
222
|
+
engine,
|
|
223
|
+
browser,
|
|
224
|
+
page,
|
|
225
|
+
browserSession,
|
|
226
|
+
}) {
|
|
227
|
+
if (engine !== 'playwright' || !page) {
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const context = page.context?.() ?? browser;
|
|
232
|
+
// Playwright exposes a persistent context as the browser's default context:
|
|
233
|
+
// unlike browser.newContext(), its `browser()` is null. Keeping the id
|
|
234
|
+
// absent preserves default-context behavior, including downloads from tabs
|
|
235
|
+
// opened outside the automation.
|
|
236
|
+
if (typeof context?.browser === 'function' && !context.browser()) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
const pageSession = await context?.newCDPSession?.(page);
|
|
240
|
+
if (!pageSession) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
let browserContextId;
|
|
245
|
+
try {
|
|
246
|
+
const { targetInfo } = await pageSession.send('Target.getTargetInfo');
|
|
247
|
+
browserContextId = targetInfo?.browserContextId || undefined;
|
|
248
|
+
} finally {
|
|
249
|
+
// This session exists only for target metadata. A failure to detach it
|
|
250
|
+
// must not discard the id that lets the long-lived source work correctly.
|
|
251
|
+
await pageSession.detach?.().catch?.(() => {});
|
|
252
|
+
}
|
|
253
|
+
if (!browserContextId || !browserSession) {
|
|
254
|
+
return browserContextId;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
// TargetInfo also reports an opaque id for a persistent/default context in
|
|
259
|
+
// current Chromium. Only ids returned here are true non-default contexts
|
|
260
|
+
// accepted by Browser.setDownloadBehavior's browserContextId parameter.
|
|
261
|
+
const { browserContextIds = [] } = await browserSession.send(
|
|
262
|
+
'Target.getBrowserContexts'
|
|
263
|
+
);
|
|
264
|
+
return browserContextIds.includes(browserContextId)
|
|
265
|
+
? browserContextId
|
|
266
|
+
: undefined;
|
|
267
|
+
} catch {
|
|
268
|
+
// Older/page-scoped CDP bridges may not expose getBrowserContexts. The
|
|
269
|
+
// persistent-context guard above handles their known default-context case.
|
|
270
|
+
return browserContextId;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
153
274
|
/**
|
|
154
275
|
* Open a CDP session that can drive the Browser domain.
|
|
155
276
|
*
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
/** How long an unchanged size must remain observable before it is complete. */
|
|
30
|
+
export const DEFAULT_STAGING_STABILITY = 100;
|
|
31
|
+
|
|
32
|
+
/** Suffixes Chromium uses for a file it is still writing. */
|
|
33
|
+
export const STAGING_IN_PROGRESS_SUFFIXES = Object.freeze([
|
|
34
|
+
'.crdownload',
|
|
35
|
+
'.partial',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Look at a staged file once.
|
|
40
|
+
*
|
|
41
|
+
* @param {string} filePath - Path the engine staged the download under
|
|
42
|
+
* @param {string[]} suffixes - Suffixes that mark a file still being written
|
|
43
|
+
* @returns {Promise<{size: number|null, reason: string|null}>} What was seen
|
|
44
|
+
*/
|
|
45
|
+
async function observeStagedFile(filePath, suffixes) {
|
|
46
|
+
for (const suffix of suffixes) {
|
|
47
|
+
try {
|
|
48
|
+
await fs.stat(`${filePath}${suffix}`);
|
|
49
|
+
return { size: null, reason: `a ${suffix} file is still being written` };
|
|
50
|
+
} catch {
|
|
51
|
+
// Absent is what we want: the rename to the final staged name has
|
|
52
|
+
// already happened, or Chromium never used this suffix at all.
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
let handle;
|
|
57
|
+
try {
|
|
58
|
+
// Opening rather than stat'ing is deliberate: "readable" is the property
|
|
59
|
+
// `saveDownload()` needs, and a name that resolves is not the same thing
|
|
60
|
+
// as a file this process is allowed to read.
|
|
61
|
+
handle = await fs.open(filePath, 'r');
|
|
62
|
+
} catch (error) {
|
|
63
|
+
return {
|
|
64
|
+
size: null,
|
|
65
|
+
reason:
|
|
66
|
+
error.code === 'ENOENT'
|
|
67
|
+
? 'the file has not appeared yet'
|
|
68
|
+
: `the file could not be opened: ${error.message}`,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
try {
|
|
73
|
+
const stats = await handle.stat();
|
|
74
|
+
return { size: stats.size, reason: null };
|
|
75
|
+
} finally {
|
|
76
|
+
await handle.close();
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Wait until a staged download is readable and has stopped growing.
|
|
82
|
+
*
|
|
83
|
+
* Stability is decided by an unchanged-size quiet period. Two equal readings
|
|
84
|
+
* only prove that no write landed between those particular polls; a stalled
|
|
85
|
+
* writer can pause for longer than one interval and resume afterwards.
|
|
86
|
+
*
|
|
87
|
+
* @param {Object} options - Wait options
|
|
88
|
+
* @param {string} options.path - Path the engine staged the download under
|
|
89
|
+
* @param {number} [options.timeout] - Total budget in milliseconds
|
|
90
|
+
* @param {number} [options.interval] - Milliseconds between readings
|
|
91
|
+
* @param {number} [options.stability] - Required unchanged-size quiet period
|
|
92
|
+
* @param {string[]} [options.inProgressSuffixes] - Suffixes of a partial file
|
|
93
|
+
* @returns {Promise<{ready: boolean, bytes: number|null, reason: string|null, waitedMs: number}>} Outcome
|
|
94
|
+
*/
|
|
95
|
+
export async function waitForStagedFile(options = {}) {
|
|
96
|
+
const {
|
|
97
|
+
path: filePath,
|
|
98
|
+
timeout = DEFAULT_STAGING_TIMEOUT,
|
|
99
|
+
interval = DEFAULT_STAGING_POLL_INTERVAL,
|
|
100
|
+
stability = DEFAULT_STAGING_STABILITY,
|
|
101
|
+
inProgressSuffixes = STAGING_IN_PROGRESS_SUFFIXES,
|
|
102
|
+
} = options;
|
|
103
|
+
|
|
104
|
+
const deadline = createDeadline({ timeout });
|
|
105
|
+
let previousSize;
|
|
106
|
+
let stableSince;
|
|
107
|
+
// Always set by the reading below before the deadline can be checked.
|
|
108
|
+
let reason;
|
|
109
|
+
|
|
110
|
+
for (;;) {
|
|
111
|
+
const seen = await observeStagedFile(filePath, inProgressSuffixes);
|
|
112
|
+
if (seen.reason !== null) {
|
|
113
|
+
reason = seen.reason;
|
|
114
|
+
// A file that vanished has to prove itself stable again from scratch.
|
|
115
|
+
previousSize = undefined;
|
|
116
|
+
stableSince = undefined;
|
|
117
|
+
} else if (seen.size === previousSize) {
|
|
118
|
+
const stableMs = deadline.elapsedMs() - stableSince;
|
|
119
|
+
if (stableMs >= stability) {
|
|
120
|
+
return {
|
|
121
|
+
ready: true,
|
|
122
|
+
bytes: seen.size,
|
|
123
|
+
reason: null,
|
|
124
|
+
waitedMs: deadline.elapsedMs(),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
reason = `the file had been unchanged for only ${stableMs}ms`;
|
|
128
|
+
} else {
|
|
129
|
+
previousSize = seen.size;
|
|
130
|
+
stableSince = deadline.elapsedMs();
|
|
131
|
+
reason = `the file was still growing at ${seen.size} bytes`;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const remaining = deadline.remainingMs();
|
|
135
|
+
if (remaining === 0) {
|
|
136
|
+
return {
|
|
137
|
+
ready: false,
|
|
138
|
+
bytes: null,
|
|
139
|
+
reason,
|
|
140
|
+
waitedMs: deadline.elapsedMs(),
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
await delay(Math.min(interval, remaining));
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The failure text a staged download that never materialized is reported under.
|
|
149
|
+
*
|
|
150
|
+
* @param {Object} options - Failure details
|
|
151
|
+
* @param {string} options.path - Path the engine staged the download under
|
|
152
|
+
* @param {number} options.timeout - Budget that was exhausted
|
|
153
|
+
* @param {string} options.reason - What the last reading saw
|
|
154
|
+
* @returns {string} Message kept verbatim on the artifact
|
|
155
|
+
*/
|
|
156
|
+
export function describeStagingTimeout({ path: filePath, timeout, reason }) {
|
|
157
|
+
return `the browser reported the download as completed, but ${filePath} was not readable within ${timeout}ms: ${reason}`;
|
|
158
|
+
}
|
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,
|