browser-commander 0.17.1 → 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.
- package/.jscpd-baseline.json +7 -18
- package/CHANGELOG.md +48 -0
- package/README.md +84 -0
- package/eslint.config.js +5 -0
- package/package.json +4 -1
- package/scripts/version-and-commit.mjs +13 -2
- package/src/bindings.js +13 -1
- package/src/browser/connector.js +15 -3
- package/src/browser/launcher.js +14 -2
- package/src/browser/navigation.js +63 -0
- package/src/core/dialog-manager.js +48 -0
- package/src/core/navigation-manager.js +83 -87
- package/src/core/navigation-readiness.js +133 -0
- package/src/core/readiness.js +478 -0
- package/src/downloads/attach.js +58 -0
- package/src/downloads/destination.js +125 -0
- package/src/downloads/manager.js +453 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +188 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +72 -0
- package/src/factory.js +55 -0
- package/src/interactions/click-activation.js +317 -0
- package/src/interactions/click-result.js +108 -0
- package/src/interactions/click.js +766 -352
- package/src/tests/index.js +75 -11
- package/src/tests/tracing.js +236 -0
- package/src/traces/bundle.js +323 -0
- package/src/traces/observers.js +246 -0
- package/src/traces/page-capture.js +365 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +437 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +165 -0
- package/src/traces/viewer.js +302 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +188 -0
- package/tests/e2e/traces.e2e.test.js +457 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +109 -0
- package/tests/helpers/e2e-browser.js +67 -0
- package/tests/helpers/file-modes.js +31 -0
- package/tests/helpers/fixture-server.js +56 -0
- package/tests/helpers/mocks.js +63 -66
- package/tests/helpers/readiness-server.js +96 -0
- package/tests/helpers/temp-directory.js +44 -0
- package/tests/helpers/trace-fixtures.js +198 -0
- package/tests/helpers/trace-server.js +117 -0
- package/tests/unit/core/dialog-manager.test.js +118 -10
- package/tests/unit/core/navigation-manager.test.js +162 -0
- package/tests/unit/core/readiness.test.js +517 -0
- package/tests/unit/downloads/attach.test.js +220 -0
- package/tests/unit/downloads/destination.test.js +114 -0
- package/tests/unit/downloads/manager.test.js +354 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/store.test.js +215 -0
- package/tests/unit/interactions/click-activation.test.js +283 -0
- package/tests/unit/interactions/click-result.test.js +90 -0
- package/tests/unit/interactions/click.test.js +409 -200
- package/tests/unit/scripts/check-release-format.test.js +4 -1
- package/tests/unit/scripts/push-failure-classifier.test.js +153 -0
- package/tests/unit/scripts/push-with-rebase-retry.test.js +302 -0
- package/tests/unit/tests/tracing.test.js +299 -0
- package/tests/unit/traces/bundle.test.js +260 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +562 -0
- package/tests/unit/traces/redaction.test.js +170 -0
- package/tests/unit/traces/schema.test.js +92 -0
- package/tests/unit/traces/viewer.test.js +289 -0
|
@@ -0,0 +1,299 @@
|
|
|
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 {
|
|
7
|
+
finishScenarioTrace,
|
|
8
|
+
normalizeTestScreenshots,
|
|
9
|
+
normalizeTestTrace,
|
|
10
|
+
resolveTestDownloads,
|
|
11
|
+
resolveTraceSetting,
|
|
12
|
+
startScenarioTrace,
|
|
13
|
+
TEST_DOWNLOADS_DIRNAME,
|
|
14
|
+
TRACE_BUNDLE_SUFFIX,
|
|
15
|
+
traceOutputPath,
|
|
16
|
+
} from '../../../src/tests/tracing.js';
|
|
17
|
+
import { readTrace } from '../../../src/traces/reader.js';
|
|
18
|
+
import { TRACE_FILES, TRACE_MODE } from '../../../src/traces/schema.js';
|
|
19
|
+
import {
|
|
20
|
+
createFakeCommander,
|
|
21
|
+
createFakePage,
|
|
22
|
+
useTempTraceDirectory,
|
|
23
|
+
} from '../../helpers/trace-fixtures.js';
|
|
24
|
+
|
|
25
|
+
describe('test runner tracing (issues #87 and #88)', () => {
|
|
26
|
+
const directory = useTempTraceDirectory();
|
|
27
|
+
|
|
28
|
+
describe('normalizing what a scenario asked for', () => {
|
|
29
|
+
it('should treat no trace option as off', () => {
|
|
30
|
+
assert.strictEqual(normalizeTestTrace(undefined), 'off');
|
|
31
|
+
assert.strictEqual(normalizeTestTrace(null), 'off');
|
|
32
|
+
assert.strictEqual(normalizeTestTrace(false), 'off');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('should read true as recording the whole run', () => {
|
|
36
|
+
assert.strictEqual(normalizeTestTrace(true), 'on');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('should keep the named modes', () => {
|
|
40
|
+
assert.strictEqual(
|
|
41
|
+
normalizeTestTrace('retain-on-failure'),
|
|
42
|
+
'retain-on-failure'
|
|
43
|
+
);
|
|
44
|
+
assert.strictEqual(
|
|
45
|
+
normalizeTestTrace('on-first-retry'),
|
|
46
|
+
'on-first-retry'
|
|
47
|
+
);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
it('should refuse a mode it does not know', () => {
|
|
51
|
+
assert.throws(
|
|
52
|
+
() => normalizeTestTrace('retain-on-tuesday'),
|
|
53
|
+
/trace must be one of off, on, retain-on-failure, on-first-retry/
|
|
54
|
+
);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('should default screenshots to only-on-failure', () => {
|
|
58
|
+
assert.strictEqual(
|
|
59
|
+
normalizeTestScreenshots(undefined),
|
|
60
|
+
'only-on-failure'
|
|
61
|
+
);
|
|
62
|
+
assert.strictEqual(normalizeTestScreenshots(true), 'on');
|
|
63
|
+
assert.strictEqual(normalizeTestScreenshots(false), 'off');
|
|
64
|
+
assert.strictEqual(normalizeTestScreenshots('off'), 'off');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
it('should refuse a screenshot mode it does not know', () => {
|
|
68
|
+
assert.throws(
|
|
69
|
+
() => normalizeTestScreenshots('sometimes'),
|
|
70
|
+
/screenshots must be one of/
|
|
71
|
+
);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('deciding whether an attempt records', () => {
|
|
76
|
+
it('should not record when tracing is off', () => {
|
|
77
|
+
assert.strictEqual(resolveTraceSetting('off'), null);
|
|
78
|
+
assert.strictEqual(resolveTraceSetting('off', 3), null);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('should keep every bundle when tracing is on', () => {
|
|
82
|
+
assert.deepStrictEqual(resolveTraceSetting('on'), {
|
|
83
|
+
recorderMode: TRACE_MODE.CONTINUOUS,
|
|
84
|
+
retainOnFailure: false,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('should record but only keep a failure otherwise', () => {
|
|
89
|
+
assert.deepStrictEqual(resolveTraceSetting('retain-on-failure'), {
|
|
90
|
+
recorderMode: TRACE_MODE.RETAIN_ON_FAILURE,
|
|
91
|
+
retainOnFailure: true,
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
it('should wait for the retry when asked to', () => {
|
|
96
|
+
assert.strictEqual(resolveTraceSetting('on-first-retry', 1), null);
|
|
97
|
+
assert.deepStrictEqual(resolveTraceSetting('on-first-retry', 2), {
|
|
98
|
+
recorderMode: TRACE_MODE.RETAIN_ON_FAILURE,
|
|
99
|
+
retainOnFailure: true,
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// The expectations resolve their paths the way the code under test does.
|
|
105
|
+
// A POSIX literal is not a path on Windows: `path.resolve('/a', 'x')` there
|
|
106
|
+
// is `D:\\a\\x`, because a rooted path with no drive means the current one.
|
|
107
|
+
describe('where artifacts go', () => {
|
|
108
|
+
it('should name a bundle after the scenario', () => {
|
|
109
|
+
assert.strictEqual(
|
|
110
|
+
traceOutputPath({ artifactsDir: '/a', safeName: 'checkout' }),
|
|
111
|
+
path.resolve('/a', `checkout${TRACE_BUNDLE_SUFFIX}`)
|
|
112
|
+
);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it('should keep each retry in its own bundle', () => {
|
|
116
|
+
assert.strictEqual(
|
|
117
|
+
traceOutputPath({
|
|
118
|
+
artifactsDir: '/a',
|
|
119
|
+
safeName: 'checkout',
|
|
120
|
+
attempt: 2,
|
|
121
|
+
}),
|
|
122
|
+
path.resolve('/a', `checkout.attempt-2${TRACE_BUNDLE_SUFFIX}`)
|
|
123
|
+
);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('should keep a scenario downloads in its own artifact directory', () => {
|
|
127
|
+
assert.deepStrictEqual(
|
|
128
|
+
resolveTestDownloads(true, {
|
|
129
|
+
artifactsDir: '/a',
|
|
130
|
+
safeName: 'checkout',
|
|
131
|
+
}),
|
|
132
|
+
{ directory: path.resolve('/a', 'checkout', TEST_DOWNLOADS_DIRNAME) }
|
|
133
|
+
);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('should keep the rest of the download options', () => {
|
|
137
|
+
assert.deepStrictEqual(
|
|
138
|
+
resolveTestDownloads(
|
|
139
|
+
{ timeout: 5000, allowOverwrite: true },
|
|
140
|
+
{ artifactsDir: '/a', safeName: 'checkout' }
|
|
141
|
+
),
|
|
142
|
+
{
|
|
143
|
+
timeout: 5000,
|
|
144
|
+
allowOverwrite: true,
|
|
145
|
+
directory: path.resolve('/a', 'checkout', TEST_DOWNLOADS_DIRNAME),
|
|
146
|
+
}
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
it('should not move downloads the caller placed itself', () => {
|
|
151
|
+
assert.deepStrictEqual(
|
|
152
|
+
resolveTestDownloads(
|
|
153
|
+
{ directory: '/somewhere/else' },
|
|
154
|
+
{ artifactsDir: '/a', safeName: 'checkout' }
|
|
155
|
+
),
|
|
156
|
+
{ directory: '/somewhere/else' }
|
|
157
|
+
);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('should leave downloads alone when they are not enabled', () => {
|
|
161
|
+
assert.strictEqual(
|
|
162
|
+
resolveTestDownloads(false, { artifactsDir: '/a', safeName: 'x' }),
|
|
163
|
+
false
|
|
164
|
+
);
|
|
165
|
+
assert.strictEqual(
|
|
166
|
+
resolveTestDownloads(undefined, { artifactsDir: '/a', safeName: 'x' }),
|
|
167
|
+
undefined
|
|
168
|
+
);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe('recording one scenario', () => {
|
|
173
|
+
/**
|
|
174
|
+
* Start a scenario trace over a fake page.
|
|
175
|
+
*
|
|
176
|
+
* @param {Object} [options] - Overrides for `startScenarioTrace`
|
|
177
|
+
* @returns {Promise<Object>} `{started, page}`
|
|
178
|
+
*/
|
|
179
|
+
const start = async (options = {}) => {
|
|
180
|
+
const { page = createFakePage(), ...overrides } = options;
|
|
181
|
+
const started = await startScenarioTrace({
|
|
182
|
+
commander: createFakeCommander(page),
|
|
183
|
+
trace: 'retain-on-failure',
|
|
184
|
+
artifactsDir: directory.path,
|
|
185
|
+
safeName: 'checkout',
|
|
186
|
+
...overrides,
|
|
187
|
+
page,
|
|
188
|
+
});
|
|
189
|
+
return { started, page };
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
it('should not record when the scenario did not ask for a trace', async () => {
|
|
193
|
+
const { started } = await start({ trace: 'off' });
|
|
194
|
+
|
|
195
|
+
assert.strictEqual(started, null);
|
|
196
|
+
assert.deepStrictEqual(await fs.readdir(directory.path), []);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it('should not record when the runner has nowhere to write', async () => {
|
|
200
|
+
const { started } = await start({ trace: 'on', artifactsDir: undefined });
|
|
201
|
+
|
|
202
|
+
assert.strictEqual(started, null);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it('should record DOM mutations whenever it records at all', async () => {
|
|
206
|
+
const { started, page } = await start({ trace: 'retain-on-failure' });
|
|
207
|
+
|
|
208
|
+
assert.ok(
|
|
209
|
+
page.state.evaluated.some(
|
|
210
|
+
(call) => call.name === 'installMutationRecorderInPage'
|
|
211
|
+
)
|
|
212
|
+
);
|
|
213
|
+
await started.trace.stop({ discard: true });
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('should throw away a bundle a passing test left behind', async () => {
|
|
217
|
+
const { started } = await start({ trace: 'retain-on-failure' });
|
|
218
|
+
|
|
219
|
+
const stopped = await finishScenarioTrace(started);
|
|
220
|
+
|
|
221
|
+
assert.strictEqual(stopped.discarded, true);
|
|
222
|
+
assert.deepStrictEqual(await fs.readdir(directory.path), []);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('should keep a failing test bundle, with the error on the timeline', async () => {
|
|
226
|
+
const { started } = await start({ trace: 'retain-on-failure' });
|
|
227
|
+
|
|
228
|
+
const stopped = await finishScenarioTrace(started, {
|
|
229
|
+
error: new Error('expected 2 items, found 1'),
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
assert.ok(!stopped.discarded);
|
|
233
|
+
const reader = await readTrace(stopped.path);
|
|
234
|
+
const fatal = reader.events.find((event) => event.fatal);
|
|
235
|
+
assert.strictEqual(fatal.message, 'expected 2 items, found 1');
|
|
236
|
+
assert.strictEqual(reader.checkpoints.at(-1).name, 'failure');
|
|
237
|
+
assert.strictEqual(reader.checkpoints.at(-1).actor, 'runner');
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('should keep a passing test bundle when tracing is simply on', async () => {
|
|
241
|
+
const { started } = await start({ trace: 'on' });
|
|
242
|
+
|
|
243
|
+
const stopped = await finishScenarioTrace(started);
|
|
244
|
+
|
|
245
|
+
assert.ok(!stopped.discarded);
|
|
246
|
+
assert.strictEqual(
|
|
247
|
+
(await readTrace(stopped.path)).checkpoints.at(-1).name,
|
|
248
|
+
'final'
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('should leave a viewer beside every bundle it keeps', async () => {
|
|
253
|
+
const { started } = await start({ trace: 'on' });
|
|
254
|
+
|
|
255
|
+
const stopped = await finishScenarioTrace(started);
|
|
256
|
+
|
|
257
|
+
const viewer = await fs.readFile(
|
|
258
|
+
path.join(stopped.path, TRACE_FILES.VIEWER),
|
|
259
|
+
'utf8'
|
|
260
|
+
);
|
|
261
|
+
assert.match(viewer, /Browser Commander trace/);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it('should take a failure screenshot even when passing runs take none', async () => {
|
|
265
|
+
const { started, page } = await start({
|
|
266
|
+
trace: 'retain-on-failure',
|
|
267
|
+
screenshots: 'only-on-failure',
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await finishScenarioTrace(started, { error: new Error('nope') });
|
|
271
|
+
|
|
272
|
+
assert.strictEqual(page.state.screenshots, 1);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it('should still stop when the final checkpoint cannot be captured', async () => {
|
|
276
|
+
const page = createFakePage();
|
|
277
|
+
const { started } = await start({ page, trace: 'on' });
|
|
278
|
+
page.failEvaluate(
|
|
279
|
+
'captureSnapshotInPage',
|
|
280
|
+
'Target page, context or browser has been closed'
|
|
281
|
+
);
|
|
282
|
+
const noted = [];
|
|
283
|
+
|
|
284
|
+
const stopped = await finishScenarioTrace(started, {
|
|
285
|
+
log: { debug: (message) => noted.push(message) },
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
assert.ok(stopped.path);
|
|
289
|
+
assert.ok((await readTrace(stopped.path)).events.length > 0);
|
|
290
|
+
// The page closing is a gap in the trace, not a second failure on top of
|
|
291
|
+
// whatever the test was already reporting.
|
|
292
|
+
assert.ok(noted.every((message) => message.startsWith('[trace]')));
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should do nothing when there was no trace to finish', async () => {
|
|
296
|
+
assert.strictEqual(await finishScenarioTrace(null), null);
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
});
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
import { describe, it } from 'node:test';
|
|
2
|
+
import assert from 'node:assert';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
import fs from 'node:fs/promises';
|
|
5
|
+
import path from 'node:path';
|
|
6
|
+
|
|
7
|
+
import {
|
|
8
|
+
openTraceBundle,
|
|
9
|
+
TRACE_FILE_MODE,
|
|
10
|
+
} from '../../../src/traces/bundle.js';
|
|
11
|
+
import {
|
|
12
|
+
createManifest,
|
|
13
|
+
TRACE_DROP_REASON,
|
|
14
|
+
TRACE_EVENT,
|
|
15
|
+
TRACE_FILES,
|
|
16
|
+
TRACE_MODE,
|
|
17
|
+
TRACE_OUTCOME,
|
|
18
|
+
} from '../../../src/traces/schema.js';
|
|
19
|
+
import { useTempTraceDirectory } from '../../helpers/trace-fixtures.js';
|
|
20
|
+
|
|
21
|
+
describe('trace bundle writer (issue #87)', () => {
|
|
22
|
+
const directory = useTempTraceDirectory();
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Open a bundle inside the suite's temporary directory.
|
|
26
|
+
*
|
|
27
|
+
* @param {Object} [options] - Options for `openTraceBundle`
|
|
28
|
+
* @returns {Promise<Object>} The bundle writer
|
|
29
|
+
*/
|
|
30
|
+
const open = (options = {}) =>
|
|
31
|
+
openTraceBundle({ output: path.join(directory.path, 'run'), ...options });
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* List a bundle's members, relative to its root.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} root - Bundle directory
|
|
37
|
+
* @returns {Promise<string[]>} Sorted member names
|
|
38
|
+
*/
|
|
39
|
+
const members = async (root) =>
|
|
40
|
+
(await fs.readdir(root, { recursive: true }))
|
|
41
|
+
.map((entry) => entry.split(path.sep).join('/'))
|
|
42
|
+
.sort();
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Read the timeline back.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} root - Bundle directory
|
|
48
|
+
* @returns {Promise<Object[]>} Parsed events
|
|
49
|
+
*/
|
|
50
|
+
const timeline = async (root) =>
|
|
51
|
+
(await fs.readFile(path.join(root, TRACE_FILES.EVENTS), 'utf8'))
|
|
52
|
+
.split('\n')
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.map((line) => JSON.parse(line));
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* The first gap the bundle recorded.
|
|
58
|
+
*
|
|
59
|
+
* @param {string} root - Bundle directory
|
|
60
|
+
* @returns {Promise<Object|undefined>} The dropped event
|
|
61
|
+
*/
|
|
62
|
+
const firstDrop = async (root) =>
|
|
63
|
+
(await timeline(root)).find((event) => event.kind === TRACE_EVENT.DROPPED);
|
|
64
|
+
|
|
65
|
+
it('should refuse to open without an output path', async () => {
|
|
66
|
+
await assert.rejects(openTraceBundle({}), /trace output must be a path/);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should write the layout the format documents', async () => {
|
|
70
|
+
const bundle = await open();
|
|
71
|
+
|
|
72
|
+
await bundle.writeCheckpoint({
|
|
73
|
+
index: 1,
|
|
74
|
+
html: '<html></html>',
|
|
75
|
+
state: { url: 'https://example.com' },
|
|
76
|
+
screenshot: Buffer.from('png'),
|
|
77
|
+
});
|
|
78
|
+
await bundle.writeMutations(1, [{ records: [] }]);
|
|
79
|
+
await bundle.writeArtifact('report body', '.txt');
|
|
80
|
+
await bundle.close(createManifest({ mode: TRACE_MODE.CHECKPOINTS }));
|
|
81
|
+
|
|
82
|
+
assert.deepStrictEqual(await members(bundle.root), [
|
|
83
|
+
'artifacts',
|
|
84
|
+
`artifacts/${crypto.createHash('sha256').update('report body').digest('hex')}.txt`,
|
|
85
|
+
'checkpoints',
|
|
86
|
+
'checkpoints/0001.html',
|
|
87
|
+
'checkpoints/0001.png',
|
|
88
|
+
'checkpoints/0001.state.json',
|
|
89
|
+
'events.ndjson',
|
|
90
|
+
'manifest.json',
|
|
91
|
+
'mutations',
|
|
92
|
+
'mutations/0001.ndjson',
|
|
93
|
+
]);
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('should number events and stamp them with both clocks', async () => {
|
|
97
|
+
const bundle = await open();
|
|
98
|
+
|
|
99
|
+
const first = await bundle.appendEvent({ kind: TRACE_EVENT.CHECKPOINT });
|
|
100
|
+
const second = await bundle.appendEvent({ kind: TRACE_EVENT.CONSOLE });
|
|
101
|
+
|
|
102
|
+
assert.strictEqual(first.sequence, 1);
|
|
103
|
+
assert.strictEqual(second.sequence, 2);
|
|
104
|
+
assert.ok(Date.parse(first.at) > 0);
|
|
105
|
+
assert.ok(second.monotonicMs >= first.monotonicMs);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('should keep a trace readable for its owner only', async () => {
|
|
109
|
+
if (process.platform === 'win32') {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const bundle = await open();
|
|
113
|
+
|
|
114
|
+
await bundle.appendEvent({ kind: TRACE_EVENT.CHECKPOINT });
|
|
115
|
+
await bundle.close(createManifest({ mode: TRACE_MODE.CHECKPOINTS }));
|
|
116
|
+
|
|
117
|
+
const mode = (await fs.stat(path.join(bundle.root, TRACE_FILES.MANIFEST)))
|
|
118
|
+
.mode;
|
|
119
|
+
assert.strictEqual(mode & 0o777, TRACE_FILE_MODE);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('should store one resource once, however often it is written', async () => {
|
|
123
|
+
const bundle = await open();
|
|
124
|
+
|
|
125
|
+
const first = await bundle.writeArtifact('same bytes', '.txt');
|
|
126
|
+
const second = await bundle.writeArtifact('same bytes', '.txt');
|
|
127
|
+
|
|
128
|
+
assert.strictEqual(first.member, second.member);
|
|
129
|
+
assert.strictEqual(first.deduplicated, false);
|
|
130
|
+
assert.strictEqual(second.deduplicated, true);
|
|
131
|
+
assert.deepStrictEqual(
|
|
132
|
+
(await fs.readdir(path.join(bundle.root, TRACE_FILES.ARTIFACTS_DIR)))
|
|
133
|
+
.length,
|
|
134
|
+
1
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('should drop a member over the per-resource limit and say so', async () => {
|
|
139
|
+
const bundle = await open({ limits: { maxResourceBytes: 16 } });
|
|
140
|
+
|
|
141
|
+
const result = await bundle.writeMember(
|
|
142
|
+
'checkpoints/0001.html',
|
|
143
|
+
'x'.repeat(64)
|
|
144
|
+
);
|
|
145
|
+
await bundle.close(createManifest({ mode: TRACE_MODE.CHECKPOINTS }));
|
|
146
|
+
|
|
147
|
+
assert.strictEqual(result, null);
|
|
148
|
+
assert.strictEqual(bundle.dropped, 1);
|
|
149
|
+
const dropped = await firstDrop(bundle.root);
|
|
150
|
+
assert.strictEqual(dropped.reason, TRACE_DROP_REASON.SIZE_LIMIT);
|
|
151
|
+
assert.strictEqual(dropped.member, 'checkpoints/0001.html');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('should report a partial trace once anything was dropped', async () => {
|
|
155
|
+
const bundle = await open({ limits: { maxResourceBytes: 4 } });
|
|
156
|
+
|
|
157
|
+
await bundle.writeMember('checkpoints/0001.html', 'over the limit');
|
|
158
|
+
const manifest = await bundle.close(
|
|
159
|
+
createManifest({ mode: TRACE_MODE.CHECKPOINTS })
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
assert.strictEqual(manifest.outcome, TRACE_OUTCOME.PARTIAL);
|
|
163
|
+
assert.strictEqual(manifest.dropped, 1);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it('should raise the first problem in strict mode instead of recording it', async () => {
|
|
167
|
+
const bundle = await open({
|
|
168
|
+
strict: true,
|
|
169
|
+
limits: { maxResourceBytes: 4 },
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
await assert.rejects(
|
|
173
|
+
bundle.writeMember('checkpoints/0001.html', 'over the limit'),
|
|
174
|
+
/dropped: size-limit/
|
|
175
|
+
);
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should record a write it could not perform', async () => {
|
|
179
|
+
const bundle = await open();
|
|
180
|
+
// A member whose parent is a file, not a directory, cannot be written.
|
|
181
|
+
await bundle.writeMember('blocked', 'i am a file');
|
|
182
|
+
|
|
183
|
+
const result = await bundle.writeMember('blocked/0001.html', 'content');
|
|
184
|
+
|
|
185
|
+
assert.strictEqual(result, null);
|
|
186
|
+
assert.strictEqual(
|
|
187
|
+
bundle.problems[0].reason,
|
|
188
|
+
TRACE_DROP_REASON.WRITE_FAILED
|
|
189
|
+
);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should write nothing for an empty batch of mutations', async () => {
|
|
193
|
+
const bundle = await open();
|
|
194
|
+
|
|
195
|
+
assert.strictEqual(await bundle.writeMutations(1, []), null);
|
|
196
|
+
assert.strictEqual(await bundle.writeMutations(1, null), null);
|
|
197
|
+
assert.strictEqual(bundle.counts.mutationBatches, 0);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('should count what it wrote in the manifest', async () => {
|
|
201
|
+
const bundle = await open();
|
|
202
|
+
|
|
203
|
+
await bundle.writeCheckpoint({ index: 1, html: '<html></html>' });
|
|
204
|
+
await bundle.writeMutations(1, [{ records: [1] }, { records: [2] }]);
|
|
205
|
+
const manifest = await bundle.close(
|
|
206
|
+
createManifest({ mode: TRACE_MODE.CONTINUOUS })
|
|
207
|
+
);
|
|
208
|
+
|
|
209
|
+
assert.strictEqual(manifest.counts.checkpoints, 1);
|
|
210
|
+
assert.strictEqual(manifest.counts.mutationBatches, 2);
|
|
211
|
+
assert.strictEqual(manifest.outcome, TRACE_OUTCOME.COMPLETE);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it('should record a dropped member even under a tiny resource limit', async () => {
|
|
215
|
+
// The record of a gap must not be the next thing that falls through it.
|
|
216
|
+
const bundle = await open({ limits: { maxResourceBytes: 16 } });
|
|
217
|
+
|
|
218
|
+
await bundle.writeMember('checkpoints/0001.html', 'x'.repeat(64));
|
|
219
|
+
|
|
220
|
+
const dropped = await firstDrop(bundle.root);
|
|
221
|
+
assert.strictEqual(dropped.reason, TRACE_DROP_REASON.SIZE_LIMIT);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should write the timeline in the order the events happened', async () => {
|
|
225
|
+
const bundle = await open();
|
|
226
|
+
|
|
227
|
+
// Observers report what happened without awaiting the record, so the
|
|
228
|
+
// writes overlap; the file must still read as the story in order.
|
|
229
|
+
await Promise.all(
|
|
230
|
+
Array.from({ length: 50 }, (_unused, index) =>
|
|
231
|
+
bundle.appendEvent({ kind: TRACE_EVENT.CONSOLE, index })
|
|
232
|
+
)
|
|
233
|
+
);
|
|
234
|
+
|
|
235
|
+
const written = await timeline(bundle.root);
|
|
236
|
+
assert.deepStrictEqual(
|
|
237
|
+
written.map((event) => event.index),
|
|
238
|
+
Array.from({ length: 50 }, (_unused, index) => index)
|
|
239
|
+
);
|
|
240
|
+
assert.deepStrictEqual(
|
|
241
|
+
written.map((event) => event.sequence),
|
|
242
|
+
Array.from({ length: 50 }, (_unused, index) => index + 1)
|
|
243
|
+
);
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
it('should stop appending once the bundle limit is reached', async () => {
|
|
247
|
+
const bundle = await open({ limits: { maxBundleBytes: 220 } });
|
|
248
|
+
|
|
249
|
+
let accepted = 0;
|
|
250
|
+
for (let index = 0; index < 20; index++) {
|
|
251
|
+
if (await bundle.appendEvent({ kind: TRACE_EVENT.CONSOLE, index })) {
|
|
252
|
+
accepted += 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
assert.ok(accepted > 0, 'the first events still fit');
|
|
257
|
+
assert.ok(accepted < 20, 'the limit stopped the rest');
|
|
258
|
+
assert.ok(bundle.bytesWritten <= 220);
|
|
259
|
+
});
|
|
260
|
+
});
|