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,198 @@
|
|
|
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}`
|
|
54
|
+
* @returns {Object} The fake page
|
|
55
|
+
*/
|
|
56
|
+
export function createFakePage(options = {}) {
|
|
57
|
+
const emitter = new EventEmitter();
|
|
58
|
+
const state = {
|
|
59
|
+
snapshot: options.snapshot ?? makeSnapshot(),
|
|
60
|
+
mutations: options.mutations ? [...options.mutations] : [],
|
|
61
|
+
droppedMutations: options.droppedMutations ?? 0,
|
|
62
|
+
screenshot: options.screenshot ?? Buffer.from('fake-png'),
|
|
63
|
+
screenshots: 0,
|
|
64
|
+
evaluated: [],
|
|
65
|
+
failures: new Map(),
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
return {
|
|
69
|
+
state,
|
|
70
|
+
/**
|
|
71
|
+
* Make one in-page call fail, the way a closed page does.
|
|
72
|
+
*
|
|
73
|
+
* @param {string} name - In-page function name
|
|
74
|
+
* @param {Error|string} failure - What it should throw
|
|
75
|
+
* @returns {void}
|
|
76
|
+
*/
|
|
77
|
+
failEvaluate(name, failure) {
|
|
78
|
+
state.failures.set(
|
|
79
|
+
name,
|
|
80
|
+
failure instanceof Error ? failure : new Error(failure)
|
|
81
|
+
);
|
|
82
|
+
},
|
|
83
|
+
/**
|
|
84
|
+
* Replace what the next capture returns.
|
|
85
|
+
*
|
|
86
|
+
* @param {Object} snapshot - A snapshot from `makeSnapshot()`
|
|
87
|
+
* @returns {void}
|
|
88
|
+
*/
|
|
89
|
+
setSnapshot(snapshot) {
|
|
90
|
+
state.snapshot = snapshot;
|
|
91
|
+
},
|
|
92
|
+
/**
|
|
93
|
+
* Queue mutation batches for the next drain.
|
|
94
|
+
*
|
|
95
|
+
* @param {Object[]} batches - Ordered batches
|
|
96
|
+
* @returns {void}
|
|
97
|
+
*/
|
|
98
|
+
queueMutations(batches) {
|
|
99
|
+
state.mutations.push(...batches);
|
|
100
|
+
},
|
|
101
|
+
evaluate: async (fn, argument) => {
|
|
102
|
+
const name = typeof fn === 'function' ? fn.name : String(fn);
|
|
103
|
+
state.evaluated.push({ name, argument });
|
|
104
|
+
const failure = state.failures.get(name);
|
|
105
|
+
if (failure) {
|
|
106
|
+
throw failure;
|
|
107
|
+
}
|
|
108
|
+
if (name === 'captureSnapshotInPage') {
|
|
109
|
+
return state.snapshot;
|
|
110
|
+
}
|
|
111
|
+
if (name === 'drainMutationsInPage') {
|
|
112
|
+
const batches = state.mutations.splice(0, state.mutations.length);
|
|
113
|
+
const dropped = state.droppedMutations;
|
|
114
|
+
state.droppedMutations = 0;
|
|
115
|
+
return { batches, dropped, installed: true };
|
|
116
|
+
}
|
|
117
|
+
return true;
|
|
118
|
+
},
|
|
119
|
+
screenshot: async () => {
|
|
120
|
+
state.screenshots += 1;
|
|
121
|
+
return state.screenshot;
|
|
122
|
+
},
|
|
123
|
+
on: (event, listener) => emitter.on(event, listener),
|
|
124
|
+
off: (event, listener) => emitter.off(event, listener),
|
|
125
|
+
emit: (event, payload) => emitter.emit(event, payload),
|
|
126
|
+
listenerCount: (event) => emitter.listenerCount(event),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Create a fake commander around a fake page.
|
|
132
|
+
*
|
|
133
|
+
* @param {Object} page - The page from `createFakePage()`
|
|
134
|
+
* @param {Object} [extras] - Extra commander properties, such as `downloads`
|
|
135
|
+
* @returns {Object} The fake commander
|
|
136
|
+
*/
|
|
137
|
+
export function createFakeCommander(page, extras = {}) {
|
|
138
|
+
return {
|
|
139
|
+
page,
|
|
140
|
+
engine: 'playwright',
|
|
141
|
+
evaluate: (fn, argument) => page.evaluate(fn, argument),
|
|
142
|
+
click: async (selector) => ({ clicked: selector }),
|
|
143
|
+
goto: async (url) => ({ url }),
|
|
144
|
+
typeText: async (selector, text) => ({ selector, text }),
|
|
145
|
+
...extras,
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* A download manager's subscription surface, without a browser.
|
|
151
|
+
*
|
|
152
|
+
* @returns {Object} `{on, off, emit, listenerCount}`
|
|
153
|
+
*/
|
|
154
|
+
export function createFakeDownloads() {
|
|
155
|
+
// The manager's subscription surface is an emitter's, so a plain one is the
|
|
156
|
+
// fixture: `on`, `off` and `listenerCount` are the calls the recorder makes.
|
|
157
|
+
return new EventEmitter();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* A dialog manager's observation surface, without a browser.
|
|
162
|
+
*
|
|
163
|
+
* `raise()` plays the manager's own rule back: observers are told, and a
|
|
164
|
+
* dialog nobody answered is dismissed rather than left blocking the page.
|
|
165
|
+
*
|
|
166
|
+
* @returns {Object} `{observeDialogs, unobserveDialogs, observerCount, raise}`
|
|
167
|
+
*/
|
|
168
|
+
export function createFakeDialogManager() {
|
|
169
|
+
const observers = [];
|
|
170
|
+
|
|
171
|
+
return {
|
|
172
|
+
observeDialogs: (observer) => observers.push(observer),
|
|
173
|
+
unobserveDialogs: (observer) => {
|
|
174
|
+
const index = observers.indexOf(observer);
|
|
175
|
+
if (index !== -1) {
|
|
176
|
+
observers.splice(index, 1);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
observerCount: () => observers.length,
|
|
180
|
+
raise: async ({ type, message }) => {
|
|
181
|
+
const dialog = {
|
|
182
|
+
dismissed: false,
|
|
183
|
+
type: () => type,
|
|
184
|
+
message: () => message,
|
|
185
|
+
dismiss: async () => {
|
|
186
|
+
dialog.dismissed = true;
|
|
187
|
+
},
|
|
188
|
+
};
|
|
189
|
+
for (const observer of observers) {
|
|
190
|
+
await observer(dialog);
|
|
191
|
+
}
|
|
192
|
+
if (!dialog.dismissed) {
|
|
193
|
+
await dialog.dismiss();
|
|
194
|
+
}
|
|
195
|
+
return dialog;
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
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"></ul>
|
|
29
|
+
<p id="status" data-state="idle">idle</p>
|
|
30
|
+
<iframe id="inner" name="inner" src="/frame" style="width:80px;height:40px"></iframe>
|
|
31
|
+
<a id="next" href="/next">Go to the next page</a>
|
|
32
|
+
<a id="download" href="/file/report.txt" download>Download the report</a>
|
|
33
|
+
<button id="add" type="button">Add an item</button>
|
|
34
|
+
<button id="touch" type="button">Change the status</button>
|
|
35
|
+
<button id="log" type="button">Log something</button>
|
|
36
|
+
<button id="boom" type="button">Throw</button>
|
|
37
|
+
<button id="ask" type="button">Ask</button>
|
|
38
|
+
<button id="unreachable" type="button">Call a server that is not there</button>
|
|
39
|
+
<script>
|
|
40
|
+
let added = 0;
|
|
41
|
+
document.getElementById('add').addEventListener('click', () => {
|
|
42
|
+
const item = document.createElement('li');
|
|
43
|
+
item.id = 'item-' + ++added;
|
|
44
|
+
item.textContent = 'item ' + added;
|
|
45
|
+
document.getElementById('items').appendChild(item);
|
|
46
|
+
});
|
|
47
|
+
document.getElementById('touch').addEventListener('click', () => {
|
|
48
|
+
const status = document.getElementById('status');
|
|
49
|
+
status.setAttribute('data-state', 'touched');
|
|
50
|
+
status.textContent = 'touched';
|
|
51
|
+
});
|
|
52
|
+
document.getElementById('log').addEventListener('click', () => {
|
|
53
|
+
console.log('something happened');
|
|
54
|
+
});
|
|
55
|
+
document.getElementById('boom').addEventListener('click', () => {
|
|
56
|
+
setTimeout(() => {
|
|
57
|
+
throw new Error('page error on purpose');
|
|
58
|
+
}, 0);
|
|
59
|
+
});
|
|
60
|
+
document.getElementById('ask').addEventListener('click', () => {
|
|
61
|
+
window.alert('are you sure?');
|
|
62
|
+
});
|
|
63
|
+
document.getElementById('unreachable').addEventListener('click', () => {
|
|
64
|
+
// Port 1 is never listening, so the request fails in the network stack
|
|
65
|
+
// rather than arriving somewhere and being answered with an error.
|
|
66
|
+
fetch('http://127.0.0.1:1/nope').catch(() => {});
|
|
67
|
+
});
|
|
68
|
+
</script>
|
|
69
|
+
</body></html>`;
|
|
70
|
+
|
|
71
|
+
/** The page a full navigation lands on. */
|
|
72
|
+
export const NEXT_PAGE = `<!doctype html>
|
|
73
|
+
<html><head><title>Next page</title></head>
|
|
74
|
+
<body style="margin:0">
|
|
75
|
+
<p id="second">the second document</p>
|
|
76
|
+
<button id="grow" type="button">Grow</button>
|
|
77
|
+
<ul id="more"></ul>
|
|
78
|
+
<script>
|
|
79
|
+
document.getElementById('grow').addEventListener('click', () => {
|
|
80
|
+
const item = document.createElement('li');
|
|
81
|
+
item.id = 'grown';
|
|
82
|
+
item.textContent = 'grown after navigating';
|
|
83
|
+
document.getElementById('more').appendChild(item);
|
|
84
|
+
});
|
|
85
|
+
</script>
|
|
86
|
+
</body></html>`;
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Start the fixture server the trace tests drive.
|
|
90
|
+
*
|
|
91
|
+
* @returns {Promise<{baseUrl: string, close: Function}>} Server handle
|
|
92
|
+
*/
|
|
93
|
+
export async function startTraceServer() {
|
|
94
|
+
return startFixtureHost((path, req, res) => {
|
|
95
|
+
if (path === '/' || path === '/app') {
|
|
96
|
+
sendHtml(res, TRACE_PAGE);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (path === '/next') {
|
|
100
|
+
sendHtml(res, NEXT_PAGE);
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
if (path === '/frame') {
|
|
104
|
+
sendHtml(res, '<!doctype html><p id="framed">inside a frame</p>');
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
if (path.startsWith('/file/')) {
|
|
108
|
+
res.writeHead(200, {
|
|
109
|
+
'content-type': 'text/plain',
|
|
110
|
+
'content-disposition': `attachment; filename="${path.slice('/file/'.length)}"`,
|
|
111
|
+
});
|
|
112
|
+
res.end(TRACE_DOWNLOAD_BODY);
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
sendNotFound(res);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
@@ -266,10 +266,98 @@ describe('dialog-manager', () => {
|
|
|
266
266
|
});
|
|
267
267
|
});
|
|
268
268
|
|
|
269
|
+
describe('observeDialogs', () => {
|
|
270
|
+
/**
|
|
271
|
+
* A manager already listening to the page, as every observer test needs.
|
|
272
|
+
*
|
|
273
|
+
* @returns {Object} The dialog manager
|
|
274
|
+
*/
|
|
275
|
+
const listening = () => {
|
|
276
|
+
const manager = createDialogManager({ page, engine: 'playwright', log });
|
|
277
|
+
manager.startListening();
|
|
278
|
+
return manager;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
it('should still auto-dismiss when only observers are watching', async () => {
|
|
282
|
+
const manager = listening();
|
|
283
|
+
|
|
284
|
+
const seen = [];
|
|
285
|
+
manager.observeDialogs((dialog) => seen.push(dialog.message()));
|
|
286
|
+
const dialog = createMockDialog({ message: 'watched' });
|
|
287
|
+
await page.emit('dialog', dialog);
|
|
288
|
+
|
|
289
|
+
assert.deepStrictEqual(seen, ['watched']);
|
|
290
|
+
// Watching a dialog must not make the watcher responsible for it, or a
|
|
291
|
+
// traced run leaves the page waiting for an answer an untraced run got.
|
|
292
|
+
assert.strictEqual(dialog._wasDismissed(), true);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it('should tell observers about a dialog a handler answers', async () => {
|
|
296
|
+
const manager = listening();
|
|
297
|
+
|
|
298
|
+
const seen = [];
|
|
299
|
+
manager.observeDialogs((dialog) => seen.push(dialog.type()));
|
|
300
|
+
manager.onDialog(async (dialog) => dialog.accept('ok'));
|
|
301
|
+
const dialog = createMockDialog({ type: 'prompt' });
|
|
302
|
+
await page.emit('dialog', dialog);
|
|
303
|
+
|
|
304
|
+
assert.deepStrictEqual(seen, ['prompt']);
|
|
305
|
+
assert.strictEqual(dialog._acceptText(), 'ok');
|
|
306
|
+
assert.strictEqual(dialog._wasDismissed(), false);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
it('should keep going when an observer throws', async () => {
|
|
310
|
+
const manager = listening();
|
|
311
|
+
|
|
312
|
+
let secondObserverCalled = false;
|
|
313
|
+
manager.observeDialogs(() => {
|
|
314
|
+
throw new Error('observer failed');
|
|
315
|
+
});
|
|
316
|
+
manager.observeDialogs(() => {
|
|
317
|
+
secondObserverCalled = true;
|
|
318
|
+
});
|
|
319
|
+
const dialog = createMockDialog();
|
|
320
|
+
await page.emit('dialog', dialog);
|
|
321
|
+
|
|
322
|
+
assert.strictEqual(secondObserverCalled, true);
|
|
323
|
+
assert.strictEqual(dialog._wasDismissed(), true);
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
it('should stop telling an observer that was removed', async () => {
|
|
327
|
+
const manager = listening();
|
|
328
|
+
|
|
329
|
+
let calls = 0;
|
|
330
|
+
const stop = manager.observeDialogs(() => {
|
|
331
|
+
calls += 1;
|
|
332
|
+
});
|
|
333
|
+
await page.emit('dialog', createMockDialog());
|
|
334
|
+
stop();
|
|
335
|
+
await page.emit('dialog', createMockDialog());
|
|
336
|
+
|
|
337
|
+
assert.strictEqual(calls, 1);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
it('should refuse an observer that is not a function', () => {
|
|
341
|
+
const manager = createDialogManager({ page, engine: 'playwright', log });
|
|
342
|
+
|
|
343
|
+
assert.throws(() => manager.observeDialogs('nope'), /must be a function/);
|
|
344
|
+
});
|
|
345
|
+
});
|
|
346
|
+
|
|
269
347
|
describe('integration with makeBrowserCommander', () => {
|
|
270
|
-
|
|
348
|
+
/**
|
|
349
|
+
* A commander driving the suite's page.
|
|
350
|
+
*
|
|
351
|
+
* @param {Object} [options] - Extra options for `makeBrowserCommander`
|
|
352
|
+
* @returns {Promise<Object>} The commander
|
|
353
|
+
*/
|
|
354
|
+
const commanderFor = async (options = {}) => {
|
|
271
355
|
const { makeBrowserCommander } = await import('../../../src/factory.js');
|
|
272
|
-
|
|
356
|
+
return makeBrowserCommander({ page, verbose: false, ...options });
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
it('should expose onDialog on commander', async () => {
|
|
360
|
+
const commander = await commanderFor();
|
|
273
361
|
|
|
274
362
|
assert.ok(typeof commander.onDialog === 'function');
|
|
275
363
|
assert.ok(typeof commander.offDialog === 'function');
|
|
@@ -278,8 +366,7 @@ describe('dialog-manager', () => {
|
|
|
278
366
|
});
|
|
279
367
|
|
|
280
368
|
it('should handle dialog via commander.onDialog', async () => {
|
|
281
|
-
const
|
|
282
|
-
const commander = makeBrowserCommander({ page, verbose: false });
|
|
369
|
+
const commander = await commanderFor();
|
|
283
370
|
|
|
284
371
|
let handlerCalled = false;
|
|
285
372
|
commander.onDialog(async (dialog) => {
|
|
@@ -296,13 +383,34 @@ describe('dialog-manager', () => {
|
|
|
296
383
|
await commander.destroy();
|
|
297
384
|
});
|
|
298
385
|
|
|
386
|
+
it('should expose observeDialogs on commander', async () => {
|
|
387
|
+
const commander = await commanderFor();
|
|
388
|
+
|
|
389
|
+
const seen = [];
|
|
390
|
+
commander.observeDialogs((dialog) => seen.push(dialog.message()));
|
|
391
|
+
const dialog = createMockDialog({ message: 'from the commander' });
|
|
392
|
+
await page.emit('dialog', dialog);
|
|
393
|
+
|
|
394
|
+
assert.deepStrictEqual(seen, ['from the commander']);
|
|
395
|
+
assert.strictEqual(dialog._wasDismissed(), true);
|
|
396
|
+
|
|
397
|
+
await commander.destroy();
|
|
398
|
+
});
|
|
399
|
+
|
|
400
|
+
it('should throw observeDialogs when enableDialogManager is false', async () => {
|
|
401
|
+
const commander = await commanderFor({ enableDialogManager: false });
|
|
402
|
+
|
|
403
|
+
assert.throws(
|
|
404
|
+
() => commander.observeDialogs(() => {}),
|
|
405
|
+
/enableDialogManager/
|
|
406
|
+
);
|
|
407
|
+
// Removing an observer from a manager that never existed is a no-op,
|
|
408
|
+
// so cleanup code does not have to know whether tracing was on.
|
|
409
|
+
assert.doesNotThrow(() => commander.unobserveDialogs(() => {}));
|
|
410
|
+
});
|
|
411
|
+
|
|
299
412
|
it('should throw onDialog when enableDialogManager is false', async () => {
|
|
300
|
-
const
|
|
301
|
-
const commander = makeBrowserCommander({
|
|
302
|
-
page,
|
|
303
|
-
verbose: false,
|
|
304
|
-
enableDialogManager: false,
|
|
305
|
-
});
|
|
413
|
+
const commander = await commanderFor({ enableDialogManager: false });
|
|
306
414
|
|
|
307
415
|
assert.throws(() => commander.onDialog(() => {}), /enableDialogManager/);
|
|
308
416
|
});
|
|
@@ -4,7 +4,33 @@ import { createNavigationManager } from '../../../src/core/navigation-manager.js
|
|
|
4
4
|
import {
|
|
5
5
|
createMockPlaywrightPage,
|
|
6
6
|
createMockLogger,
|
|
7
|
+
createMockNetworkTracker,
|
|
7
8
|
} from '../../helpers/mocks.js';
|
|
9
|
+
import { READINESS_STATUS } from '../../../src/core/readiness.js';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Build a navigation manager wired to a network tracker whose idle wait is
|
|
13
|
+
* fully controlled by the test.
|
|
14
|
+
*/
|
|
15
|
+
function createManagerWithNetwork(options = {}) {
|
|
16
|
+
const { idle = true, onIdleWait = () => {} } = options;
|
|
17
|
+
const page = createMockPlaywrightPage();
|
|
18
|
+
const log = createMockLogger();
|
|
19
|
+
const networkTracker = createMockNetworkTracker();
|
|
20
|
+
networkTracker.waitForNetworkIdle = async (opts = {}) => {
|
|
21
|
+
onIdleWait(opts);
|
|
22
|
+
return typeof idle === 'function' ? await idle(opts) : idle;
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const manager = createNavigationManager({
|
|
26
|
+
page,
|
|
27
|
+
engine: 'playwright',
|
|
28
|
+
log,
|
|
29
|
+
networkTracker,
|
|
30
|
+
});
|
|
31
|
+
manager.configure({ redirectStabilizationTime: 0 });
|
|
32
|
+
return { manager, page, log, networkTracker };
|
|
33
|
+
}
|
|
8
34
|
|
|
9
35
|
describe('navigation manager', () => {
|
|
10
36
|
describe('setContent', () => {
|
|
@@ -59,4 +85,140 @@ describe('navigation manager', () => {
|
|
|
59
85
|
await assert.doesNotReject(() => manager.setContent({ html: '' }));
|
|
60
86
|
});
|
|
61
87
|
});
|
|
88
|
+
|
|
89
|
+
describe('waitForReady', () => {
|
|
90
|
+
it('should never extend the caller deadline beyond the timeout', async () => {
|
|
91
|
+
// Regression test for issue #89: the old implementation waited
|
|
92
|
+
// `Math.max(60000, timeout - elapsed)`, so a 500ms budget could block for
|
|
93
|
+
// a full minute.
|
|
94
|
+
const idleTimeouts = [];
|
|
95
|
+
const { manager } = createManagerWithNetwork({
|
|
96
|
+
idle: true,
|
|
97
|
+
onIdleWait: (opts) => idleTimeouts.push(opts.timeout),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
await manager.waitForReady({ timeout: 1000, reason: 'deadline test' });
|
|
101
|
+
|
|
102
|
+
assert.strictEqual(idleTimeouts.length, 1);
|
|
103
|
+
assert.ok(
|
|
104
|
+
idleTimeouts[0] <= 1000,
|
|
105
|
+
`network idle timeout ${idleTimeouts[0]} exceeded the 1000ms budget`
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('should report timed_out instead of claiming the page is ready', async () => {
|
|
110
|
+
// Regression test for issue #89: `waitForPageReady` logged "Page ready"
|
|
111
|
+
// and returned true even after network idle had failed.
|
|
112
|
+
const { manager } = createManagerWithNetwork({
|
|
113
|
+
// Burn the whole remaining budget, the way a real idle wait does.
|
|
114
|
+
idle: async (opts) => {
|
|
115
|
+
await new Promise((resolve) => setTimeout(resolve, opts.timeout));
|
|
116
|
+
return false;
|
|
117
|
+
},
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
const result = await manager.waitForReady({
|
|
121
|
+
timeout: 600,
|
|
122
|
+
reason: 'failure test',
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
assert.strictEqual(result.ready, false);
|
|
126
|
+
assert.strictEqual(result.status, READINESS_STATUS.TIMED_OUT);
|
|
127
|
+
assert.ok(result.checks.failed.includes('networkIdleFor'));
|
|
128
|
+
assert.ok(
|
|
129
|
+
result.elapsedMs < 3000,
|
|
130
|
+
`wait took ${result.elapsedMs}ms for a 600ms budget`
|
|
131
|
+
);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('should report failed when a check fails before the deadline', async () => {
|
|
135
|
+
const { manager } = createManagerWithNetwork({ idle: false });
|
|
136
|
+
|
|
137
|
+
const result = await manager.waitForReady({ timeout: 5000 });
|
|
138
|
+
|
|
139
|
+
assert.strictEqual(result.ready, false);
|
|
140
|
+
assert.strictEqual(result.status, READINESS_STATUS.FAILED);
|
|
141
|
+
assert.ok(result.checks.failed.includes('networkIdleFor'));
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should not emit onPageReady after a failed wait', async () => {
|
|
145
|
+
// Regression test for issue #89: cleanup and the ready event were both
|
|
146
|
+
// owned by completeNavigation(), so failing waits still announced ready.
|
|
147
|
+
const { manager } = createManagerWithNetwork({ idle: false });
|
|
148
|
+
const events = [];
|
|
149
|
+
manager.on('onPageReady', () => events.push('pageReady'));
|
|
150
|
+
manager.on('onNavigationComplete', (payload) =>
|
|
151
|
+
events.push(`complete:${payload.ready}`)
|
|
152
|
+
);
|
|
153
|
+
|
|
154
|
+
const ready = await manager.navigate({
|
|
155
|
+
url: 'https://example.com',
|
|
156
|
+
timeout: 1000,
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
assert.strictEqual(ready, false);
|
|
160
|
+
assert.ok(!events.includes('pageReady'));
|
|
161
|
+
assert.ok(events.includes('complete:false'));
|
|
162
|
+
assert.strictEqual(manager.isNavigating(), false);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('should emit onPageReady exactly once when every check passes', async () => {
|
|
166
|
+
const { manager } = createManagerWithNetwork({ idle: true });
|
|
167
|
+
const events = [];
|
|
168
|
+
manager.on('onPageReady', () => events.push('pageReady'));
|
|
169
|
+
|
|
170
|
+
const result = await manager.waitForReady({ timeout: 2000 });
|
|
171
|
+
|
|
172
|
+
assert.strictEqual(result.ready, true);
|
|
173
|
+
assert.strictEqual(result.status, READINESS_STATUS.READY);
|
|
174
|
+
assert.deepStrictEqual(events, ['pageReady']);
|
|
175
|
+
assert.ok(result.checks.satisfied.includes('networkIdleFor'));
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('should record evidence for every check it ran', async () => {
|
|
179
|
+
const { manager } = createManagerWithNetwork({ idle: true });
|
|
180
|
+
|
|
181
|
+
const result = await manager.waitForReady({ timeout: 2000 });
|
|
182
|
+
|
|
183
|
+
assert.ok(Array.isArray(result.evidence));
|
|
184
|
+
assert.ok(result.evidence.length >= 2);
|
|
185
|
+
for (const entry of result.evidence) {
|
|
186
|
+
assert.strictEqual(typeof entry.name, 'string');
|
|
187
|
+
assert.strictEqual(typeof entry.satisfied, 'boolean');
|
|
188
|
+
assert.strictEqual(typeof entry.elapsedMs, 'number');
|
|
189
|
+
}
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should stop running checks once the deadline is exhausted', async () => {
|
|
193
|
+
let secondCheckRan = false;
|
|
194
|
+
const { manager } = createManagerWithNetwork({ idle: true });
|
|
195
|
+
|
|
196
|
+
const slow = {
|
|
197
|
+
name: 'slow',
|
|
198
|
+
async run({ deadline }) {
|
|
199
|
+
await new Promise((resolve) => setTimeout(resolve, 60));
|
|
200
|
+
return {
|
|
201
|
+
satisfied: false,
|
|
202
|
+
detail: { remaining: deadline.remainingMs() },
|
|
203
|
+
};
|
|
204
|
+
},
|
|
205
|
+
};
|
|
206
|
+
const second = {
|
|
207
|
+
name: 'second',
|
|
208
|
+
async run() {
|
|
209
|
+
secondCheckRan = true;
|
|
210
|
+
return { satisfied: true };
|
|
211
|
+
},
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const result = await manager.waitForReady({
|
|
215
|
+
timeout: 50,
|
|
216
|
+
checks: [slow, second],
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
assert.strictEqual(result.ready, false);
|
|
220
|
+
assert.strictEqual(secondCheckRan, false);
|
|
221
|
+
assert.ok(result.checks.pending.includes('second'));
|
|
222
|
+
});
|
|
223
|
+
});
|
|
62
224
|
});
|