browser-commander 0.17.2 → 0.19.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 (74) hide show
  1. package/.jscpd-baseline.json +12 -17
  2. package/CHANGELOG.md +68 -0
  3. package/README.md +132 -0
  4. package/eslint.config.js +5 -0
  5. package/package.json +5 -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 +476 -0
  17. package/src/downloads/naming.js +246 -0
  18. package/src/downloads/sources.js +238 -0
  19. package/src/downloads/staging.js +146 -0
  20. package/src/downloads/store.js +256 -0
  21. package/src/exports.js +86 -0
  22. package/src/factory.js +55 -0
  23. package/src/interactions/click-activation.js +317 -0
  24. package/src/interactions/click-result.js +108 -0
  25. package/src/interactions/click.js +766 -352
  26. package/src/tests/index.js +75 -11
  27. package/src/tests/tracing.js +236 -0
  28. package/src/traces/bundle.js +338 -0
  29. package/src/traces/deadline.js +33 -0
  30. package/src/traces/identity.js +127 -0
  31. package/src/traces/init-script.js +95 -0
  32. package/src/traces/links.js +623 -0
  33. package/src/traces/mutation-stream.js +232 -0
  34. package/src/traces/observers.js +272 -0
  35. package/src/traces/page-capture.js +601 -0
  36. package/src/traces/reader.js +226 -0
  37. package/src/traces/recorder.js +454 -0
  38. package/src/traces/redaction.js +231 -0
  39. package/src/traces/schema.js +229 -0
  40. package/src/traces/viewer.js +448 -0
  41. package/tests/e2e/click-readiness.e2e.test.js +240 -0
  42. package/tests/e2e/downloads.e2e.test.js +235 -0
  43. package/tests/e2e/traces.e2e.test.js +742 -0
  44. package/tests/helpers/click-fixtures.js +62 -0
  45. package/tests/helpers/download-fixtures.js +125 -0
  46. package/tests/helpers/download-server.js +129 -0
  47. package/tests/helpers/e2e-browser.js +67 -0
  48. package/tests/helpers/file-modes.js +31 -0
  49. package/tests/helpers/fixture-server.js +56 -0
  50. package/tests/helpers/mocks.js +63 -66
  51. package/tests/helpers/readiness-server.js +96 -0
  52. package/tests/helpers/temp-directory.js +44 -0
  53. package/tests/helpers/trace-fixtures.js +274 -0
  54. package/tests/helpers/trace-server.js +173 -0
  55. package/tests/unit/core/dialog-manager.test.js +118 -10
  56. package/tests/unit/core/navigation-manager.test.js +162 -0
  57. package/tests/unit/core/readiness.test.js +517 -0
  58. package/tests/unit/downloads/attach.test.js +220 -0
  59. package/tests/unit/downloads/destination.test.js +114 -0
  60. package/tests/unit/downloads/manager.test.js +485 -0
  61. package/tests/unit/downloads/naming.test.js +174 -0
  62. package/tests/unit/downloads/staging.test.js +117 -0
  63. package/tests/unit/downloads/store.test.js +215 -0
  64. package/tests/unit/interactions/click-activation.test.js +283 -0
  65. package/tests/unit/interactions/click-result.test.js +90 -0
  66. package/tests/unit/interactions/click.test.js +409 -200
  67. package/tests/unit/tests/tracing.test.js +299 -0
  68. package/tests/unit/traces/bundle.test.js +260 -0
  69. package/tests/unit/traces/links.test.js +557 -0
  70. package/tests/unit/traces/reader.test.js +208 -0
  71. package/tests/unit/traces/recorder.test.js +730 -0
  72. package/tests/unit/traces/redaction.test.js +170 -0
  73. package/tests/unit/traces/schema.test.js +92 -0
  74. package/tests/unit/traces/viewer.test.js +294 -0
