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.
Files changed (35) hide show
  1. package/.jscpd-baseline.json +8 -0
  2. package/CHANGELOG.md +45 -0
  3. package/README.md +50 -2
  4. package/package.json +8 -3
  5. package/scripts/run-tests.mjs +4 -2
  6. package/src/downloads/manager.js +40 -8
  7. package/src/downloads/sources.js +127 -6
  8. package/src/downloads/staging.js +158 -0
  9. package/src/exports.js +14 -0
  10. package/src/traces/bundle.js +26 -3
  11. package/src/traces/deadline.js +33 -0
  12. package/src/traces/identity.js +127 -0
  13. package/src/traces/init-script.js +95 -0
  14. package/src/traces/links.js +623 -0
  15. package/src/traces/mutation-stream.js +232 -0
  16. package/src/traces/observers.js +29 -3
  17. package/src/traces/page-capture.js +251 -15
  18. package/src/traces/recorder.js +124 -94
  19. package/src/traces/schema.js +65 -1
  20. package/src/traces/viewer.js +157 -11
  21. package/tests/e2e/downloads.e2e.test.js +126 -2
  22. package/tests/e2e/traces.e2e.test.js +296 -11
  23. package/tests/helpers/download-server.js +26 -6
  24. package/tests/helpers/trace-fixtures.js +97 -3
  25. package/tests/helpers/trace-server.js +57 -1
  26. package/tests/unit/downloads/manager.test.js +213 -9
  27. package/tests/unit/downloads/staging.test.js +139 -0
  28. package/tests/unit/scripts/ci-workflow-policy.test.js +8 -0
  29. package/tests/unit/scripts/npm-release-helpers.test.js +3 -2
  30. package/tests/unit/scripts/pre-commit-config.test.js +2 -1
  31. package/tests/unit/traces/bundle.test.js +24 -3
  32. package/tests/unit/traces/links.test.js +564 -0
  33. package/tests/unit/traces/reader.test.js +10 -4
  34. package/tests/unit/traces/recorder.test.js +172 -1
  35. package/tests/unit/traces/viewer.test.js +9 -0
@@ -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. The viewer seeks,
661
- diffs and replays the captured DOM with scripts and network disabled.
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.18.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": ">=20.0.0"
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": "^12.11.1",
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
  }
@@ -147,13 +147,15 @@ export function buildNodeTestArgs(
147
147
  testFiles,
148
148
  nodeVersion = process.versions.node
149
149
  ) {
150
- const args = ['--test'];
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 20-compatible fallback for consumers on the minimum engine version.
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
  }
@@ -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
- * Save a download whose bytes the engine has finished writing.
203
+ * Register work a download is not finished without.
198
204
  *
199
- * Engine events are fire-and-forget, so the save is registered as in-flight
200
- * work: otherwise `dispose()` could return while bytes are still being
201
- * written and leave a `.partial` file as the only trace of the download.
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
- const work = save(artifact, source);
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
- attached.push(await attachCdpSource({ session: cdpSession, root, sink }));
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
  }
@@ -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({ session, root, sink }) {
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
- sink.finished(record, {
129
- path: path.join(stagingDirectory, guid),
130
- removeSource: true,
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,