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,457 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real-browser tests for issue #87.
|
|
3
|
+
*
|
|
4
|
+
* A trace claims to hold the state a run was actually in: the value a user
|
|
5
|
+
* typed rather than the one the server sent, the DOM changes an app made
|
|
6
|
+
* between two moments, and every navigation, interaction, console message,
|
|
7
|
+
* error, failed request, dialog and download in one order. None of that can
|
|
8
|
+
* be proven against a fake page, so these drive both engines and finish by
|
|
9
|
+
* opening the offline viewer in the same browser.
|
|
10
|
+
*
|
|
11
|
+
* Run with: npm run test:e2e:traces
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { describe, it, before, beforeEach, after } from 'node:test';
|
|
15
|
+
import assert from 'node:assert';
|
|
16
|
+
import fs from 'node:fs/promises';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import { makeBrowserCommander } from '../../src/factory.js';
|
|
21
|
+
import { readTrace } from '../../src/traces/reader.js';
|
|
22
|
+
import { startTrace } from '../../src/traces/recorder.js';
|
|
23
|
+
import {
|
|
24
|
+
TRACE_DROP_REASON,
|
|
25
|
+
TRACE_EVENT,
|
|
26
|
+
TRACE_FILES,
|
|
27
|
+
TRACE_MODE,
|
|
28
|
+
TRACE_OUTCOME,
|
|
29
|
+
} from '../../src/traces/schema.js';
|
|
30
|
+
import { writeTraceViewer } from '../../src/traces/viewer.js';
|
|
31
|
+
import { launchE2EBrowser } from '../helpers/e2e-browser.js';
|
|
32
|
+
import {
|
|
33
|
+
startTraceServer,
|
|
34
|
+
TRACE_DOWNLOAD_BODY,
|
|
35
|
+
TRACE_SECRET,
|
|
36
|
+
} from '../helpers/trace-server.js';
|
|
37
|
+
|
|
38
|
+
const ENGINES = ['playwright', 'puppeteer'];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Run the trace expectations against one engine.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} engine - 'playwright' or 'puppeteer'
|
|
44
|
+
* @returns {void}
|
|
45
|
+
*/
|
|
46
|
+
function describeTraces(engine) {
|
|
47
|
+
describe(`E2E - portable traces with ${engine} (issue #87)`, () => {
|
|
48
|
+
let server;
|
|
49
|
+
let browser;
|
|
50
|
+
let page;
|
|
51
|
+
let downloads;
|
|
52
|
+
let commander;
|
|
53
|
+
let artifacts;
|
|
54
|
+
let downloadDirectory;
|
|
55
|
+
let cleanup;
|
|
56
|
+
let runs = 0;
|
|
57
|
+
|
|
58
|
+
before(async () => {
|
|
59
|
+
server = await startTraceServer();
|
|
60
|
+
artifacts = await fs.mkdtemp(path.join(os.tmpdir(), 'bc-e2e-traces-'));
|
|
61
|
+
downloadDirectory = await fs.mkdtemp(
|
|
62
|
+
path.join(os.tmpdir(), 'bc-e2e-files-')
|
|
63
|
+
);
|
|
64
|
+
({ browser, page, downloads, cleanup } = await launchE2EBrowser({
|
|
65
|
+
engine,
|
|
66
|
+
downloadDirectory,
|
|
67
|
+
}));
|
|
68
|
+
commander = makeBrowserCommander({ page, downloads });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
beforeEach(async () => {
|
|
72
|
+
await page.goto(`${server.baseUrl}/`);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
after(async () => {
|
|
76
|
+
await commander?.destroy();
|
|
77
|
+
await cleanup?.();
|
|
78
|
+
await server?.close();
|
|
79
|
+
for (const directory of [artifacts, downloadDirectory]) {
|
|
80
|
+
await fs.rm(directory, { recursive: true, force: true });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Start a trace into this run's own bundle directory.
|
|
86
|
+
*
|
|
87
|
+
* @param {Object} [options] - Options for `commander.startTrace()`
|
|
88
|
+
* @returns {Promise<Object>} The running trace
|
|
89
|
+
*/
|
|
90
|
+
const record = (options = {}) =>
|
|
91
|
+
commander.startTrace({
|
|
92
|
+
output: path.join(artifacts, `run-${++runs}`),
|
|
93
|
+
screenshots: false,
|
|
94
|
+
...options,
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Wait until the page is somewhere.
|
|
99
|
+
*
|
|
100
|
+
* @param {RegExp} pattern - What the URL must match
|
|
101
|
+
* @returns {Promise<void>} Resolves once it does
|
|
102
|
+
*/
|
|
103
|
+
const waitForUrl = async (pattern) => {
|
|
104
|
+
const deadline = Date.now() + 20000;
|
|
105
|
+
while (!pattern.test(page.url())) {
|
|
106
|
+
if (Date.now() > deadline) {
|
|
107
|
+
throw new Error(`still at ${page.url()}, expected ${pattern}`);
|
|
108
|
+
}
|
|
109
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
110
|
+
}
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Drive the fixture through one app update, between two checkpoints.
|
|
115
|
+
*
|
|
116
|
+
* Two items are added and an attribute changes, so the batches recorded
|
|
117
|
+
* between the checkpoints hold both kinds of DOM change the format
|
|
118
|
+
* promises to preserve.
|
|
119
|
+
*
|
|
120
|
+
* @param {Object} trace - A running trace in continuous mode
|
|
121
|
+
* @returns {Promise<void>} Resolves once the second checkpoint is written
|
|
122
|
+
*/
|
|
123
|
+
const recordAnAppUpdate = async (trace) => {
|
|
124
|
+
await trace.checkpoint('loaded');
|
|
125
|
+
await page.click('#add');
|
|
126
|
+
await page.click('#add');
|
|
127
|
+
await page.click('#touch');
|
|
128
|
+
await trace.checkpoint('the app updated itself');
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Wait until the timeline holds every kind of event named.
|
|
133
|
+
*
|
|
134
|
+
* The observers write as things happen, so asking right after a click is
|
|
135
|
+
* asking too early; this waits for the record rather than for a guess.
|
|
136
|
+
*
|
|
137
|
+
* @param {Object} trace - A running trace
|
|
138
|
+
* @param {string[]} kinds - Event kinds that must be present
|
|
139
|
+
* @returns {Promise<Object[]>} The timeline
|
|
140
|
+
*/
|
|
141
|
+
const waitForEvents = async (trace, kinds) => {
|
|
142
|
+
const deadline = Date.now() + 20000;
|
|
143
|
+
let seen = new Set();
|
|
144
|
+
for (;;) {
|
|
145
|
+
const { events } = await readTrace(trace.path);
|
|
146
|
+
seen = new Set(events.map((event) => event.kind));
|
|
147
|
+
if (kinds.every((kind) => seen.has(kind))) {
|
|
148
|
+
return events;
|
|
149
|
+
}
|
|
150
|
+
if (Date.now() > deadline) {
|
|
151
|
+
throw new Error(
|
|
152
|
+
`timed out waiting for ${kinds.join(', ')}; saw ${[...seen].join(', ')}`
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
156
|
+
}
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Choose an option, the same way in either engine.
|
|
161
|
+
*
|
|
162
|
+
* @param {string} selector - The select element
|
|
163
|
+
* @param {string} value - Option value to choose
|
|
164
|
+
* @returns {Promise<void>} Resolves once chosen
|
|
165
|
+
*/
|
|
166
|
+
const choose = (selector, value) =>
|
|
167
|
+
commander.evaluate(
|
|
168
|
+
(options) => {
|
|
169
|
+
const element = document.querySelector(options.selector);
|
|
170
|
+
element.value = options.value;
|
|
171
|
+
element.dispatchEvent(new Event('change', { bubbles: true }));
|
|
172
|
+
},
|
|
173
|
+
{ selector, value }
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* The control state one checkpoint captured, by CSS path.
|
|
178
|
+
*
|
|
179
|
+
* @param {Object} state - A checkpoint's state
|
|
180
|
+
* @returns {Map<string, Object>} Controls, keyed by path
|
|
181
|
+
*/
|
|
182
|
+
const controlsOf = (state) =>
|
|
183
|
+
new Map(state.controls.map((control) => [control.path, control]));
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Every record in a checkpoint's mutation batches.
|
|
187
|
+
*
|
|
188
|
+
* @param {Object[]} batches - Batches from `reader.mutations()`
|
|
189
|
+
* @returns {Object[]} The records, in order
|
|
190
|
+
*/
|
|
191
|
+
const changesIn = (batches) => batches.flatMap((batch) => batch.records);
|
|
192
|
+
|
|
193
|
+
it('should capture the state a user made, not the state the server sent', async () => {
|
|
194
|
+
const trace = await record({ screenshots: 'checkpoints' });
|
|
195
|
+
|
|
196
|
+
await page.type('#name', 'ada');
|
|
197
|
+
await page.click('#terms');
|
|
198
|
+
await choose('#plan', 'pro');
|
|
199
|
+
await trace.checkpoint('the form is filled in');
|
|
200
|
+
const stopped = await trace.stop();
|
|
201
|
+
|
|
202
|
+
const reader = await readTrace(stopped.path);
|
|
203
|
+
const controls = controlsOf(await reader.state(1));
|
|
204
|
+
assert.strictEqual(controls.get('#name').value, 'ada');
|
|
205
|
+
assert.strictEqual(controls.get('#terms').checked, true);
|
|
206
|
+
assert.deepStrictEqual(controls.get('#plan').selected, ['pro']);
|
|
207
|
+
const state = await reader.state(1);
|
|
208
|
+
assert.strictEqual(state.title, 'Traced app');
|
|
209
|
+
assert.strictEqual(state.url, `${server.baseUrl}/`);
|
|
210
|
+
// The frame is part of the moment too, and so is a picture of it.
|
|
211
|
+
assert.deepStrictEqual(
|
|
212
|
+
state.frames.map((frame) => frame.name),
|
|
213
|
+
['inner']
|
|
214
|
+
);
|
|
215
|
+
// The serialized markup carries the typed value, which `outerHTML`
|
|
216
|
+
// alone would not: a replay of this checkpoint shows a filled-in form.
|
|
217
|
+
assert.match(await reader.html(1), /id="name"[^>]*value="ada"/);
|
|
218
|
+
const shot = await fs.readFile(
|
|
219
|
+
path.join(stopped.path, TRACE_FILES.CHECKPOINTS_DIR, '0001.png')
|
|
220
|
+
);
|
|
221
|
+
assert.strictEqual(shot.subarray(1, 4).toString(), 'PNG');
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('should keep ordered mutations across an app update and a navigation', async () => {
|
|
225
|
+
const trace = await record({ mode: TRACE_MODE.CONTINUOUS });
|
|
226
|
+
|
|
227
|
+
await recordAnAppUpdate(trace);
|
|
228
|
+
await page.click('#next');
|
|
229
|
+
await waitForUrl(/\/next$/);
|
|
230
|
+
await trace.checkpoint('the next document');
|
|
231
|
+
await page.click('#grow');
|
|
232
|
+
const stopped = await trace.stop();
|
|
233
|
+
|
|
234
|
+
const reader = await readTrace(stopped.path);
|
|
235
|
+
const batches = await reader.mutations(1);
|
|
236
|
+
const added = changesIn(batches)
|
|
237
|
+
.flatMap((change) => change.added ?? [])
|
|
238
|
+
.filter((node) => node?.id);
|
|
239
|
+
assert.deepStrictEqual(
|
|
240
|
+
added.map((node) => node.id),
|
|
241
|
+
['item-1', 'item-2']
|
|
242
|
+
);
|
|
243
|
+
const attribute = changesIn(batches).find(
|
|
244
|
+
(change) => change.kind === 'attributes'
|
|
245
|
+
);
|
|
246
|
+
assert.strictEqual(attribute.attribute, 'data-state');
|
|
247
|
+
assert.strictEqual(attribute.before, 'idle');
|
|
248
|
+
assert.strictEqual(attribute.after, 'touched');
|
|
249
|
+
assert.deepStrictEqual(
|
|
250
|
+
batches.map((batch) => batch.sequence),
|
|
251
|
+
batches.map((_batch, index) => index + 1)
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
// The navigation threw the in-page recorder away with the document it
|
|
255
|
+
// lived in; a batch after it proves a new one took its place.
|
|
256
|
+
const afterNavigating = await reader.mutations(3);
|
|
257
|
+
assert.ok(
|
|
258
|
+
changesIn(afterNavigating)
|
|
259
|
+
.flatMap((change) => change.added ?? [])
|
|
260
|
+
.some((node) => node?.id === 'grown'),
|
|
261
|
+
`expected the grown item, got ${JSON.stringify(afterNavigating)}`
|
|
262
|
+
);
|
|
263
|
+
assert.match((await reader.state(3)).url, /\/next$/);
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('should put everything that happened on one ordered timeline', async () => {
|
|
267
|
+
const trace = await record();
|
|
268
|
+
|
|
269
|
+
// One interaction goes through the commander, because an interaction
|
|
270
|
+
// event is a record of what Browser Commander was asked to do. The
|
|
271
|
+
// rest drive the page directly: they are about what the page did.
|
|
272
|
+
//
|
|
273
|
+
// That one interaction costs about 30 seconds, and the cost is the
|
|
274
|
+
// factory's own configuration rather than anything this trace does:
|
|
275
|
+
// the `page.goto` above put the navigation manager into its external
|
|
276
|
+
// navigation wait, and a commander action joins that wait, which asks
|
|
277
|
+
// the network tracker for idle with the factory's `idleTimeout` of
|
|
278
|
+
// 30000ms - the window the network must stay quiet for. Changing that
|
|
279
|
+
// number would change what every caller's readiness means, so it is
|
|
280
|
+
// left alone and paid for here, once.
|
|
281
|
+
await commander.click({
|
|
282
|
+
selector: '#log',
|
|
283
|
+
waitForNavigation: false,
|
|
284
|
+
waitAfterClick: 0,
|
|
285
|
+
});
|
|
286
|
+
await page.click('#boom');
|
|
287
|
+
await page.click('#unreachable');
|
|
288
|
+
await page.click('#ask');
|
|
289
|
+
const artifact = await downloads.capture({
|
|
290
|
+
action: () => page.click('#download'),
|
|
291
|
+
timeout: 20000,
|
|
292
|
+
});
|
|
293
|
+
await page.goto(`${server.baseUrl}/next`);
|
|
294
|
+
const events = await waitForEvents(trace, [
|
|
295
|
+
TRACE_EVENT.INTERACTION,
|
|
296
|
+
TRACE_EVENT.CONSOLE,
|
|
297
|
+
TRACE_EVENT.PAGE_ERROR,
|
|
298
|
+
TRACE_EVENT.REQUEST_FAILED,
|
|
299
|
+
TRACE_EVENT.DIALOG,
|
|
300
|
+
TRACE_EVENT.DOWNLOAD,
|
|
301
|
+
TRACE_EVENT.NAVIGATION,
|
|
302
|
+
]);
|
|
303
|
+
await trace.stop();
|
|
304
|
+
|
|
305
|
+
assert.deepStrictEqual(
|
|
306
|
+
events.map((event) => event.sequence),
|
|
307
|
+
events.map((_event, index) => index + 1)
|
|
308
|
+
);
|
|
309
|
+
const byKind = (kind) => events.filter((event) => event.kind === kind);
|
|
310
|
+
assert.strictEqual(byKind(TRACE_EVENT.INTERACTION)[0].target, '#log');
|
|
311
|
+
assert.match(byKind(TRACE_EVENT.CONSOLE)[0].text, /something happened/);
|
|
312
|
+
assert.match(
|
|
313
|
+
byKind(TRACE_EVENT.PAGE_ERROR)[0].message,
|
|
314
|
+
/page error on purpose/
|
|
315
|
+
);
|
|
316
|
+
assert.match(
|
|
317
|
+
byKind(TRACE_EVENT.REQUEST_FAILED)[0].url,
|
|
318
|
+
/127\.0\.0\.1:1\/nope/
|
|
319
|
+
);
|
|
320
|
+
assert.strictEqual(
|
|
321
|
+
byKind(TRACE_EVENT.DIALOG)[0].message,
|
|
322
|
+
'are you sure?'
|
|
323
|
+
);
|
|
324
|
+
// The download is on the timeline by reference: the bytes stay where
|
|
325
|
+
// the download manager put them, and the trace says where that is.
|
|
326
|
+
const completed = byKind(TRACE_EVENT.DOWNLOAD).find(
|
|
327
|
+
(event) => event.phase === 'completed'
|
|
328
|
+
);
|
|
329
|
+
assert.strictEqual(completed.path, artifact.path);
|
|
330
|
+
assert.strictEqual(completed.checksum, artifact.checksum);
|
|
331
|
+
assert.ok(!Object.hasOwn(completed, 'contents'));
|
|
332
|
+
assert.strictEqual(
|
|
333
|
+
await fs.readFile(completed.path, 'utf8'),
|
|
334
|
+
TRACE_DOWNLOAD_BODY
|
|
335
|
+
);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it('should keep a secret the page held out of every member', async () => {
|
|
339
|
+
// No privacy options: a password is a secret by default, because a
|
|
340
|
+
// trace nobody configured is the one most likely to be shared.
|
|
341
|
+
const trace = await record({ screenshots: 'checkpoints' });
|
|
342
|
+
|
|
343
|
+
await commander.fill({ selector: '#password', text: TRACE_SECRET });
|
|
344
|
+
await trace.checkpoint('after typing the password');
|
|
345
|
+
const stopped = await trace.stop();
|
|
346
|
+
await writeTraceViewer(stopped.path);
|
|
347
|
+
|
|
348
|
+
let checked = 0;
|
|
349
|
+
for (const entry of await fs.readdir(stopped.path, {
|
|
350
|
+
recursive: true,
|
|
351
|
+
withFileTypes: true,
|
|
352
|
+
})) {
|
|
353
|
+
if (!entry.isFile()) {
|
|
354
|
+
continue;
|
|
355
|
+
}
|
|
356
|
+
const body = await fs.readFile(
|
|
357
|
+
path.join(entry.parentPath ?? entry.path, entry.name),
|
|
358
|
+
'latin1'
|
|
359
|
+
);
|
|
360
|
+
assert.ok(
|
|
361
|
+
!body.includes(TRACE_SECRET),
|
|
362
|
+
`${entry.name} carries the secret`
|
|
363
|
+
);
|
|
364
|
+
checked += 1;
|
|
365
|
+
}
|
|
366
|
+
assert.ok(checked >= 4, `expected a full bundle, checked ${checked}`);
|
|
367
|
+
const controls = controlsOf(
|
|
368
|
+
await (await readTrace(stopped.path)).state(1)
|
|
369
|
+
);
|
|
370
|
+
assert.strictEqual(controls.get('#password').redacted, true);
|
|
371
|
+
assert.strictEqual(controls.get('#password').value, '[redacted]');
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
it('should leave a readable trace when the page closes mid-run', async () => {
|
|
375
|
+
const second = await browser.newPage();
|
|
376
|
+
await second.goto(`${server.baseUrl}/`);
|
|
377
|
+
// No commander: a consumer can trace any page, and this one is going to
|
|
378
|
+
// be taken away while the recorder is still running.
|
|
379
|
+
const trace = await startTrace({
|
|
380
|
+
page: second,
|
|
381
|
+
output: path.join(artifacts, `closed-${++runs}`),
|
|
382
|
+
screenshots: false,
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await trace.checkpoint('still open');
|
|
386
|
+
await second.close();
|
|
387
|
+
await trace.checkpoint('after the page went away');
|
|
388
|
+
const stopped = await trace.stop();
|
|
389
|
+
|
|
390
|
+
const reader = await readTrace(stopped.path);
|
|
391
|
+
assert.strictEqual(reader.manifest.outcome, TRACE_OUTCOME.PARTIAL);
|
|
392
|
+
// The first checkpoint survives the second one failing: a partial trace
|
|
393
|
+
// is readable, not a corrupted run.
|
|
394
|
+
assert.match(await reader.html(1), /Traced app/);
|
|
395
|
+
const dropped = reader.events.find(
|
|
396
|
+
(event) => event.kind === TRACE_EVENT.DROPPED
|
|
397
|
+
);
|
|
398
|
+
assert.ok(
|
|
399
|
+
[
|
|
400
|
+
TRACE_DROP_REASON.PAGE_CLOSED,
|
|
401
|
+
TRACE_DROP_REASON.CAPTURE_FAILED,
|
|
402
|
+
].includes(dropped.reason),
|
|
403
|
+
`unexpected reason ${dropped.reason}`
|
|
404
|
+
);
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
it('should leave a viewer that opens with no network and replays the run', async () => {
|
|
408
|
+
const trace = await record({ mode: TRACE_MODE.CONTINUOUS });
|
|
409
|
+
await recordAnAppUpdate(trace);
|
|
410
|
+
const stopped = await trace.stop();
|
|
411
|
+
|
|
412
|
+
const viewer = await writeTraceViewer(stopped.path);
|
|
413
|
+
assert.strictEqual(viewer, path.join(stopped.path, TRACE_FILES.VIEWER));
|
|
414
|
+
const requested = [];
|
|
415
|
+
const onRequest = (request) => requested.push(request.url());
|
|
416
|
+
page.on('request', onRequest);
|
|
417
|
+
await page.goto(`file://${viewer}`);
|
|
418
|
+
|
|
419
|
+
const shown = await page.evaluate(() => ({
|
|
420
|
+
events: document.querySelectorAll('#timeline li').length,
|
|
421
|
+
step: document.getElementById('step').textContent,
|
|
422
|
+
details: document.getElementById('details').textContent,
|
|
423
|
+
framed: document.getElementById('stage').getAttribute('srcdoc'),
|
|
424
|
+
}));
|
|
425
|
+
assert.ok(shown.events > 0, 'the timeline is empty');
|
|
426
|
+
assert.match(shown.details, /Traced app/);
|
|
427
|
+
assert.match(shown.step, /[1-9]\d* mutation batches/);
|
|
428
|
+
assert.match(shown.framed, /id="items"/);
|
|
429
|
+
|
|
430
|
+
// Stepping replays the recorded mutations into the captured document.
|
|
431
|
+
await page.click('#step-forward');
|
|
432
|
+
await page.click('#step-forward');
|
|
433
|
+
const replayed = await page.evaluate(() => ({
|
|
434
|
+
step: document.getElementById('step').textContent,
|
|
435
|
+
framed: document.getElementById('stage').getAttribute('srcdoc'),
|
|
436
|
+
}));
|
|
437
|
+
assert.match(replayed.step, /batch \d+ of \d+/);
|
|
438
|
+
assert.match(replayed.framed, /id="item-1"|data-state="touched"/);
|
|
439
|
+
page.off('request', onRequest);
|
|
440
|
+
// Opening a colleague's trace must not call anyone's server.
|
|
441
|
+
assert.deepStrictEqual(
|
|
442
|
+
requested.filter((url) => /^https?:/.test(url)),
|
|
443
|
+
[]
|
|
444
|
+
);
|
|
445
|
+
});
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
describe(
|
|
450
|
+
'E2E - privacy-aware portable traces (issue #87)',
|
|
451
|
+
{ skip: !process.env.RUN_E2E, timeout: 600000 },
|
|
452
|
+
() => {
|
|
453
|
+
for (const engine of ENGINES) {
|
|
454
|
+
describeTraces(engine);
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
);
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared fixtures for the click tests.
|
|
3
|
+
*
|
|
4
|
+
* The activation tests and the `clickElement` tests exercise the same two
|
|
5
|
+
* shapes - an element state probe result and a click-point probe result - so
|
|
6
|
+
* they live here instead of drifting apart in two files.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** State of a plain, enabled button that nothing has changed. */
|
|
10
|
+
export const UNCHANGED_STATE = Object.freeze({
|
|
11
|
+
disabled: false,
|
|
12
|
+
ariaPressed: 'false',
|
|
13
|
+
ariaExpanded: null,
|
|
14
|
+
ariaSelected: null,
|
|
15
|
+
checked: false,
|
|
16
|
+
className: 'btn',
|
|
17
|
+
isConnected: true,
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
/** Click-point probe result for an element fully inside the viewport. */
|
|
21
|
+
export const IN_VIEWPORT_POINT = Object.freeze({
|
|
22
|
+
x: 50,
|
|
23
|
+
y: 60,
|
|
24
|
+
width: 100,
|
|
25
|
+
height: 40,
|
|
26
|
+
top: 40,
|
|
27
|
+
left: 0,
|
|
28
|
+
viewport: { width: 800, height: 600 },
|
|
29
|
+
scroll: { x: 0, y: 0 },
|
|
30
|
+
inViewport: true,
|
|
31
|
+
hitsTarget: true,
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* A mutable window scroll position with the `evaluateOnPage` contract the
|
|
36
|
+
* click code uses: an empty argument list reads the position, a one-element
|
|
37
|
+
* list writes it. The list is what the real adapter takes - passing a bare
|
|
38
|
+
* object made Playwright try to spread it.
|
|
39
|
+
*
|
|
40
|
+
* @param {number} [initial] - Initial vertical scroll offset
|
|
41
|
+
* @returns {{current: Function, set: Function, evaluateOnPage: Function}} Scroll model
|
|
42
|
+
*/
|
|
43
|
+
export function createScrollModel(initial = 0) {
|
|
44
|
+
let scrollY = initial;
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
current: () => scrollY,
|
|
48
|
+
set: (next) => {
|
|
49
|
+
scrollY = next;
|
|
50
|
+
},
|
|
51
|
+
evaluateOnPage: async (fn, args = []) => {
|
|
52
|
+
if (!Array.isArray(args)) {
|
|
53
|
+
throw new TypeError('evaluateOnPage takes an argument list');
|
|
54
|
+
}
|
|
55
|
+
if (args.length > 0) {
|
|
56
|
+
scrollY = args[0].y;
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
return { x: 0, y: scrollY };
|
|
60
|
+
},
|
|
61
|
+
};
|
|
62
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixtures for the managed download tests (issue #88).
|
|
3
|
+
*
|
|
4
|
+
* The fake context speaks exactly the part of Playwright's download API the
|
|
5
|
+
* manager uses, so the tests exercise the real event plumbing without needing
|
|
6
|
+
* a browser.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { EventEmitter } from 'node:events';
|
|
10
|
+
import { setImmediate as yieldToEngine } from 'node:timers/promises';
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import {
|
|
14
|
+
makeTempDirectory as makeDirectory,
|
|
15
|
+
useTempDirectory,
|
|
16
|
+
} from './temp-directory.js';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Create a temporary directory that a test can write downloads into.
|
|
20
|
+
*
|
|
21
|
+
* @returns {Promise<string>} Absolute directory path
|
|
22
|
+
*/
|
|
23
|
+
export async function makeTempDirectory() {
|
|
24
|
+
return makeDirectory('bc-downloads-');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Build a fake Playwright download whose bytes are already on disk.
|
|
29
|
+
*
|
|
30
|
+
* @param {Object} options - Download options
|
|
31
|
+
* @param {string} options.dir - Directory to stage the bytes in
|
|
32
|
+
* @param {string} options.suggestedFilename - Name the page suggested
|
|
33
|
+
* @param {string|Buffer} [options.body] - File contents
|
|
34
|
+
* @param {string} [options.url] - Source URL
|
|
35
|
+
* @param {string} [options.failure] - Engine failure text, when it failed
|
|
36
|
+
* @returns {Promise<Object>} Fake download object
|
|
37
|
+
*/
|
|
38
|
+
export async function makeFakeDownload({
|
|
39
|
+
dir,
|
|
40
|
+
suggestedFilename,
|
|
41
|
+
body = 'hello',
|
|
42
|
+
url = 'https://example.com/file',
|
|
43
|
+
failure = null,
|
|
44
|
+
}) {
|
|
45
|
+
const staged = path.join(
|
|
46
|
+
dir,
|
|
47
|
+
`staged-${Math.random().toString(36).slice(2)}`
|
|
48
|
+
);
|
|
49
|
+
if (!failure) {
|
|
50
|
+
await fs.writeFile(staged, body);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return {
|
|
54
|
+
url: () => url,
|
|
55
|
+
suggestedFilename: () => suggestedFilename,
|
|
56
|
+
failure: async () => failure,
|
|
57
|
+
path: async () => staged,
|
|
58
|
+
stagedPath: staged,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Expose an emitter the way an engine object does.
|
|
64
|
+
*
|
|
65
|
+
* @param {Object} emitter - Backing event emitter
|
|
66
|
+
* @returns {{on: Function, off: Function}} Subscription methods
|
|
67
|
+
*/
|
|
68
|
+
function subscriptionsOf(emitter) {
|
|
69
|
+
return {
|
|
70
|
+
on: (event, listener) => emitter.on(event, listener),
|
|
71
|
+
off: (event, listener) => emitter.off(event, listener),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Create a fake browser context that emits download events.
|
|
77
|
+
*
|
|
78
|
+
* @returns {Object} Context with `on`, `off` and `emitDownload`
|
|
79
|
+
*/
|
|
80
|
+
export function createFakeContext() {
|
|
81
|
+
const emitter = new EventEmitter();
|
|
82
|
+
return {
|
|
83
|
+
...subscriptionsOf(emitter),
|
|
84
|
+
/**
|
|
85
|
+
* Emit a download and wait for the manager to finish handling it.
|
|
86
|
+
*
|
|
87
|
+
* @param {Object} download - Fake download object
|
|
88
|
+
* @returns {Promise<void>}
|
|
89
|
+
*/
|
|
90
|
+
emitDownload: async (download) => {
|
|
91
|
+
emitter.emit('download', download);
|
|
92
|
+
// The manager's handler is async; yielding twice lets it run to the
|
|
93
|
+
// point where the artifact is saved and published.
|
|
94
|
+
await yieldToEngine();
|
|
95
|
+
await yieldToEngine();
|
|
96
|
+
},
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Create a fake CDP session that speaks the Browser download domain.
|
|
102
|
+
*
|
|
103
|
+
* @returns {Object} Session with `send`, `on`, `off` and `emit`
|
|
104
|
+
*/
|
|
105
|
+
export function createFakeCdpSession() {
|
|
106
|
+
const emitter = new EventEmitter();
|
|
107
|
+
const sent = [];
|
|
108
|
+
return {
|
|
109
|
+
...subscriptionsOf(emitter),
|
|
110
|
+
sent,
|
|
111
|
+
send: async (method, params) => {
|
|
112
|
+
sent.push({ method, params });
|
|
113
|
+
},
|
|
114
|
+
emit: (event, payload) => emitter.emit(event, payload),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Give a suite its own download directory, created and removed per test.
|
|
120
|
+
*
|
|
121
|
+
* @returns {{path: string}} Holder whose `path` is the current directory
|
|
122
|
+
*/
|
|
123
|
+
export function useTempDownloadDirectory() {
|
|
124
|
+
return useTempDirectory('bc-downloads-');
|
|
125
|
+
}
|