@@ -0,0 +1,96 @@
1
+ /**
2
+ * A tiny static server for the real-browser readiness and click tests.
3
+ *
4
+ * The issue #89 repros need pages that a mock cannot fake: an element far
5
+ * below the fold, a button whose handler does nothing, a link that navigates,
6
+ * and a page whose network never goes quiet. Serving them from localhost keeps
7
+ * the tests self-contained - no external app has to be running.
8
+ */
9
+
10
+ import { sendHtml, sendNotFound, startFixtureHost } from './fixture-server.js';
11
+
12
+ /** A button 4200px down a tall page, matching the issue #89 repro. */
13
+ export const FAR_BELOW_FOLD_PAGE = `<!doctype html>
14
+ <html><body style="margin:0">
15
+ <div style="height:4200px"></div>
16
+ <button id="target" style="height:40px">Far below the fold</button>
17
+ <div style="height:2000px"></div>
18
+ <script>
19
+ window.__clicks = 0;
20
+ document.getElementById('target').addEventListener('click', () => {
21
+ window.__clicks += 1;
22
+ document.getElementById('target').setAttribute('aria-pressed', 'true');
23
+ });
24
+ </script>
25
+ </body></html>`;
26
+
27
+ /** A button in the viewport whose handler does nothing observable. */
28
+ export const NO_OP_PAGE = `<!doctype html>
29
+ <html><body style="margin:0">
30
+ <button id="noop" style="height:40px">Does nothing</button>
31
+ <script>
32
+ window.__clicks = 0;
33
+ document.getElementById('noop').addEventListener('click', () => {
34
+ window.__clicks += 1;
35
+ });
36
+ </script>
37
+ </body></html>`;
38
+
39
+ /** A link that navigates to another page on the same server. */
40
+ export const NAVIGATES_PAGE = `<!doctype html>
41
+ <html><body style="margin:0">
42
+ <a id="go" href="/arrived">Go</a>
43
+ </body></html>`;
44
+
45
+ /** The navigation target. */
46
+ export const ARRIVED_PAGE =
47
+ '<!doctype html><html><body><h1 id="arrived">Arrived</h1></body></html>';
48
+
49
+ /** A page that keeps a request in flight forever, so the network never idles. */
50
+ export const NEVER_IDLE_PAGE = `<!doctype html>
51
+ <html><body>
52
+ <p id="loaded">loaded</p>
53
+ <script>fetch('/hang').catch(() => {});</script>
54
+ </body></html>`;
55
+
56
+ const PAGES = {
57
+ '/far-below-fold': FAR_BELOW_FOLD_PAGE,
58
+ '/no-op': NO_OP_PAGE,
59
+ '/navigates': NAVIGATES_PAGE,
60
+ '/arrived': ARRIVED_PAGE,
61
+ '/never-idle': NEVER_IDLE_PAGE,
62
+ };
63
+
64
+ /**
65
+ * Start the fixture server on an ephemeral port.
66
+ *
67
+ * @returns {Promise<{baseUrl: string, close: Function}>} Server handle
68
+ */
69
+ export async function startFixtureServer() {
70
+ /** Requests parked on purpose, closed when the server shuts down. */
71
+ const hanging = new Set();
72
+
73
+ return startFixtureHost(
74
+ (path, req, res) => {
75
+ if (path === '/hang') {
76
+ hanging.add(res);
77
+ res.on('close', () => hanging.delete(res));
78
+ return;
79
+ }
80
+
81
+ const body = PAGES[path];
82
+ if (body === undefined) {
83
+ sendNotFound(res);
84
+ return;
85
+ }
86
+
87
+ sendHtml(res, body);
88
+ },
89
+ () => {
90
+ for (const res of hanging) {
91
+ res.destroy();
92
+ }
93
+ hanging.clear();
94
+ }
95
+ );
96
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Temporary directories for tests that write real files.
3
+ *
4
+ * Download and trace tests both need a directory of their own per test, and
5
+ * both may leave it read-only on purpose, so the lifecycle lives in one place.
6
+ */
7
+
8
+ import { afterEach, beforeEach } from 'node:test';
9
+ import fs from 'node:fs/promises';
10
+ import os from 'node:os';
11
+ import path from 'node:path';
12
+
13
+ /**
14
+ * Create a temporary directory.
15
+ *
16
+ * @param {string} [prefix] - Name prefix, to tell suites apart under /tmp
17
+ * @returns {Promise<string>} Absolute directory path
18
+ */
19
+ export async function makeTempDirectory(prefix = 'bc-test-') {
20
+ return fs.mkdtemp(path.join(os.tmpdir(), prefix));
21
+ }
22
+
23
+ /**
24
+ * Give each test in a suite its own directory.
25
+ *
26
+ * @param {string} [prefix] - Name prefix for the directories
27
+ * @returns {{path: string}} Holder whose `path` is the current directory
28
+ */
29
+ export function useTempDirectory(prefix) {
30
+ const holder = { path: '' };
31
+
32
+ beforeEach(async () => {
33
+ holder.path = await makeTempDirectory(prefix);
34
+ });
35
+
36
+ afterEach(async () => {
37
+ // A test may have made the directory read-only on purpose; removing it
38
+ // still has to work, or the next test inherits the mess.
39
+ await fs.chmod(holder.path, 0o700).catch(() => {});
40
+ await fs.rm(holder.path, { recursive: true, force: true });
41
+ });
42
+
43
+ return holder;
44
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Fixtures for the trace tests (issue #87).
3
+ *
4
+ * The fake page answers the few calls the recorder makes — evaluate a capture
5
+ * function, take a screenshot, emit an engine event — so the recorder's own
6
+ * behaviour is tested without a browser. The functions that run *inside* a
7
+ * page are proven against real engines in `tests/e2e/traces.e2e.test.js`.
8
+ */
9
+
10
+ import { EventEmitter } from 'node:events';
11
+ import { useTempDirectory } from './temp-directory.js';
12
+
13
+ /**
14
+ * Give a suite its own directory for trace bundles.
15
+ *
16
+ * @returns {{path: string}} Holder whose `path` is the current directory
17
+ */
18
+ export function useTempTraceDirectory() {
19
+ return useTempDirectory('bc-traces-');
20
+ }
21
+
22
+ /**
23
+ * A snapshot shaped like the one the in-page capture returns.
24
+ *
25
+ * @param {Object} [overrides] - Fields to replace
26
+ * @returns {Object} `{html, state, truncated}`
27
+ */
28
+ export function makeSnapshot(overrides = {}) {
29
+ const { state: stateOverrides, ...rest } = overrides;
30
+ return {
31
+ html: '<!DOCTYPE html>\n<html><body><p>captured</p></body></html>',
32
+ truncated: false,
33
+ ...rest,
34
+ state: {
35
+ url: 'https://example.com/report',
36
+ title: 'Report',
37
+ readyState: 'complete',
38
+ scroll: { x: 0, y: 0 },
39
+ viewport: { width: 1280, height: 720 },
40
+ activeElement: null,
41
+ controls: [
42
+ { path: 'form > input', tag: 'input', type: 'text', value: 'alice' },
43
+ ],
44
+ frames: [],
45
+ ...stateOverrides,
46
+ },
47
+ };
48
+ }
49
+
50
+ /**
51
+ * Create a fake page the recorder can drive.
52
+ *
53
+ * @param {Object} [options] - `{snapshot, mutations, screenshot, frames}`
54
+ * @param {Object[]} [options.frames] - Extra frames, each `{mutations}`; the
55
+ * page itself is always the main frame, and a page given no frames has no
56
+ * `frames()` at all, the way a page object from neither engine would
57
+ * @returns {Object} The fake page
58
+ */
59
+ export function createFakePage(options = {}) {
60
+ const emitter = new EventEmitter();
61
+ const state = {
62
+ snapshot: options.snapshot ?? makeSnapshot(),
63
+ mutations: options.mutations ? [...options.mutations] : [],
64
+ droppedMutations: options.droppedMutations ?? 0,
65
+ screenshot: options.screenshot ?? Buffer.from('fake-png'),
66
+ screenshots: 0,
67
+ evaluated: [],
68
+ initScripts: [],
69
+ failures: new Map(),
70
+ };
71
+
72
+ const page = {
73
+ state,
74
+ /**
75
+ * Make one in-page call fail, the way a closed page does.
76
+ *
77
+ * @param {string} name - In-page function name
78
+ * @param {Error|string} failure - What it should throw
79
+ * @returns {void}
80
+ */
81
+ failEvaluate(name, failure) {
82
+ state.failures.set(
83
+ name,
84
+ failure instanceof Error ? failure : new Error(failure)
85
+ );
86
+ },
87
+ /**
88
+ * Replace what the next capture returns.
89
+ *
90
+ * @param {Object} snapshot - A snapshot from `makeSnapshot()`
91
+ * @returns {void}
92
+ */
93
+ setSnapshot(snapshot) {
94
+ state.snapshot = snapshot;
95
+ },
96
+ /**
97
+ * Queue mutation batches for the next drain.
98
+ *
99
+ * @param {Object[]} batches - Ordered batches
100
+ * @returns {void}
101
+ */
102
+ queueMutations(batches) {
103
+ state.mutations.push(...batches);
104
+ },
105
+ evaluate: async (fn, argument) => {
106
+ const name = typeof fn === 'function' ? fn.name : String(fn);
107
+ state.evaluated.push({ name, argument });
108
+ const failure = state.failures.get(name);
109
+ if (failure) {
110
+ throw failure;
111
+ }
112
+ if (name === 'captureSnapshotInPage') {
113
+ return state.snapshot;
114
+ }
115
+ if (name === 'drainMutationsInPage') {
116
+ const batches = state.mutations.splice(0, state.mutations.length);
117
+ const dropped = state.droppedMutations;
118
+ state.droppedMutations = 0;
119
+ return {
120
+ batches: batches.map((batch) => ({
121
+ frameId: 'main',
122
+ mainFrame: true,
123
+ ...batch,
124
+ })),
125
+ dropped,
126
+ installed: true,
127
+ frameId: 'main',
128
+ mainFrame: true,
129
+ };
130
+ }
131
+ return true;
132
+ },
133
+ screenshot: async () => {
134
+ state.screenshots += 1;
135
+ return state.screenshot;
136
+ },
137
+ /**
138
+ * Register a script for every future document, the Playwright spelling.
139
+ *
140
+ * @param {Function} fn - The function to run
141
+ * @param {*} argument - Its argument
142
+ * @returns {Promise<void>} Resolves once registered
143
+ */
144
+ addInitScript: async (fn, argument) => {
145
+ state.initScripts.push({
146
+ name: typeof fn === 'function' ? fn.name : String(fn),
147
+ argument,
148
+ });
149
+ },
150
+ on: (event, listener) => emitter.on(event, listener),
151
+ off: (event, listener) => emitter.off(event, listener),
152
+ emit: (event, payload) => emitter.emit(event, payload),
153
+ listenerCount: (event) => emitter.listenerCount(event),
154
+ };
155
+
156
+ if (options.frames) {
157
+ const children = options.frames.map((frame, index) =>
158
+ createFakeFrame(state, frame, `child-${index + 1}`)
159
+ );
160
+ page.frames = () => [
161
+ createFakeFrame(state, null, 'main', page),
162
+ ...children,
163
+ ];
164
+ }
165
+
166
+ return page;
167
+ }
168
+
169
+ /**
170
+ * One frame of a fake page.
171
+ *
172
+ * The main frame answers out of the page's own state, so a page with frames
173
+ * behaves exactly like one without for everything except the extra documents.
174
+ *
175
+ * @param {Object} state - The page's state
176
+ * @param {Object|null} frame - `{mutations, dropped}` for a child frame
177
+ * @param {string} frameId - What this frame calls itself
178
+ * @param {Object} [page] - The page, when this is the main frame
179
+ * @returns {Object} A frame with `evaluate`
180
+ */
181
+ function createFakeFrame(state, frame, frameId, page) {
182
+ if (page) {
183
+ return { evaluate: page.evaluate };
184
+ }
185
+ const queued = frame?.mutations ? [...frame.mutations] : [];
186
+ return {
187
+ evaluate: async (fn, argument) => {
188
+ const name = typeof fn === 'function' ? fn.name : String(fn);
189
+ state.evaluated.push({ name, argument, frameId });
190
+ if (name === 'drainMutationsInPage') {
191
+ return {
192
+ batches: queued
193
+ .splice(0, queued.length)
194
+ .map((batch) => ({ frameId, mainFrame: false, ...batch })),
195
+ dropped: frame?.dropped ?? 0,
196
+ installed: true,
197
+ frameId,
198
+ mainFrame: false,
199
+ };
200
+ }
201
+ return true;
202
+ },
203
+ };
204
+ }
205
+
206
+ /**
207
+ * Create a fake commander around a fake page.
208
+ *
209
+ * @param {Object} page - The page from `createFakePage()`
210
+ * @param {Object} [extras] - Extra commander properties, such as `downloads`
211
+ * @returns {Object} The fake commander
212
+ */
213
+ export function createFakeCommander(page, extras = {}) {
214
+ return {
215
+ page,
216
+ engine: 'playwright',
217
+ evaluate: (fn, argument) => page.evaluate(fn, argument),
218
+ click: async (selector) => ({ clicked: selector }),
219
+ goto: async (url) => ({ url }),
220
+ typeText: async (selector, text) => ({ selector, text }),
221
+ ...extras,
222
+ };
223
+ }
224
+
225
+ /**
226
+ * A download manager's subscription surface, without a browser.
227
+ *
228
+ * @returns {Object} `{on, off, emit, listenerCount}`
229
+ */
230
+ export function createFakeDownloads() {
231
+ // The manager's subscription surface is an emitter's, so a plain one is the
232
+ // fixture: `on`, `off` and `listenerCount` are the calls the recorder makes.
233
+ return new EventEmitter();
234
+ }
235
+
236
+ /**
237
+ * A dialog manager's observation surface, without a browser.
238
+ *
239
+ * `raise()` plays the manager's own rule back: observers are told, and a
240
+ * dialog nobody answered is dismissed rather than left blocking the page.
241
+ *
242
+ * @returns {Object} `{observeDialogs, unobserveDialogs, observerCount, raise}`
243
+ */
244
+ export function createFakeDialogManager() {
245
+ const observers = [];
246
+
247
+ return {
248
+ observeDialogs: (observer) => observers.push(observer),
249
+ unobserveDialogs: (observer) => {
250
+ const index = observers.indexOf(observer);
251
+ if (index !== -1) {
252
+ observers.splice(index, 1);
253
+ }
254
+ },
255
+ observerCount: () => observers.length,
256
+ raise: async ({ type, message }) => {
257
+ const dialog = {
258
+ dismissed: false,
259
+ type: () => type,
260
+ message: () => message,
261
+ dismiss: async () => {
262
+ dialog.dismissed = true;
263
+ },
264
+ };
265
+ for (const observer of observers) {
266
+ await observer(dialog);
267
+ }
268
+ if (!dialog.dismissed) {
269
+ await dialog.dismiss();
270
+ }
271
+ return dialog;
272
+ },
273
+ };
274
+ }
@@ -0,0 +1,173 @@
1
+ /**
2
+ * A tiny server for the real-browser trace tests (issue #87).
3
+ *
4
+ * The trace format claims to capture things a mock cannot produce: the value a
5
+ * user actually typed, DOM mutations an SPA makes without navigating, a full
6
+ * navigation that replaces the document and the observer with it, a dialog, a
7
+ * request that never reaches a server, and a download that lands on disk.
8
+ */
9
+
10
+ import { sendHtml, sendNotFound, startFixtureHost } from './fixture-server.js';
11
+
12
+ /** Text the download endpoint serves. */
13
+ export const TRACE_DOWNLOAD_BODY = 'traced report body';
14
+
15
+ /** A password the page is given, which must never reach a bundle. */
16
+ export const TRACE_SECRET = 'correct-horse-battery-staple';
17
+
18
+ /** The page the trace tests drive. */
19
+ export const TRACE_PAGE = `<!doctype html>
20
+ <html><head><title>Traced app</title></head>
21
+ <body style="margin:0">
22
+ <form id="signup">
23
+ <input id="name" name="name" type="text" value="">
24
+ <input id="password" name="password" type="password" value="">
25
+ <select id="plan"><option value="free">Free</option><option value="pro">Pro</option></select>
26
+ <label><input id="terms" type="checkbox"> I agree</label>
27
+ </form>
28
+ <ul id="items"><li id="item-a">a</li><li id="item-b">b</li></ul>
29
+ <div id="subtree"><p id="old-child">old</p></div>
30
+ <p id="status" data-state="idle">idle</p>
31
+ <textarea id="notes"></textarea>
32
+ <div id="tall" style="height:3000px"></div>
33
+ <iframe id="inner" name="inner" src="/frame" style="width:80px;height:40px"></iframe>
34
+ <a id="next" href="/next">Go to the next page</a>
35
+ <a id="download" href="/file/report.txt" download>Download the report</a>
36
+ <button id="add" type="button">Add an item</button>
37
+ <button id="insert-first" type="button">Insert before the first item</button>
38
+ <button id="move-last" type="button">Move the last item to the front</button>
39
+ <button id="remove-one" type="button">Remove an item</button>
40
+ <button id="replace-subtree" type="button">Replace a subtree</button>
41
+ <button id="touch" type="button">Change the status</button>
42
+ <button id="log" type="button">Log something</button>
43
+ <button id="boom" type="button">Throw</button>
44
+ <button id="ask" type="button">Ask</button>
45
+ <button id="unreachable" type="button">Call a server that is not there</button>
46
+ <script>
47
+ let added = 0;
48
+ document.getElementById('add').addEventListener('click', () => {
49
+ const item = document.createElement('li');
50
+ item.id = 'item-' + ++added;
51
+ item.textContent = 'item ' + added;
52
+ document.getElementById('items').appendChild(item);
53
+ });
54
+ // Issue #93's third regression case: a replay that only appends gets
55
+ // every one of these wrong.
56
+ const items = () => document.getElementById('items');
57
+ document.getElementById('insert-first').addEventListener('click', () => {
58
+ const item = document.createElement('li');
59
+ item.id = 'inserted';
60
+ item.textContent = 'inserted first';
61
+ items().insertBefore(item, items().firstChild);
62
+ });
63
+ document.getElementById('move-last').addEventListener('click', () => {
64
+ items().insertBefore(items().lastElementChild, items().firstChild);
65
+ });
66
+ document.getElementById('remove-one').addEventListener('click', () => {
67
+ const gone = document.getElementById('item-a');
68
+ if (gone) gone.remove();
69
+ });
70
+ document.getElementById('replace-subtree').addEventListener('click', () => {
71
+ const subtree = document.getElementById('subtree');
72
+ subtree.replaceChildren();
73
+ const fresh = document.createElement('p');
74
+ fresh.id = 'new-child';
75
+ fresh.textContent = 'new';
76
+ subtree.appendChild(fresh);
77
+ });
78
+ document.getElementById('touch').addEventListener('click', () => {
79
+ const status = document.getElementById('status');
80
+ status.setAttribute('data-state', 'touched');
81
+ status.textContent = 'touched';
82
+ });
83
+ document.getElementById('log').addEventListener('click', () => {
84
+ console.log('something happened');
85
+ });
86
+ document.getElementById('boom').addEventListener('click', () => {
87
+ setTimeout(() => {
88
+ throw new Error('page error on purpose');
89
+ }, 0);
90
+ });
91
+ document.getElementById('ask').addEventListener('click', () => {
92
+ window.alert('are you sure?');
93
+ });
94
+ document.getElementById('unreachable').addEventListener('click', () => {
95
+ // Port 1 is never listening, so the request fails in the network stack
96
+ // rather than arriving somewhere and being answered with an error.
97
+ fetch('http://127.0.0.1:1/nope').catch(() => {});
98
+ });
99
+ </script>
100
+ </body></html>`;
101
+
102
+ /**
103
+ * A page that changes its own DOM while it is still loading.
104
+ *
105
+ * Issue #93's first regression case: the interval between a navigation and the
106
+ * next checkpoint used to be unrecorded, because the in-page observer died
107
+ * with the previous document. Everything this page builds happens before any
108
+ * automation could install an observer after the fact.
109
+ */
110
+ export const INIT_PAGE = `<!doctype html>
111
+ <html><head><title>Initializing page</title></head>
112
+ <body style="margin:0">
113
+ <div id="root"></div>
114
+ <script>
115
+ const made = document.createElement('p');
116
+ made.id = 'made-while-loading';
117
+ made.textContent = 'built during initialization';
118
+ document.getElementById('root').appendChild(made);
119
+ document.getElementById('root').setAttribute('data-initialized', 'yes');
120
+ </script>
121
+ </body></html>`;
122
+
123
+ /** The page a full navigation lands on. */
124
+ export const NEXT_PAGE = `<!doctype html>
125
+ <html><head><title>Next page</title></head>
126
+ <body style="margin:0">
127
+ <p id="second">the second document</p>
128
+ <button id="grow" type="button">Grow</button>
129
+ <ul id="more"></ul>
130
+ <script>
131
+ document.getElementById('grow').addEventListener('click', () => {
132
+ const item = document.createElement('li');
133
+ item.id = 'grown';
134
+ item.textContent = 'grown after navigating';
135
+ document.getElementById('more').appendChild(item);
136
+ });
137
+ </script>
138
+ </body></html>`;
139
+
140
+ /**
141
+ * Start the fixture server the trace tests drive.
142
+ *
143
+ * @returns {Promise<{baseUrl: string, close: Function}>} Server handle
144
+ */
145
+ export async function startTraceServer() {
146
+ return startFixtureHost((path, req, res) => {
147
+ if (path === '/' || path === '/app') {
148
+ sendHtml(res, TRACE_PAGE);
149
+ return;
150
+ }
151
+ if (path === '/init') {
152
+ sendHtml(res, INIT_PAGE);
153
+ return;
154
+ }
155
+ if (path === '/next') {
156
+ sendHtml(res, NEXT_PAGE);
157
+ return;
158
+ }
159
+ if (path === '/frame') {
160
+ sendHtml(res, '<!doctype html><p id="framed">inside a frame</p>');
161
+ return;
162
+ }
163
+ if (path.startsWith('/file/')) {
164
+ res.writeHead(200, {
165
+ 'content-type': 'text/plain',
166
+ 'content-disposition': `attachment; filename="${path.slice('/file/'.length)}"`,
167
+ });
168
+ res.end(TRACE_DOWNLOAD_BODY);
169
+ return;
170
+ }
171
+ sendNotFound(res);
172
+ });
173
+ }