browser-commander 0.17.2 → 0.18.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 (66) hide show
  1. package/.jscpd-baseline.json +4 -17
  2. package/CHANGELOG.md +34 -0
  3. package/README.md +84 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +4 -1
  6. package/src/bindings.js +13 -1
  7. package/src/browser/connector.js +15 -3
  8. package/src/browser/launcher.js +14 -2
  9. package/src/browser/navigation.js +63 -0
  10. package/src/core/dialog-manager.js +48 -0
  11. package/src/core/navigation-manager.js +83 -87
  12. package/src/core/navigation-readiness.js +133 -0
  13. package/src/core/readiness.js +478 -0
  14. package/src/downloads/attach.js +58 -0
  15. package/src/downloads/destination.js +125 -0
  16. package/src/downloads/manager.js +453 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +188 -0
  19. package/src/downloads/store.js +256 -0
  20. package/src/exports.js +72 -0
  21. package/src/factory.js +55 -0
  22. package/src/interactions/click-activation.js +317 -0
  23. package/src/interactions/click-result.js +108 -0
  24. package/src/interactions/click.js +766 -352
  25. package/src/tests/index.js +75 -11
  26. package/src/tests/tracing.js +236 -0
  27. package/src/traces/bundle.js +323 -0
  28. package/src/traces/observers.js +246 -0
  29. package/src/traces/page-capture.js +365 -0
  30. package/src/traces/reader.js +226 -0
  31. package/src/traces/recorder.js +437 -0
  32. package/src/traces/redaction.js +231 -0
  33. package/src/traces/schema.js +165 -0
  34. package/src/traces/viewer.js +302 -0
  35. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  36. package/tests/e2e/downloads.e2e.test.js +188 -0
  37. package/tests/e2e/traces.e2e.test.js +457 -0
  38. package/tests/helpers/click-fixtures.js +62 -0
  39. package/tests/helpers/download-fixtures.js +125 -0
  40. package/tests/helpers/download-server.js +109 -0
  41. package/tests/helpers/e2e-browser.js +67 -0
  42. package/tests/helpers/file-modes.js +31 -0
  43. package/tests/helpers/fixture-server.js +56 -0
  44. package/tests/helpers/mocks.js +63 -66
  45. package/tests/helpers/readiness-server.js +96 -0
  46. package/tests/helpers/temp-directory.js +44 -0
  47. package/tests/helpers/trace-fixtures.js +198 -0
  48. package/tests/helpers/trace-server.js +117 -0
  49. package/tests/unit/core/dialog-manager.test.js +118 -10
  50. package/tests/unit/core/navigation-manager.test.js +162 -0
  51. package/tests/unit/core/readiness.test.js +517 -0
  52. package/tests/unit/downloads/attach.test.js +220 -0
  53. package/tests/unit/downloads/destination.test.js +114 -0
  54. package/tests/unit/downloads/manager.test.js +354 -0
  55. package/tests/unit/downloads/naming.test.js +174 -0
  56. package/tests/unit/downloads/store.test.js +215 -0
  57. package/tests/unit/interactions/click-activation.test.js +283 -0
  58. package/tests/unit/interactions/click-result.test.js +90 -0
  59. package/tests/unit/interactions/click.test.js +409 -200
  60. package/tests/unit/tests/tracing.test.js +299 -0
  61. package/tests/unit/traces/bundle.test.js +260 -0
  62. package/tests/unit/traces/reader.test.js +208 -0
  63. package/tests/unit/traces/recorder.test.js +562 -0
  64. package/tests/unit/traces/redaction.test.js +170 -0
  65. package/tests/unit/traces/schema.test.js +92 -0
  66. package/tests/unit/traces/viewer.test.js +289 -0
