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.
- package/.jscpd-baseline.json +12 -17
- package/CHANGELOG.md +68 -0
- package/README.md +132 -0
- package/eslint.config.js +5 -0
- package/package.json +5 -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 +476 -0
- package/src/downloads/naming.js +246 -0
- package/src/downloads/sources.js +238 -0
- package/src/downloads/staging.js +146 -0
- package/src/downloads/store.js +256 -0
- package/src/exports.js +86 -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 +338 -0
- package/src/traces/deadline.js +33 -0
- package/src/traces/identity.js +127 -0
- package/src/traces/init-script.js +95 -0
- package/src/traces/links.js +623 -0
- package/src/traces/mutation-stream.js +232 -0
- package/src/traces/observers.js +272 -0
- package/src/traces/page-capture.js +601 -0
- package/src/traces/reader.js +226 -0
- package/src/traces/recorder.js +454 -0
- package/src/traces/redaction.js +231 -0
- package/src/traces/schema.js +229 -0
- package/src/traces/viewer.js +448 -0
- package/tests/e2e/click-readiness.e2e.test.js +240 -0
- package/tests/e2e/downloads.e2e.test.js +235 -0
- package/tests/e2e/traces.e2e.test.js +742 -0
- package/tests/helpers/click-fixtures.js +62 -0
- package/tests/helpers/download-fixtures.js +125 -0
- package/tests/helpers/download-server.js +129 -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 +274 -0
- package/tests/helpers/trace-server.js +173 -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 +485 -0
- package/tests/unit/downloads/naming.test.js +174 -0
- package/tests/unit/downloads/staging.test.js +117 -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/links.test.js +557 -0
- package/tests/unit/traces/reader.test.js +208 -0
- package/tests/unit/traces/recorder.test.js +730 -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 +294 -0
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,129 @@
|
|
|
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
|
+
/**
|
|
19
|
+
* A PDF large enough that Chromium writes it in more than one chunk.
|
|
20
|
+
*
|
|
21
|
+
* Issue #92 is a race between `Browser.downloadProgress` reporting
|
|
22
|
+
* `completed` and the staged bytes being readable, so a body the browser can
|
|
23
|
+
* write in a single instant is the one body that would not exercise it.
|
|
24
|
+
*/
|
|
25
|
+
export const BLOB_PDF_BODY = `%PDF-1.7\n${'blob pdf payload line\n'.repeat(40000)}%%EOF\n`;
|
|
26
|
+
|
|
27
|
+
/** A page offering the download shapes the issue lists. */
|
|
28
|
+
export const DOWNLOADS_PAGE = `<!doctype html>
|
|
29
|
+
<html><body style="margin:0">
|
|
30
|
+
<a id="link" href="/file/report.pdf" download>Download the report</a>
|
|
31
|
+
<a id="navigating" href="/attachment">Open the statement</a>
|
|
32
|
+
<a id="unnamed" href="/unnamed" download>Download without a name</a>
|
|
33
|
+
<a id="broken" href="/aborted" download>Download a broken file</a>
|
|
34
|
+
<button id="blob">Build and download</button>
|
|
35
|
+
<button id="blob-pdf">Build and download a PDF</button>
|
|
36
|
+
<script>
|
|
37
|
+
const downloadBlob = (parts, type, name) => {
|
|
38
|
+
const url = URL.createObjectURL(new Blob(parts, { type }));
|
|
39
|
+
const anchor = document.createElement('a');
|
|
40
|
+
anchor.href = url;
|
|
41
|
+
anchor.download = name;
|
|
42
|
+
document.body.appendChild(anchor);
|
|
43
|
+
anchor.click();
|
|
44
|
+
};
|
|
45
|
+
document.getElementById('blob').addEventListener('click', () => {
|
|
46
|
+
downloadBlob([${JSON.stringify(REPORT_BODY)}], 'text/plain', 'generated.txt');
|
|
47
|
+
});
|
|
48
|
+
document.getElementById('blob-pdf').addEventListener('click', () => {
|
|
49
|
+
// Built from a repeated unit rather than shipped as one literal: the
|
|
50
|
+
// page has to produce the bytes, not receive them over the wire.
|
|
51
|
+
const line = ${JSON.stringify('blob pdf payload line\n')};
|
|
52
|
+
downloadBlob(
|
|
53
|
+
['%PDF-1.7\\n', line.repeat(40000), '%%EOF\\n'],
|
|
54
|
+
'application/pdf',
|
|
55
|
+
'generated.pdf'
|
|
56
|
+
);
|
|
57
|
+
});
|
|
58
|
+
</script>
|
|
59
|
+
</body></html>`;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Answer a request for one of the fixture downloads.
|
|
63
|
+
*
|
|
64
|
+
* @param {string} path - Request path
|
|
65
|
+
* @param {Object} res - Node response
|
|
66
|
+
* @returns {boolean} Whether the request was a download
|
|
67
|
+
*/
|
|
68
|
+
function serveDownload(path, res) {
|
|
69
|
+
if (path.startsWith('/file/')) {
|
|
70
|
+
res.writeHead(200, {
|
|
71
|
+
'content-type': 'application/pdf',
|
|
72
|
+
'content-disposition': `attachment; filename="${path.slice('/file/'.length)}"`,
|
|
73
|
+
});
|
|
74
|
+
res.end(REPORT_BODY);
|
|
75
|
+
return true;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (path === '/attachment') {
|
|
79
|
+
// No `download` attribute on the link: the browser starts navigating and
|
|
80
|
+
// turns the navigation into a download once it reads this header.
|
|
81
|
+
res.writeHead(200, {
|
|
82
|
+
'content-type': 'application/pdf',
|
|
83
|
+
'content-disposition': 'attachment; filename="statement.pdf"',
|
|
84
|
+
});
|
|
85
|
+
res.end(REPORT_BODY);
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (path === '/unnamed') {
|
|
90
|
+
// Nothing names this file and the server declares nothing useful: only the
|
|
91
|
+
// bytes themselves can give it a suffix.
|
|
92
|
+
res.writeHead(200, { 'content-type': 'application/octet-stream' });
|
|
93
|
+
res.end(PDF_BODY);
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if (path === '/aborted') {
|
|
98
|
+
res.writeHead(200, {
|
|
99
|
+
'content-type': 'application/pdf',
|
|
100
|
+
'content-length': '1000000',
|
|
101
|
+
'content-disposition': 'attachment; filename="broken.pdf"',
|
|
102
|
+
});
|
|
103
|
+
res.write('partial');
|
|
104
|
+
res.destroy();
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return false;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Start the download fixture server on an ephemeral port.
|
|
113
|
+
*
|
|
114
|
+
* @returns {Promise<{baseUrl: string, close: Function}>} Server handle
|
|
115
|
+
*/
|
|
116
|
+
export async function startDownloadServer() {
|
|
117
|
+
return startFixtureHost((path, req, res) => {
|
|
118
|
+
if (serveDownload(path, res)) {
|
|
119
|
+
return;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (path === '/') {
|
|
123
|
+
sendHtml(res, DOWNLOADS_PAGE);
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
sendNotFound(res);
|
|
128
|
+
});
|
|
129
|
+
}
|
|
@@ -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
|
|