browser-commander 0.17.2 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.jscpd-baseline.json +4 -17
- package/CHANGELOG.md +34 -0
- package/README.md +84 -0
- package/eslint.config.js +5 -0
- package/package.json +4 -1
- 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/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,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A tiny server for the real-browser download tests (issue #88).
|
|
3
|
+
*
|
|
4
|
+
* The issue's acceptance criteria are about downloads a mock cannot produce:
|
|
5
|
+
* a link the browser downloads instead of navigating to, a file built in the
|
|
6
|
+
* page from a blob, a navigation that turns into a download, a name the page
|
|
7
|
+
* only hints at, and a transfer the server aborts halfway.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { sendHtml, sendNotFound, startFixtureHost } from './fixture-server.js';
|
|
11
|
+
|
|
12
|
+
/** Bytes every fixture download is expected to produce. */
|
|
13
|
+
export const REPORT_BODY = 'quarterly report body';
|
|
14
|
+
|
|
15
|
+
/** A body only its leading bytes identify as a PDF. */
|
|
16
|
+
export const PDF_BODY = `%PDF-1.7\n${REPORT_BODY}`;
|
|
17
|
+
|
|
18
|
+
/** A page offering the download shapes the issue lists. */
|
|
19
|
+
export const DOWNLOADS_PAGE = `<!doctype html>
|
|
20
|
+
<html><body style="margin:0">
|
|
21
|
+
<a id="link" href="/file/report.pdf" download>Download the report</a>
|
|
22
|
+
<a id="navigating" href="/attachment">Open the statement</a>
|
|
23
|
+
<a id="unnamed" href="/unnamed" download>Download without a name</a>
|
|
24
|
+
<a id="broken" href="/aborted" download>Download a broken file</a>
|
|
25
|
+
<button id="blob">Build and download</button>
|
|
26
|
+
<script>
|
|
27
|
+
document.getElementById('blob').addEventListener('click', () => {
|
|
28
|
+
const blob = new Blob([${JSON.stringify(REPORT_BODY)}], {
|
|
29
|
+
type: 'text/plain',
|
|
30
|
+
});
|
|
31
|
+
const url = URL.createObjectURL(blob);
|
|
32
|
+
const anchor = document.createElement('a');
|
|
33
|
+
anchor.href = url;
|
|
34
|
+
anchor.download = 'generated.txt';
|
|
35
|
+
document.body.appendChild(anchor);
|
|
36
|
+
anchor.click();
|
|
37
|
+
});
|
|
38
|
+
</script>
|
|
39
|
+
</body></html>`;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Answer a request for one of the fixture downloads.
|
|
43
|
+
*
|
|
44
|
+
* @param {string} path - Request path
|
|
45
|
+
* @param {Object} res - Node response
|
|
46
|
+
* @returns {boolean} Whether the request was a download
|
|
47
|
+
*/
|
|
48
|
+
function serveDownload(path, res) {
|
|
49
|
+
if (path.startsWith('/file/')) {
|
|
50
|
+
res.writeHead(200, {
|
|
51
|
+
'content-type': 'application/pdf',
|
|
52
|
+
'content-disposition': `attachment; filename="${path.slice('/file/'.length)}"`,
|
|
53
|
+
});
|
|
54
|
+
res.end(REPORT_BODY);
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (path === '/attachment') {
|
|
59
|
+
// No `download` attribute on the link: the browser starts navigating and
|
|
60
|
+
// turns the navigation into a download once it reads this header.
|
|
61
|
+
res.writeHead(200, {
|
|
62
|
+
'content-type': 'application/pdf',
|
|
63
|
+
'content-disposition': 'attachment; filename="statement.pdf"',
|
|
64
|
+
});
|
|
65
|
+
res.end(REPORT_BODY);
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (path === '/unnamed') {
|
|
70
|
+
// Nothing names this file and the server declares nothing useful: only the
|
|
71
|
+
// bytes themselves can give it a suffix.
|
|
72
|
+
res.writeHead(200, { 'content-type': 'application/octet-stream' });
|
|
73
|
+
res.end(PDF_BODY);
|
|
74
|
+
return true;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (path === '/aborted') {
|
|
78
|
+
res.writeHead(200, {
|
|
79
|
+
'content-type': 'application/pdf',
|
|
80
|
+
'content-length': '1000000',
|
|
81
|
+
'content-disposition': 'attachment; filename="broken.pdf"',
|
|
82
|
+
});
|
|
83
|
+
res.write('partial');
|
|
84
|
+
res.destroy();
|
|
85
|
+
return true;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Start the download fixture server on an ephemeral port.
|
|
93
|
+
*
|
|
94
|
+
* @returns {Promise<{baseUrl: string, close: Function}>} Server handle
|
|
95
|
+
*/
|
|
96
|
+
export async function startDownloadServer() {
|
|
97
|
+
return startFixtureHost((path, req, res) => {
|
|
98
|
+
if (serveDownload(path, res)) {
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (path === '/') {
|
|
103
|
+
sendHtml(res, DOWNLOADS_PAGE);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
sendNotFound(res);
|
|
108
|
+
});
|
|
109
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The browser an end-to-end suite runs against (issues #87, #88).
|
|
3
|
+
*
|
|
4
|
+
* Every real-browser suite needs the same three things before it can test
|
|
5
|
+
* anything: a throwaway profile, the arguments a sandbox-less container
|
|
6
|
+
* needs, and a directory the browser may write downloads into. Starting a
|
|
7
|
+
* browser is not what those suites are about, so it lives here once and they
|
|
8
|
+
* read as the behaviour they check.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import fs from 'node:fs/promises';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import path from 'node:path';
|
|
14
|
+
|
|
15
|
+
import { launchBrowser } from '../../src/browser/launcher.js';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Extra Chrome arguments for environments without a usable sandbox.
|
|
19
|
+
*
|
|
20
|
+
* Containers commonly forbid unprivileged user namespaces, where Chromium
|
|
21
|
+
* refuses to start at all; CHROME_NO_SANDBOX=true makes a suite runnable
|
|
22
|
+
* there without weakening how the library launches browsers for everyone else.
|
|
23
|
+
*/
|
|
24
|
+
export const SANDBOX_ARGS =
|
|
25
|
+
process.env.CHROME_NO_SANDBOX === 'true' ? ['--no-sandbox'] : [];
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Launch a real browser for one engine, with its own profile.
|
|
29
|
+
*
|
|
30
|
+
* @param {Object} options - Launch options
|
|
31
|
+
* @param {string} options.engine - 'playwright' or 'puppeteer'
|
|
32
|
+
* @param {string} [options.downloadDirectory] - Where managed downloads are kept
|
|
33
|
+
* @returns {Promise<{browser: Object, page: Object, downloads: Object, cleanup: Function}>} The launched browser and a `cleanup` that closes it and removes the profile
|
|
34
|
+
*/
|
|
35
|
+
export async function launchE2EBrowser(options = {}) {
|
|
36
|
+
const { engine, downloadDirectory } = options;
|
|
37
|
+
const userDataDir = await fs.mkdtemp(
|
|
38
|
+
path.join(os.tmpdir(), 'bc-e2e-profile-')
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const launched = await launchBrowser({
|
|
42
|
+
engine,
|
|
43
|
+
userDataDir,
|
|
44
|
+
headless: process.env.HEADLESS !== 'false',
|
|
45
|
+
slowMo: 0,
|
|
46
|
+
args: SANDBOX_ARGS,
|
|
47
|
+
...(process.env.CHROME_PATH
|
|
48
|
+
? { executablePath: process.env.CHROME_PATH }
|
|
49
|
+
: {}),
|
|
50
|
+
...(downloadDirectory
|
|
51
|
+
? { downloads: { directory: downloadDirectory } }
|
|
52
|
+
: {}),
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
return {
|
|
56
|
+
...launched,
|
|
57
|
+
/**
|
|
58
|
+
* Close the browser and remove the profile this helper created.
|
|
59
|
+
*
|
|
60
|
+
* @returns {Promise<void>} Resolves once both are gone
|
|
61
|
+
*/
|
|
62
|
+
cleanup: async () => {
|
|
63
|
+
await launched.browser?.close();
|
|
64
|
+
await fs.rm(userDataDir, { recursive: true, force: true });
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checking owner-only permissions where the platform actually has them.
|
|
3
|
+
*
|
|
4
|
+
* Node accepts a `mode` on Windows and then ignores it: NTFS permissions are
|
|
5
|
+
* ACLs, not POSIX bits, and `fs.stat` reports a synthesised `0o666` whatever
|
|
6
|
+
* the file was created with. Asserting the mode there would be asserting
|
|
7
|
+
* Node's emulation rather than our code, so the permission check runs on the
|
|
8
|
+
* platforms that enforce it and the rest of each test runs everywhere.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from 'node:assert';
|
|
12
|
+
import fs from 'node:fs/promises';
|
|
13
|
+
|
|
14
|
+
/** Whether this platform enforces POSIX mode bits. */
|
|
15
|
+
export const HAS_POSIX_MODES = process.platform !== 'win32';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Assert that a path carries the mode the code asked for.
|
|
19
|
+
*
|
|
20
|
+
* @param {string} target - File or directory to inspect
|
|
21
|
+
* @param {number} expected - Expected permission bits, e.g. `0o600`
|
|
22
|
+
* @returns {Promise<void>} Resolves once checked, or immediately on Windows
|
|
23
|
+
*/
|
|
24
|
+
export async function assertMode(target, expected) {
|
|
25
|
+
if (!HAS_POSIX_MODES) {
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const { mode } = await fs.stat(target);
|
|
30
|
+
assert.strictEqual(mode & 0o777, expected);
|
|
31
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The bit of a fixture server that has nothing to do with the fixtures.
|
|
3
|
+
*
|
|
4
|
+
* The readiness pages (issue #89) and the download endpoints (issue #88) need
|
|
5
|
+
* the same thing underneath: a localhost server on a port nobody else is using,
|
|
6
|
+
* with a handle that shuts it down again.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createServer } from 'node:http';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Send an HTML fixture page.
|
|
13
|
+
*
|
|
14
|
+
* @param {Object} res - Node response
|
|
15
|
+
* @param {string} body - Page markup
|
|
16
|
+
* @returns {void}
|
|
17
|
+
*/
|
|
18
|
+
export function sendHtml(res, body) {
|
|
19
|
+
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' });
|
|
20
|
+
res.end(body);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Answer a request for a fixture that does not exist.
|
|
25
|
+
*
|
|
26
|
+
* @param {Object} res - Node response
|
|
27
|
+
* @returns {void}
|
|
28
|
+
*/
|
|
29
|
+
export function sendNotFound(res) {
|
|
30
|
+
res.writeHead(404, { 'content-type': 'text/plain' });
|
|
31
|
+
res.end('not found');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Start a fixture server on an ephemeral port.
|
|
36
|
+
*
|
|
37
|
+
* @param {Function} handle - Request handler, called with (path, req, res)
|
|
38
|
+
* @param {Function} [onClose] - Teardown to run before the server closes
|
|
39
|
+
* @returns {Promise<{baseUrl: string, close: Function}>} Server handle
|
|
40
|
+
*/
|
|
41
|
+
export async function startFixtureHost(handle, onClose) {
|
|
42
|
+
const server = createServer((req, res) => {
|
|
43
|
+
handle(req.url.split('?')[0], req, res);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
47
|
+
const { port } = server.address();
|
|
48
|
+
|
|
49
|
+
return {
|
|
50
|
+
baseUrl: `http://127.0.0.1:${port}`,
|
|
51
|
+
close: async () => {
|
|
52
|
+
await onClose?.();
|
|
53
|
+
await new Promise((resolve) => server.close(resolve));
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
package/tests/helpers/mocks.js
CHANGED
|
@@ -2,6 +2,67 @@
|
|
|
2
2
|
* Mock utilities for unit tests
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* Members shared by the Playwright and Puppeteer page mocks.
|
|
7
|
+
*
|
|
8
|
+
* Both engines expose the same event, input and frame surface, so the mocks
|
|
9
|
+
* describe it once. `mouse` records its clicks because the `scroll: 'none'`
|
|
10
|
+
* click path dispatches real pointer input at a measured point.
|
|
11
|
+
*
|
|
12
|
+
* @param {Object} options - Configuration options
|
|
13
|
+
* @param {string} options.url - URL the mock page reports
|
|
14
|
+
* @param {Map} options.eventListeners - Listener registry shared with the page
|
|
15
|
+
* @returns {Object} Shared page members
|
|
16
|
+
*/
|
|
17
|
+
function createSharedPageBehaviors(options) {
|
|
18
|
+
const { url, eventListeners } = options;
|
|
19
|
+
|
|
20
|
+
return {
|
|
21
|
+
mainFrame: () => ({
|
|
22
|
+
url: () => url,
|
|
23
|
+
}),
|
|
24
|
+
bringToFront: async () => {},
|
|
25
|
+
on: (event, handler) => {
|
|
26
|
+
if (!eventListeners.has(event)) {
|
|
27
|
+
eventListeners.set(event, []);
|
|
28
|
+
}
|
|
29
|
+
eventListeners.get(event).push(handler);
|
|
30
|
+
},
|
|
31
|
+
off: (event, handler) => {
|
|
32
|
+
const handlers = eventListeners.get(event);
|
|
33
|
+
if (handlers) {
|
|
34
|
+
const idx = handlers.indexOf(handler);
|
|
35
|
+
if (idx !== -1) {
|
|
36
|
+
handlers.splice(idx, 1);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
emit: (event, data) => {
|
|
41
|
+
const handlers = eventListeners.get(event);
|
|
42
|
+
if (handlers) {
|
|
43
|
+
handlers.forEach((h) => h(data));
|
|
44
|
+
}
|
|
45
|
+
},
|
|
46
|
+
click: async (sel, opts = {}) => {},
|
|
47
|
+
type: async (sel, text, opts = {}) => {},
|
|
48
|
+
keyboard: {
|
|
49
|
+
press: async (key) => {},
|
|
50
|
+
type: async (text) => {},
|
|
51
|
+
down: async (key) => {},
|
|
52
|
+
up: async (key) => {},
|
|
53
|
+
},
|
|
54
|
+
mouse: {
|
|
55
|
+
clicks: [],
|
|
56
|
+
async click(x, y, opts = {}) {
|
|
57
|
+
this.clicks.push({ x, y, opts });
|
|
58
|
+
},
|
|
59
|
+
async move(x, y) {},
|
|
60
|
+
async down() {},
|
|
61
|
+
async up() {},
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|
|
65
|
+
|
|
5
66
|
/**
|
|
6
67
|
* Create a mock Playwright page object
|
|
7
68
|
*/
|
|
@@ -122,40 +183,8 @@ export function createMockPlaywrightPage(options = {}) {
|
|
|
122
183
|
return fn;
|
|
123
184
|
}
|
|
124
185
|
},
|
|
125
|
-
|
|
126
|
-
url: () => url,
|
|
127
|
-
}),
|
|
186
|
+
...createSharedPageBehaviors({ url, eventListeners }),
|
|
128
187
|
context: () => ({}),
|
|
129
|
-
bringToFront: async () => {},
|
|
130
|
-
on: (event, handler) => {
|
|
131
|
-
if (!eventListeners.has(event)) {
|
|
132
|
-
eventListeners.set(event, []);
|
|
133
|
-
}
|
|
134
|
-
eventListeners.get(event).push(handler);
|
|
135
|
-
},
|
|
136
|
-
off: (event, handler) => {
|
|
137
|
-
const handlers = eventListeners.get(event);
|
|
138
|
-
if (handlers) {
|
|
139
|
-
const idx = handlers.indexOf(handler);
|
|
140
|
-
if (idx !== -1) {
|
|
141
|
-
handlers.splice(idx, 1);
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
},
|
|
145
|
-
emit: (event, data) => {
|
|
146
|
-
const handlers = eventListeners.get(event);
|
|
147
|
-
if (handlers) {
|
|
148
|
-
handlers.forEach((h) => h(data));
|
|
149
|
-
}
|
|
150
|
-
},
|
|
151
|
-
click: async (sel, opts = {}) => {},
|
|
152
|
-
type: async (sel, text, opts = {}) => {},
|
|
153
|
-
keyboard: {
|
|
154
|
-
press: async (key) => {},
|
|
155
|
-
type: async (text) => {},
|
|
156
|
-
down: async (key) => {},
|
|
157
|
-
up: async (key) => {},
|
|
158
|
-
},
|
|
159
188
|
pdf: async (opts = {}) => Buffer.from('%PDF-1.4 mock playwright'),
|
|
160
189
|
};
|
|
161
190
|
}
|
|
@@ -289,39 +318,7 @@ export function createMockPuppeteerPage(options = {}) {
|
|
|
289
318
|
}
|
|
290
319
|
},
|
|
291
320
|
content: async () => '<html><body>Mock page</body></html>',
|
|
292
|
-
|
|
293
|
-
url: () => url,
|
|
294
|
-
}),
|
|
295
|
-
bringToFront: async () => {},
|
|
296
|
-
on: (event, handler) => {
|
|
297
|
-
if (!eventListeners.has(event)) {
|
|
298
|
-
eventListeners.set(event, []);
|
|
299
|
-
}
|
|
300
|
-
eventListeners.get(event).push(handler);
|
|
301
|
-
},
|
|
302
|
-
off: (event, handler) => {
|
|
303
|
-
const handlers = eventListeners.get(event);
|
|
304
|
-
if (handlers) {
|
|
305
|
-
const idx = handlers.indexOf(handler);
|
|
306
|
-
if (idx !== -1) {
|
|
307
|
-
handlers.splice(idx, 1);
|
|
308
|
-
}
|
|
309
|
-
}
|
|
310
|
-
},
|
|
311
|
-
emit: (event, data) => {
|
|
312
|
-
const handlers = eventListeners.get(event);
|
|
313
|
-
if (handlers) {
|
|
314
|
-
handlers.forEach((h) => h(data));
|
|
315
|
-
}
|
|
316
|
-
},
|
|
317
|
-
click: async (sel, opts = {}) => {},
|
|
318
|
-
type: async (sel, text, opts = {}) => {},
|
|
319
|
-
keyboard: {
|
|
320
|
-
press: async (key) => {},
|
|
321
|
-
type: async (text) => {},
|
|
322
|
-
down: async (key) => {},
|
|
323
|
-
up: async (key) => {},
|
|
324
|
-
},
|
|
321
|
+
...createSharedPageBehaviors({ url, eventListeners }),
|
|
325
322
|
pdf: async (opts = {}) => Buffer.from('%PDF-1.4 mock puppeteer'),
|
|
326
323
|
};
|
|
327
324
|
|
|
@@ -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
|
+
}
|