@@ -0,0 +1,170 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ import {
5
+ DEFAULT_REDACT_SELECTORS,
6
+ normalizePrivacyOptions,
7
+ redactHeaders,
8
+ redactText,
9
+ redactUrl,
10
+ redactValue,
11
+ REDACTED,
12
+ } from '../../../src/traces/redaction.js';
13
+
14
+ describe('trace redaction (issue #87)', () => {
15
+ const privacy = normalizePrivacyOptions();
16
+
17
+ describe('normalizePrivacyOptions', () => {
18
+ it('should add to the safe defaults rather than replace them', () => {
19
+ const options = normalizePrivacyOptions({
20
+ redactSelectors: ['.secret'],
21
+ });
22
+
23
+ for (const selector of DEFAULT_REDACT_SELECTORS) {
24
+ assert.ok(options.redactSelectors.includes(selector));
25
+ }
26
+ assert.ok(options.redactSelectors.includes('.secret'));
27
+ });
28
+
29
+ it('should let a caller opt out of the defaults on purpose', () => {
30
+ const options = normalizePrivacyOptions({
31
+ useDefaults: false,
32
+ redactSelectors: ['.secret'],
33
+ });
34
+
35
+ assert.deepStrictEqual(options.redactSelectors, ['.secret']);
36
+ });
37
+
38
+ it('should compile string patterns into regular expressions', () => {
39
+ const options = normalizePrivacyOptions({ redactPatterns: ['sk-\\w+'] });
40
+
41
+ assert.strictEqual(
42
+ redactText('key sk-live42 here', options),
43
+ `key ${REDACTED} here`
44
+ );
45
+ });
46
+
47
+ it('should refuse options that are not an object', () => {
48
+ assert.throws(
49
+ () => normalizePrivacyOptions('everything'),
50
+ /privacy options must be an object/
51
+ );
52
+ });
53
+
54
+ it('should refuse a redact callback that is not callable', () => {
55
+ assert.throws(
56
+ () => normalizePrivacyOptions({ redact: 'yes please' }),
57
+ /privacy.redact must be a function/
58
+ );
59
+ });
60
+ });
61
+
62
+ describe('redactUrl', () => {
63
+ it('should remove credentials, tokens and fragment tokens', () => {
64
+ // Assembled rather than written out: a literal `user:password@host` in
65
+ // the source is exactly what the repository's secret scanner exists to
66
+ // catch, and it cannot tell a fixture from a leak.
67
+ const credentials = ['user', 'hunter2'].join(':');
68
+ const redacted = redactUrl(
69
+ `https://${credentials}@example.com/report?token=abc&page=2#id_token=zz`,
70
+ privacy
71
+ );
72
+
73
+ assert.strictEqual(
74
+ redacted,
75
+ `https://${REDACTED}:${REDACTED}@example.com/report?token=${REDACTED}&page=2#id_token=${REDACTED}`
76
+ );
77
+ assert.ok(!redacted.includes('hunter2'));
78
+ assert.ok(!redacted.includes('abc'));
79
+ });
80
+
81
+ it('should keep a URL that cannot be parsed', () => {
82
+ assert.strictEqual(
83
+ redactUrl('/relative/path', privacy),
84
+ '/relative/path'
85
+ );
86
+ });
87
+
88
+ it('should leave a value that is not a URL alone', () => {
89
+ assert.strictEqual(redactUrl('', privacy), '');
90
+ assert.strictEqual(redactUrl(null, privacy), null);
91
+ });
92
+ });
93
+
94
+ describe('redactHeaders', () => {
95
+ it('should mask authorization and cookie headers by default', () => {
96
+ const headers = redactHeaders(
97
+ {
98
+ Authorization: 'Bearer secret-token',
99
+ Cookie: 'session=abc',
100
+ 'Content-Type': 'application/json',
101
+ },
102
+ privacy
103
+ );
104
+
105
+ assert.deepStrictEqual(headers, {
106
+ Authorization: REDACTED,
107
+ Cookie: REDACTED,
108
+ 'Content-Type': 'application/json',
109
+ });
110
+ });
111
+
112
+ it('should pass a value that is not a header map through', () => {
113
+ assert.strictEqual(redactHeaders(null, privacy), null);
114
+ });
115
+ });
116
+
117
+ describe('redactText', () => {
118
+ it('should let a callback rewrite what is about to be persisted', () => {
119
+ const options = normalizePrivacyOptions({
120
+ redact: ({ value }) => value.replace('Ada Lovelace', 'a customer'),
121
+ });
122
+
123
+ assert.strictEqual(
124
+ redactText('signed by Ada Lovelace', options),
125
+ 'signed by a customer'
126
+ );
127
+ });
128
+
129
+ it('should reset a stateful pattern between values', () => {
130
+ const options = normalizePrivacyOptions({
131
+ redactPatterns: [/secret/g],
132
+ });
133
+
134
+ assert.strictEqual(redactText('a secret', options), `a ${REDACTED}`);
135
+ assert.strictEqual(redactText('a secret', options), `a ${REDACTED}`);
136
+ });
137
+ });
138
+
139
+ describe('redactValue', () => {
140
+ it('should redact every string in a nested payload', () => {
141
+ const event = {
142
+ request: {
143
+ url: 'https://example.com/api?access_token=live',
144
+ headers: { authorization: 'Bearer live' },
145
+ method: 'GET',
146
+ },
147
+ cookie: 'session=abc',
148
+ sizes: [1, 2, 3],
149
+ };
150
+
151
+ const redacted = redactValue(event, privacy);
152
+
153
+ assert.strictEqual(
154
+ redacted.request.url,
155
+ `https://example.com/api?access_token=${REDACTED}`
156
+ );
157
+ assert.strictEqual(redacted.request.headers.authorization, REDACTED);
158
+ assert.strictEqual(redacted.request.method, 'GET');
159
+ assert.strictEqual(redacted.cookie, REDACTED);
160
+ assert.deepStrictEqual(redacted.sizes, [1, 2, 3]);
161
+ assert.ok(!JSON.stringify(redacted).includes('live'));
162
+ });
163
+
164
+ it('should leave values that carry no text unchanged', () => {
165
+ assert.strictEqual(redactValue(42, privacy), 42);
166
+ assert.strictEqual(redactValue(null, privacy), null);
167
+ assert.strictEqual(redactValue(true, privacy), true);
168
+ });
169
+ });
170
+ });
@@ -0,0 +1,92 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+
4
+ import {
5
+ assertReadableManifest,
6
+ createManifest,
7
+ sequenceName,
8
+ TRACE_EVENT,
9
+ TRACE_MODE,
10
+ TRACE_OUTCOME,
11
+ TRACE_SCHEMA_VERSION,
12
+ } from '../../../src/traces/schema.js';
13
+
14
+ describe('trace schema (issue #87)', () => {
15
+ describe('sequenceName', () => {
16
+ it('should sort lexically in the same order as the sequence', () => {
17
+ const names = [10, 2, 1].map(sequenceName).sort();
18
+
19
+ assert.deepStrictEqual(names, ['0001', '0002', '0010']);
20
+ });
21
+ });
22
+
23
+ describe('createManifest', () => {
24
+ it('should record the version, engine and environment of the run', () => {
25
+ const manifest = createManifest({
26
+ mode: TRACE_MODE.CHECKPOINTS,
27
+ startedAt: '2026-01-01T00:00:00.000Z',
28
+ engine: 'playwright',
29
+ commanderVersion: '0.17.2',
30
+ });
31
+
32
+ assert.strictEqual(manifest.schemaVersion, TRACE_SCHEMA_VERSION);
33
+ assert.strictEqual(manifest.format, 'browser-commander-trace');
34
+ assert.strictEqual(manifest.engine, 'playwright');
35
+ assert.strictEqual(manifest.commanderVersion, '0.17.2');
36
+ assert.ok(manifest.platform.includes(process.platform));
37
+ assert.ok(manifest.runtime.startsWith('node '));
38
+ });
39
+
40
+ it('should start every counter at zero', () => {
41
+ const manifest = createManifest({ mode: TRACE_MODE.CONTINUOUS });
42
+
43
+ assert.deepStrictEqual(manifest.counts, {
44
+ checkpoints: 0,
45
+ events: 0,
46
+ mutationBatches: 0,
47
+ });
48
+ assert.strictEqual(manifest.outcome, TRACE_OUTCOME.COMPLETE);
49
+ });
50
+
51
+ it('should copy collections so a caller cannot mutate the manifest', () => {
52
+ const events = ['console'];
53
+ const manifest = createManifest({ mode: TRACE_MODE.OFF, events });
54
+
55
+ events.push('download');
56
+
57
+ assert.deepStrictEqual(manifest.events, ['console']);
58
+ });
59
+ });
60
+
61
+ describe('assertReadableManifest', () => {
62
+ it('should accept a manifest of this version', () => {
63
+ assert.doesNotThrow(() =>
64
+ assertReadableManifest(createManifest({ mode: TRACE_MODE.CHECKPOINTS }))
65
+ );
66
+ });
67
+
68
+ it('should refuse a directory that is not a trace bundle', () => {
69
+ assert.throws(
70
+ () => assertReadableManifest({ format: 'something-else' }),
71
+ /not a Browser Commander trace bundle/
72
+ );
73
+ });
74
+
75
+ it('should refuse a schema it was not written for', () => {
76
+ assert.throws(
77
+ () =>
78
+ assertReadableManifest({
79
+ format: 'browser-commander-trace',
80
+ schemaVersion: TRACE_SCHEMA_VERSION + 1,
81
+ }),
82
+ /newer than this reader/
83
+ );
84
+ });
85
+ });
86
+
87
+ describe('event kinds', () => {
88
+ it('should name a dropped record so gaps stay visible', () => {
89
+ assert.strictEqual(TRACE_EVENT.DROPPED, 'dropped');
90
+ });
91
+ });
92
+ });
@@ -0,0 +1,289 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert';
3
+ import fs from 'node:fs/promises';
4
+ import path from 'node:path';
5
+
6
+ import { readTrace } from '../../../src/traces/reader.js';
7
+ import { startTrace } from '../../../src/traces/recorder.js';
8
+ import { TRACE_FILES, TRACE_MODE } from '../../../src/traces/schema.js';
9
+ import {
10
+ DEFAULT_MAX_INLINE_BYTES,
11
+ renderTraceViewer,
12
+ writeTraceViewer,
13
+ } from '../../../src/traces/viewer.js';
14
+ import {
15
+ createFakeCommander,
16
+ createFakePage,
17
+ makeSnapshot,
18
+ useTempTraceDirectory,
19
+ } from '../../helpers/trace-fixtures.js';
20
+ import { assertMode } from '../../helpers/file-modes.js';
21
+
22
+ describe('offline trace viewer (issue #87)', () => {
23
+ const directory = useTempTraceDirectory();
24
+
25
+ /**
26
+ * Start a recorder over a fake page.
27
+ *
28
+ * @param {string} name - Directory the bundle is written to
29
+ * @param {Object} [options] - `{pageOptions, page, ...startTrace options}`
30
+ * @returns {Promise<Object>} `{trace, page}`
31
+ */
32
+ const beginTrace = async (name, options = {}) => {
33
+ const {
34
+ pageOptions = {},
35
+ page = createFakePage(pageOptions),
36
+ ...rest
37
+ } = options;
38
+ const trace = await startTrace({
39
+ commander: createFakeCommander(page),
40
+ page,
41
+ output: path.join(directory.path, name),
42
+ mode: TRACE_MODE.CONTINUOUS,
43
+ screenshots: false,
44
+ ...rest,
45
+ });
46
+ return { trace, page };
47
+ };
48
+
49
+ /**
50
+ * Record a small trace and return the path to its bundle.
51
+ *
52
+ * @param {Object} [options] - `{pageOptions, checkpoints, ...startTrace options}`
53
+ * @returns {Promise<string>} Bundle path
54
+ */
55
+ const recordBundle = async (options = {}) => {
56
+ const { checkpoints = ['first'], ...rest } = options;
57
+ const { trace } = await beginTrace(
58
+ `run-${Math.random().toString(36).slice(2)}`,
59
+ rest
60
+ );
61
+ for (const name of checkpoints) {
62
+ await trace.checkpoint(name);
63
+ }
64
+ return (await trace.stop()).path;
65
+ };
66
+
67
+ /**
68
+ * The JSON the viewer carries inline.
69
+ *
70
+ * @param {string} viewer - The rendered viewer document
71
+ * @returns {Object} The embedded trace data
72
+ */
73
+ const embedded = (viewer) =>
74
+ JSON.parse(
75
+ viewer.match(
76
+ /<script id="trace-data" type="application\/json">([\s\S]*?)<\/script>/
77
+ )[1]
78
+ );
79
+
80
+ /**
81
+ * Render the viewer for a bundle recorded from one snapshot.
82
+ *
83
+ * @param {Object} [snapshot] - A snapshot from `makeSnapshot()`
84
+ * @returns {Promise<string>} The viewer document
85
+ */
86
+ const renderFor = async (snapshot) =>
87
+ renderTraceViewer(
88
+ await readTrace(
89
+ await recordBundle(snapshot ? { pageOptions: { snapshot } } : {})
90
+ )
91
+ );
92
+
93
+ /**
94
+ * Stop a trace and read back what its viewer would carry.
95
+ *
96
+ * @param {Object} trace - A running trace
97
+ * @returns {Promise<Object>} The embedded trace data
98
+ */
99
+ const dataAfterStopping = async (trace) => {
100
+ const stopped = await trace.stop();
101
+ return embedded(await renderTraceViewer(await readTrace(stopped.path)));
102
+ };
103
+
104
+ describe('inertness', () => {
105
+ it('should forbid everything the captured page might reach for', async () => {
106
+ const viewer = await renderFor();
107
+
108
+ const policy = viewer.match(
109
+ /<meta http-equiv="Content-Security-Policy" content="([^"]+)"/
110
+ )[1];
111
+ assert.match(policy, /default-src 'none'/);
112
+ // No `connect-src`, no `http:` and no `https:` anywhere in the policy:
113
+ // opening a colleague's trace must not call anyone's server.
114
+ assert.ok(!/https?:/.test(policy));
115
+ assert.ok(!policy.includes('connect-src'));
116
+ });
117
+
118
+ it('should render the captured page in a frame with no privileges', async () => {
119
+ const viewer = await renderFor();
120
+
121
+ const frame = viewer.match(/<iframe [^>]*>/)[0];
122
+ // A bare `sandbox` is the empty allow-list: no scripts, no forms, no
123
+ // top-level navigation, and an opaque origin.
124
+ assert.match(frame, /\ssandbox(\s|>)/);
125
+ assert.ok(!frame.includes('allow-scripts'));
126
+ assert.ok(!frame.includes('allow-forms'));
127
+ assert.ok(!frame.includes('allow-same-origin'));
128
+ assert.match(frame, /referrerpolicy="no-referrer"/);
129
+ });
130
+
131
+ it('should never let captured markup become part of the viewer document', async () => {
132
+ const page = createFakePage({
133
+ snapshot: makeSnapshot({
134
+ html: '<html><body></script><script>fetch("https://attacker.example")</script><img src=x onerror=alert(1)></body></html>',
135
+ }),
136
+ });
137
+ const viewer = await renderFor(page.state.snapshot);
138
+
139
+ // Exactly the viewer's own two scripts: the embedded data and the UI.
140
+ assert.strictEqual(viewer.match(/<script/g).length, 2);
141
+ // Every angle bracket the page produced is escaped, so none of its
142
+ // markup can become an element of the viewer's own document.
143
+ assert.ok(!viewer.includes('<img'));
144
+ assert.ok(!viewer.includes('</script><script>'));
145
+ // The markup is still there, escaped, so a reviewer can read it.
146
+ assert.ok(viewer.includes('\\u003cimg src=x onerror=alert(1)\\u003e'));
147
+ });
148
+
149
+ it('should escape the line separators a JavaScript parser treats as newlines', async () => {
150
+ const page = createFakePage({
151
+ snapshot: makeSnapshot({ html: '<p>one
two
three</p>' }),
152
+ });
153
+ const viewer = await renderFor(page.state.snapshot);
154
+
155
+ assert.ok(!viewer.includes('
'));
156
+ assert.ok(!viewer.includes('
'));
157
+ assert.ok(viewer.includes('\\u2028'));
158
+ });
159
+ });
160
+
161
+ describe('what it embeds', () => {
162
+ it('should carry the trace inline so it opens from a file:// URL', async () => {
163
+ const bundle = await recordBundle({ checkpoints: ['first', 'second'] });
164
+
165
+ const viewer = await renderTraceViewer(await readTrace(bundle));
166
+
167
+ const data = embedded(viewer);
168
+ assert.strictEqual(data.checkpoints.length, 2);
169
+ assert.match(data.html[1], /captured/);
170
+ assert.ok(data.state[1].controls.length > 0);
171
+ assert.ok(data.events.length > 0);
172
+ // Member names are metadata on the timeline; nothing is loaded from
173
+ // them, because a viewer that fetched would not open from a file:// URL.
174
+ assert.ok(!/(?:src|href)="[^"]*checkpoints\//.test(viewer));
175
+ assert.ok(!viewer.includes('fetch('));
176
+ });
177
+
178
+ it('should carry ordered mutation batches for replay', async () => {
179
+ const { trace, page } = await beginTrace('replayed');
180
+ await trace.checkpoint('first');
181
+ // The batches the page produced *after* the first checkpoint belong to
182
+ // that checkpoint's interval, which is what replay animates.
183
+ page.queueMutations([
184
+ { sequence: 1, records: [{ kind: 'attributes' }] },
185
+ { sequence: 2, records: [{ kind: 'childList' }] },
186
+ ]);
187
+ await trace.checkpoint('second');
188
+
189
+ const data = await dataAfterStopping(trace);
190
+
191
+ assert.deepStrictEqual(
192
+ data.mutations[1].map((batch) => batch.sequence),
193
+ [1, 2]
194
+ );
195
+ });
196
+
197
+ it('should diff each checkpoint against the one before it', async () => {
198
+ const { trace, page } = await beginTrace('diffed');
199
+ await trace.checkpoint('before');
200
+ page.setSnapshot(
201
+ makeSnapshot({
202
+ state: {
203
+ controls: [{ path: 'form > input', tag: 'input', value: 'bob' }],
204
+ },
205
+ })
206
+ );
207
+ await trace.checkpoint('after');
208
+
209
+ const data = await dataAfterStopping(trace);
210
+
211
+ assert.deepStrictEqual(data.diffs[1], []);
212
+ assert.deepStrictEqual(data.diffs[2], [
213
+ {
214
+ path: 'form > input',
215
+ change: 'changed',
216
+ before: 'alice',
217
+ after: 'bob',
218
+ },
219
+ ]);
220
+ });
221
+
222
+ it('should leave out a checkpoint too large to embed and say which', async () => {
223
+ const bundle = await recordBundle();
224
+
225
+ const viewer = await renderTraceViewer(await readTrace(bundle), {
226
+ maxInlineBytes: 8,
227
+ });
228
+
229
+ const data = embedded(viewer);
230
+ assert.deepStrictEqual(data.elided, [1]);
231
+ assert.strictEqual(data.html[1], null);
232
+ // The checkpoint itself is still on the timeline; only its markup is gone.
233
+ assert.strictEqual(data.checkpoints.length, 1);
234
+ assert.ok(data.state[1]);
235
+ });
236
+
237
+ it('should embed everything short of the default ceiling', async () => {
238
+ assert.strictEqual(DEFAULT_MAX_INLINE_BYTES, 8 * 1024 * 1024);
239
+
240
+ const viewer = await renderFor();
241
+
242
+ const data = embedded(viewer);
243
+ assert.deepStrictEqual(data.elided, []);
244
+ });
245
+
246
+ it('should show what the run was and how it ended', async () => {
247
+ const viewer = await renderFor();
248
+
249
+ assert.match(viewer, /mode continuous/);
250
+ assert.match(viewer, /outcome complete/);
251
+ assert.match(viewer, /engine playwright/);
252
+ });
253
+ });
254
+
255
+ describe('writing it into a bundle', () => {
256
+ it('should put the viewer next to the trace it explains', async () => {
257
+ const bundle = await recordBundle();
258
+
259
+ const target = await writeTraceViewer(bundle);
260
+
261
+ assert.strictEqual(target, path.join(bundle, TRACE_FILES.VIEWER));
262
+ const body = await fs.readFile(target, 'utf8');
263
+ assert.match(body, /^<!DOCTYPE html>/);
264
+ await assertMode(target, 0o600);
265
+ });
266
+
267
+ it('should still explain a run that never stopped', async () => {
268
+ const { trace } = await beginTrace('killed');
269
+ await trace.checkpoint('the last thing it did');
270
+ // No `stop()`: the process was killed, so there is no manifest.
271
+ await fs.rm(path.join(trace.path, TRACE_FILES.MANIFEST), { force: true });
272
+
273
+ const body = await fs.readFile(
274
+ await writeTraceViewer(trace.path),
275
+ 'utf8'
276
+ );
277
+
278
+ assert.match(body, /outcome truncated/);
279
+ assert.match(body, /the last thing it did|1 checkpoints/);
280
+ });
281
+
282
+ it('should refuse a directory that is not a bundle', async () => {
283
+ const empty = path.join(directory.path, 'not-a-bundle');
284
+ await fs.mkdir(empty, { recursive: true });
285
+
286
+ await assert.rejects(writeTraceViewer(empty), /no trace bundle at/);
287
+ });
288
+ });
289
+ });