@arraypress/extension-kit 1.0.0 → 1.1.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/README.md +4 -1
- package/package.json +1 -1
- package/src/testing/e2e.d.ts +64 -7
- package/src/testing/e2e.js +75 -20
- package/src/testing/index.d.ts +22 -2
- package/src/testing/index.js +16 -2
- package/src/testing/server.d.ts +47 -0
- package/src/testing/server.js +90 -0
package/README.md
CHANGED
|
@@ -157,7 +157,10 @@ except those listed, each with a reason:
|
|
|
157
157
|
| Export | What it does |
|
|
158
158
|
|---|---|
|
|
159
159
|
| `fakeChromeStorage({local, sync, session, now})` | In-memory `chrome.storage`: JSON copies, `onChanged` for real changes only, sync's 8,192-byte item quota and 120 writes a minute. `settled()` waits for listeners. |
|
|
160
|
-
| `launchExtension(chromium, dir, options)` | Load an unpacked extension into a fresh profile; `{context, worker, extensionId, url(page), close()}`. |
|
|
160
|
+
| `launchExtension(chromium, dir, options)` | Load an unpacked extension into a fresh profile; `{context, worker, extensionId, url(page), profile, close()}`. |
|
|
161
|
+
| `extensionProfile(chromium, dir, options)` | A profile to start the browser on more than once (a restart, a first start with a changed manifest): `{dir, launch(), remove()}`. |
|
|
162
|
+
| `checkRunner()` | Named checks that report and go on: `check(name, fn)`, `fail(name)` for an error found outside a check, `finish()`. |
|
|
163
|
+
| `serve(routes, {port})` | Serve test pages on 127.0.0.1 from a table, a `Map` or a function of the path; `{origin, port, server, close()}`. |
|
|
161
164
|
| `until(fn, what, timeout?)` | Poll until truthy. |
|
|
162
165
|
| `offscreenEmulator(cdp)` | Emulate light/dark inside the offscreen document. |
|
|
163
166
|
| `recordIconCalls(worker)` | Record `chrome.action.setIcon` calls Chrome accepted. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arraypress/extension-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "Shared pieces for Chrome extensions: Chrome's own page styles, a Chrome Sync settings store, the light/dark toolbar glyph, About links, and build and test helpers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/testing/e2e.d.ts
CHANGED
|
@@ -26,10 +26,28 @@ export type LaunchedExtension = {
|
|
|
26
26
|
*/
|
|
27
27
|
url: (page?: string) => string;
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
29
|
+
* The profile's folder (Chrome writes `DevToolsActivePort` there, for one).
|
|
30
|
+
*/
|
|
31
|
+
profile: string;
|
|
32
|
+
/**
|
|
33
|
+
* Close the browser; from {@link launchExtension}, delete its profile too.
|
|
30
34
|
*/
|
|
31
35
|
close: () => Promise<void>;
|
|
32
36
|
};
|
|
37
|
+
export type ExtensionProfile = {
|
|
38
|
+
/**
|
|
39
|
+
* The profile's folder.
|
|
40
|
+
*/
|
|
41
|
+
dir: string;
|
|
42
|
+
/**
|
|
43
|
+
* Start the browser on it; its `close()` keeps the profile.
|
|
44
|
+
*/
|
|
45
|
+
launch: () => Promise<LaunchedExtension>;
|
|
46
|
+
/**
|
|
47
|
+
* Delete the profile (close every launch first).
|
|
48
|
+
*/
|
|
49
|
+
remove: () => Promise<void>;
|
|
50
|
+
};
|
|
33
51
|
export type LaunchOptions = NonNullable<Parameters<BrowserType['launchPersistentContext']>[1]> & {
|
|
34
52
|
prefix?: string;
|
|
35
53
|
};
|
|
@@ -42,7 +60,17 @@ export type LaunchOptions = NonNullable<Parameters<BrowserType['launchPersistent
|
|
|
42
60
|
* @property {Worker} worker Its service worker.
|
|
43
61
|
* @property {string} extensionId Its id, the host of its pages.
|
|
44
62
|
* @property {(page?: string) => string} url `chrome-extension://<id>/<page>`.
|
|
45
|
-
* @property {
|
|
63
|
+
* @property {string} profile The profile's folder (Chrome writes `DevToolsActivePort` there, for one).
|
|
64
|
+
* @property {() => Promise<void>} close Close the browser; from {@link launchExtension}, delete its profile too.
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* A profile that outlives one browser start, for checks that something
|
|
68
|
+
* survives a restart (or that need a first start with a changed manifest).
|
|
69
|
+
*
|
|
70
|
+
* @typedef {object} ExtensionProfile
|
|
71
|
+
* @property {string} dir The profile's folder.
|
|
72
|
+
* @property {() => Promise<LaunchedExtension>} launch Start the browser on it; its `close()` keeps the profile.
|
|
73
|
+
* @property {() => Promise<void>} remove Delete the profile (close every launch first).
|
|
46
74
|
*/
|
|
47
75
|
/**
|
|
48
76
|
* What `launchExtension` takes: Playwright's persistent-context options, a
|
|
@@ -57,10 +85,31 @@ export type LaunchOptions = NonNullable<Parameters<BrowserType['launchPersistent
|
|
|
57
85
|
* @param {() => T | Promise<T>} fn - The check.
|
|
58
86
|
* @param {string} what - What's awaited, for the timeout's message.
|
|
59
87
|
* @param {number} [timeout] - Give up after this many ms. Default 5000.
|
|
60
|
-
* @returns {Promise<
|
|
88
|
+
* @returns {Promise<Exclude<T, false | 0 | 0n | '' | null | undefined>>} The first truthy value
|
|
89
|
+
* (typed without the falsy ones, so `until(() => ready && text, …)` is a string).
|
|
61
90
|
* @throws {Error} "Timed out waiting for <what>".
|
|
62
91
|
*/
|
|
63
|
-
export declare function until<T>(fn: () => T | Promise<T>, what: string, timeout?: number): Promise<
|
|
92
|
+
export declare function until<T>(fn: () => T | Promise<T>, what: string, timeout?: number): Promise<Exclude<T, false | 0 | 0n | '' | null | undefined>>;
|
|
93
|
+
/**
|
|
94
|
+
* A fresh profile for the unpacked extension at `dir`, to start the browser
|
|
95
|
+
* on as often as a test needs: each start loads the extension and waits for
|
|
96
|
+
* its service worker.
|
|
97
|
+
*
|
|
98
|
+
* @param {BrowserType} chromium - Playwright's `chromium`.
|
|
99
|
+
* @param {string} dir - The unpacked extension.
|
|
100
|
+
* @param {LaunchOptions} [options] - Playwright's context options (`locale`, `viewport`, …), plus
|
|
101
|
+
* `prefix` (the temporary profile's name prefix) and `args` (more Chromium switches).
|
|
102
|
+
* @returns {Promise<ExtensionProfile>} The profile.
|
|
103
|
+
* @example
|
|
104
|
+
* const profile = await extensionProfile(chromium, 'extension');
|
|
105
|
+
* const first = await profile.launch();
|
|
106
|
+
* await first.worker.evaluate(() => chrome.storage.local.set({seen: true}));
|
|
107
|
+
* await first.close();
|
|
108
|
+
* const { worker, close } = await profile.launch(); // the same storage, read as on any day
|
|
109
|
+
* await close();
|
|
110
|
+
* await profile.remove();
|
|
111
|
+
*/
|
|
112
|
+
export declare function extensionProfile(chromium: BrowserType, dir: string, { prefix, args, ...options }?: LaunchOptions): Promise<ExtensionProfile>;
|
|
64
113
|
/**
|
|
65
114
|
* Load the unpacked extension at `dir` into a fresh profile and wait for its
|
|
66
115
|
* service worker.
|
|
@@ -69,13 +118,13 @@ export declare function until<T>(fn: () => T | Promise<T>, what: string, timeout
|
|
|
69
118
|
* @param {string} dir - The unpacked extension.
|
|
70
119
|
* @param {LaunchOptions} [options] - Playwright's context options (`locale`, `viewport`, …), plus
|
|
71
120
|
* `prefix` (the temporary profile's name prefix) and `args` (more Chromium switches).
|
|
72
|
-
* @returns {Promise<LaunchedExtension>} The running extension.
|
|
121
|
+
* @returns {Promise<LaunchedExtension>} The running extension; `close()` deletes the profile too.
|
|
73
122
|
* @example
|
|
74
123
|
* const { context, worker, url, close } = await launchExtension(chromium, 'extension');
|
|
75
124
|
* const page = await context.newPage();
|
|
76
125
|
* await page.goto(url('options/options.html'));
|
|
77
126
|
*/
|
|
78
|
-
export declare function launchExtension(chromium: BrowserType, dir: string,
|
|
127
|
+
export declare function launchExtension(chromium: BrowserType, dir: string, options?: LaunchOptions): Promise<LaunchedExtension>;
|
|
79
128
|
/**
|
|
80
129
|
* Emulate colour schemes in the offscreen document: one CDP session attached
|
|
81
130
|
* to it, reused for every change.
|
|
@@ -99,6 +148,11 @@ export type CheckRunner = {
|
|
|
99
148
|
* Run one check; resolves to whether it passed.
|
|
100
149
|
*/
|
|
101
150
|
check: (name: string, fn: () => unknown) => Promise<boolean>;
|
|
151
|
+
/**
|
|
152
|
+
* Count a failure found outside any check (a page's
|
|
153
|
+
* uncaught error, say): printed now, listed and counted at the end.
|
|
154
|
+
*/
|
|
155
|
+
fail: (name: string) => void;
|
|
102
156
|
/**
|
|
103
157
|
* Print the tally, and exit with 1 if anything failed.
|
|
104
158
|
*/
|
|
@@ -110,6 +164,8 @@ export type CheckRunner = {
|
|
|
110
164
|
*
|
|
111
165
|
* @typedef {object} CheckRunner
|
|
112
166
|
* @property {(name: string, fn: () => unknown) => Promise<boolean>} check Run one check; resolves to whether it passed.
|
|
167
|
+
* @property {(name: string) => void} fail Count a failure found outside any check (a page's
|
|
168
|
+
* uncaught error, say): printed now, listed and counted at the end.
|
|
113
169
|
* @property {() => void} finish Print the tally, and exit with 1 if anything failed.
|
|
114
170
|
*/
|
|
115
171
|
/**
|
|
@@ -118,7 +174,8 @@ export type CheckRunner = {
|
|
|
118
174
|
*
|
|
119
175
|
* @returns {CheckRunner} The runner.
|
|
120
176
|
* @example
|
|
121
|
-
* const { check, finish } = checkRunner();
|
|
177
|
+
* const { check, fail, finish } = checkRunner();
|
|
178
|
+
* page.on('pageerror', (error) => fail(`page error: ${error.message}`));
|
|
122
179
|
* await check('the menu opens', async () => { … });
|
|
123
180
|
* finish();
|
|
124
181
|
*/
|
package/src/testing/e2e.js
CHANGED
|
@@ -23,7 +23,18 @@ import path from 'node:path';
|
|
|
23
23
|
* @property {Worker} worker Its service worker.
|
|
24
24
|
* @property {string} extensionId Its id, the host of its pages.
|
|
25
25
|
* @property {(page?: string) => string} url `chrome-extension://<id>/<page>`.
|
|
26
|
-
* @property {
|
|
26
|
+
* @property {string} profile The profile's folder (Chrome writes `DevToolsActivePort` there, for one).
|
|
27
|
+
* @property {() => Promise<void>} close Close the browser; from {@link launchExtension}, delete its profile too.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A profile that outlives one browser start, for checks that something
|
|
32
|
+
* survives a restart (or that need a first start with a changed manifest).
|
|
33
|
+
*
|
|
34
|
+
* @typedef {object} ExtensionProfile
|
|
35
|
+
* @property {string} dir The profile's folder.
|
|
36
|
+
* @property {() => Promise<LaunchedExtension>} launch Start the browser on it; its `close()` keeps the profile.
|
|
37
|
+
* @property {() => Promise<void>} remove Delete the profile (close every launch first).
|
|
27
38
|
*/
|
|
28
39
|
|
|
29
40
|
/**
|
|
@@ -40,19 +51,65 @@ import path from 'node:path';
|
|
|
40
51
|
* @param {() => T | Promise<T>} fn - The check.
|
|
41
52
|
* @param {string} what - What's awaited, for the timeout's message.
|
|
42
53
|
* @param {number} [timeout] - Give up after this many ms. Default 5000.
|
|
43
|
-
* @returns {Promise<
|
|
54
|
+
* @returns {Promise<Exclude<T, false | 0 | 0n | '' | null | undefined>>} The first truthy value
|
|
55
|
+
* (typed without the falsy ones, so `until(() => ready && text, …)` is a string).
|
|
44
56
|
* @throws {Error} "Timed out waiting for <what>".
|
|
45
57
|
*/
|
|
46
58
|
export async function until(fn, what, timeout = 5000) {
|
|
47
59
|
const end = Date.now() + timeout;
|
|
48
60
|
for (;;) {
|
|
49
61
|
const value = await fn();
|
|
50
|
-
if (value) return /** @type {
|
|
62
|
+
if (value) return /** @type {Exclude<T, false | 0 | 0n | '' | null | undefined>} */ (value);
|
|
51
63
|
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
|
|
52
64
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
53
65
|
}
|
|
54
66
|
}
|
|
55
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A fresh profile for the unpacked extension at `dir`, to start the browser
|
|
70
|
+
* on as often as a test needs: each start loads the extension and waits for
|
|
71
|
+
* its service worker.
|
|
72
|
+
*
|
|
73
|
+
* @param {BrowserType} chromium - Playwright's `chromium`.
|
|
74
|
+
* @param {string} dir - The unpacked extension.
|
|
75
|
+
* @param {LaunchOptions} [options] - Playwright's context options (`locale`, `viewport`, …), plus
|
|
76
|
+
* `prefix` (the temporary profile's name prefix) and `args` (more Chromium switches).
|
|
77
|
+
* @returns {Promise<ExtensionProfile>} The profile.
|
|
78
|
+
* @example
|
|
79
|
+
* const profile = await extensionProfile(chromium, 'extension');
|
|
80
|
+
* const first = await profile.launch();
|
|
81
|
+
* await first.worker.evaluate(() => chrome.storage.local.set({seen: true}));
|
|
82
|
+
* await first.close();
|
|
83
|
+
* const { worker, close } = await profile.launch(); // the same storage, read as on any day
|
|
84
|
+
* await close();
|
|
85
|
+
* await profile.remove();
|
|
86
|
+
*/
|
|
87
|
+
export async function extensionProfile(chromium, dir, { prefix = 'extension-', args = [], ...options } = {}) {
|
|
88
|
+
const tmp = await mkdtemp(path.join(tmpdir(), prefix));
|
|
89
|
+
const profile = path.join(tmp, 'profile');
|
|
90
|
+
return {
|
|
91
|
+
dir: profile,
|
|
92
|
+
async launch() {
|
|
93
|
+
const context = await chromium.launchPersistentContext(profile, {
|
|
94
|
+
channel: 'chromium',
|
|
95
|
+
...options,
|
|
96
|
+
args: [`--disable-extensions-except=${dir}`, `--load-extension=${dir}`, ...args],
|
|
97
|
+
});
|
|
98
|
+
const worker = context.serviceWorkers()[0] ?? await context.waitForEvent('serviceworker');
|
|
99
|
+
const extensionId = new URL(worker.url()).host;
|
|
100
|
+
return {
|
|
101
|
+
context,
|
|
102
|
+
worker,
|
|
103
|
+
extensionId,
|
|
104
|
+
profile,
|
|
105
|
+
url: (page = '') => `chrome-extension://${extensionId}/${page}`,
|
|
106
|
+
close: () => context.close(),
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
remove: () => rm(tmp, { recursive: true, force: true }),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
56
113
|
/**
|
|
57
114
|
* Load the unpacked extension at `dir` into a fresh profile and wait for its
|
|
58
115
|
* service worker.
|
|
@@ -61,29 +118,20 @@ export async function until(fn, what, timeout = 5000) {
|
|
|
61
118
|
* @param {string} dir - The unpacked extension.
|
|
62
119
|
* @param {LaunchOptions} [options] - Playwright's context options (`locale`, `viewport`, …), plus
|
|
63
120
|
* `prefix` (the temporary profile's name prefix) and `args` (more Chromium switches).
|
|
64
|
-
* @returns {Promise<LaunchedExtension>} The running extension.
|
|
121
|
+
* @returns {Promise<LaunchedExtension>} The running extension; `close()` deletes the profile too.
|
|
65
122
|
* @example
|
|
66
123
|
* const { context, worker, url, close } = await launchExtension(chromium, 'extension');
|
|
67
124
|
* const page = await context.newPage();
|
|
68
125
|
* await page.goto(url('options/options.html'));
|
|
69
126
|
*/
|
|
70
|
-
export async function launchExtension(chromium, dir,
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
channel: 'chromium',
|
|
74
|
-
...options,
|
|
75
|
-
args: [`--disable-extensions-except=${dir}`, `--load-extension=${dir}`, ...args],
|
|
76
|
-
});
|
|
77
|
-
const worker = context.serviceWorkers()[0] ?? await context.waitForEvent('serviceworker');
|
|
78
|
-
const extensionId = new URL(worker.url()).host;
|
|
127
|
+
export async function launchExtension(chromium, dir, options = {}) {
|
|
128
|
+
const profile = await extensionProfile(chromium, dir, options);
|
|
129
|
+
const launched = await profile.launch();
|
|
79
130
|
return {
|
|
80
|
-
|
|
81
|
-
worker,
|
|
82
|
-
extensionId,
|
|
83
|
-
url: (page = '') => `chrome-extension://${extensionId}/${page}`,
|
|
131
|
+
...launched,
|
|
84
132
|
async close() {
|
|
85
|
-
await
|
|
86
|
-
await
|
|
133
|
+
await launched.close();
|
|
134
|
+
await profile.remove();
|
|
87
135
|
},
|
|
88
136
|
};
|
|
89
137
|
}
|
|
@@ -139,6 +187,8 @@ export async function recordIconCalls(worker) {
|
|
|
139
187
|
*
|
|
140
188
|
* @typedef {object} CheckRunner
|
|
141
189
|
* @property {(name: string, fn: () => unknown) => Promise<boolean>} check Run one check; resolves to whether it passed.
|
|
190
|
+
* @property {(name: string) => void} fail Count a failure found outside any check (a page's
|
|
191
|
+
* uncaught error, say): printed now, listed and counted at the end.
|
|
142
192
|
* @property {() => void} finish Print the tally, and exit with 1 if anything failed.
|
|
143
193
|
*/
|
|
144
194
|
|
|
@@ -148,7 +198,8 @@ export async function recordIconCalls(worker) {
|
|
|
148
198
|
*
|
|
149
199
|
* @returns {CheckRunner} The runner.
|
|
150
200
|
* @example
|
|
151
|
-
* const { check, finish } = checkRunner();
|
|
201
|
+
* const { check, fail, finish } = checkRunner();
|
|
202
|
+
* page.on('pageerror', (error) => fail(`page error: ${error.message}`));
|
|
152
203
|
* await check('the menu opens', async () => { … });
|
|
153
204
|
* finish();
|
|
154
205
|
*/
|
|
@@ -169,6 +220,10 @@ export function checkRunner() {
|
|
|
169
220
|
return false;
|
|
170
221
|
}
|
|
171
222
|
},
|
|
223
|
+
fail(name) {
|
|
224
|
+
failures.push(name);
|
|
225
|
+
console.log(` ✗ ${name}`);
|
|
226
|
+
},
|
|
172
227
|
finish() {
|
|
173
228
|
console.log(`\n${passed} passed, ${failures.length} failed`);
|
|
174
229
|
if (!failures.length) return;
|
package/src/testing/index.d.ts
CHANGED
|
@@ -1,9 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
|
|
3
|
-
* end-to-end tests, and checks for a packaging
|
|
3
|
+
* end-to-end tests and a server for their pages, and checks for a packaging
|
|
4
|
+
* test. Node only.
|
|
4
5
|
*
|
|
5
6
|
* @module testing
|
|
6
7
|
*/
|
|
7
|
-
export { checkRunner, checkToolbarTheme, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
|
|
8
|
+
export { checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
|
|
9
|
+
export { serve } from './server.js';
|
|
8
10
|
export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
9
11
|
export { filesUnder, manifestReferences, missingReferences, zipEntries } from './package.js';
|
|
12
|
+
export type CheckRunner = import('./e2e.js').CheckRunner;
|
|
13
|
+
export type ExtensionProfile = import('./e2e.js').ExtensionProfile;
|
|
14
|
+
export type LaunchedExtension = import('./e2e.js').LaunchedExtension;
|
|
15
|
+
export type LaunchOptions = import('./e2e.js').LaunchOptions;
|
|
16
|
+
export type Reply = import('./server.js').Reply;
|
|
17
|
+
export type Routes = import('./server.js').Routes;
|
|
18
|
+
export type TestServer = import('./server.js').TestServer;
|
|
19
|
+
/**
|
|
20
|
+
* The helpers' types, for a test's JSDoc.
|
|
21
|
+
*
|
|
22
|
+
* @typedef {import('./e2e.js').CheckRunner} CheckRunner
|
|
23
|
+
* @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
|
|
24
|
+
* @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
|
|
25
|
+
* @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
|
|
26
|
+
* @typedef {import('./server.js').Reply} Reply
|
|
27
|
+
* @typedef {import('./server.js').Routes} Routes
|
|
28
|
+
* @typedef {import('./server.js').TestServer} TestServer
|
|
29
|
+
*/
|
package/src/testing/index.js
CHANGED
|
@@ -1,10 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
|
|
3
|
-
* end-to-end tests, and checks for a packaging
|
|
3
|
+
* end-to-end tests and a server for their pages, and checks for a packaging
|
|
4
|
+
* test. Node only.
|
|
4
5
|
*
|
|
5
6
|
* @module testing
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
export { checkRunner, checkToolbarTheme, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
|
|
9
|
+
export { checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
|
|
10
|
+
export { serve } from './server.js';
|
|
9
11
|
export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
10
12
|
export { filesUnder, manifestReferences, missingReferences, zipEntries } from './package.js';
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The helpers' types, for a test's JSDoc.
|
|
16
|
+
*
|
|
17
|
+
* @typedef {import('./e2e.js').CheckRunner} CheckRunner
|
|
18
|
+
* @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
|
|
19
|
+
* @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
|
|
20
|
+
* @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
|
|
21
|
+
* @typedef {import('./server.js').Reply} Reply
|
|
22
|
+
* @typedef {import('./server.js').Routes} Routes
|
|
23
|
+
* @typedef {import('./server.js').TestServer} TestServer
|
|
24
|
+
*/
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A local web server for end-to-end test pages: a site for the content
|
|
3
|
+
* script to run on, answering each path from a table (or a function), on
|
|
4
|
+
* 127.0.0.1 so a test build's host permission can cover it.
|
|
5
|
+
*
|
|
6
|
+
* @module testing/server
|
|
7
|
+
*/
|
|
8
|
+
import type { IncomingMessage, Server } from 'node:http';
|
|
9
|
+
export type Reply = string | Uint8Array | {
|
|
10
|
+
body: string | Uint8Array;
|
|
11
|
+
type?: string;
|
|
12
|
+
status?: number;
|
|
13
|
+
headers?: Record<string, string>;
|
|
14
|
+
};
|
|
15
|
+
export type Routes = Readonly<Record<string, Reply>> | ReadonlyMap<string, Reply> | ((pathname: string, request: IncomingMessage) => Reply | undefined | Promise<Reply | undefined>);
|
|
16
|
+
export type TestServer = {
|
|
17
|
+
/**
|
|
18
|
+
* Node's server.
|
|
19
|
+
*/
|
|
20
|
+
server: Server;
|
|
21
|
+
/**
|
|
22
|
+
* The port it listens on.
|
|
23
|
+
*/
|
|
24
|
+
port: number;
|
|
25
|
+
/**
|
|
26
|
+
* `http://127.0.0.1:<port>`.
|
|
27
|
+
*/
|
|
28
|
+
origin: string;
|
|
29
|
+
/**
|
|
30
|
+
* Drop open connections and stop listening.
|
|
31
|
+
*/
|
|
32
|
+
close: () => Promise<void>;
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Serve `routes` on 127.0.0.1.
|
|
36
|
+
*
|
|
37
|
+
* @param {Routes} routes - Path → page, or a function of the path.
|
|
38
|
+
* @param {{port?: number}} [options] - `port`: a fixed port (default: any free one).
|
|
39
|
+
* @returns {Promise<TestServer>} The listening server.
|
|
40
|
+
* @example
|
|
41
|
+
* const site = await serve({'/': '<!doctype html><textarea></textarea>'});
|
|
42
|
+
* await page.goto(`${site.origin}/`);
|
|
43
|
+
* await site.close();
|
|
44
|
+
*/
|
|
45
|
+
export declare function serve(routes: Routes, { port }?: {
|
|
46
|
+
port?: number;
|
|
47
|
+
}): Promise<TestServer>;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A local web server for end-to-end test pages: a site for the content
|
|
3
|
+
* script to run on, answering each path from a table (or a function), on
|
|
4
|
+
* 127.0.0.1 so a test build's host permission can cover it.
|
|
5
|
+
*
|
|
6
|
+
* @module testing/server
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { createServer } from 'node:http';
|
|
10
|
+
|
|
11
|
+
/** @import { IncomingMessage, Server } from 'node:http' */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* What a path answers: a body (sent as UTF-8 HTML), or a body with its own
|
|
15
|
+
* type, status and headers.
|
|
16
|
+
*
|
|
17
|
+
* @typedef {string | Uint8Array | {body: string | Uint8Array, type?: string, status?: number, headers?: Record<string, string>}} Reply
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The site: path (without the query) → reply, as an object or a `Map` (which
|
|
22
|
+
* a test may change as it goes), or a function of the path; no reply is a 404.
|
|
23
|
+
*
|
|
24
|
+
* @typedef {Readonly<Record<string, Reply>> | ReadonlyMap<string, Reply>
|
|
25
|
+
* | ((pathname: string, request: IncomingMessage) => Reply | undefined | Promise<Reply | undefined>)} Routes
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* A running test site.
|
|
30
|
+
*
|
|
31
|
+
* @typedef {object} TestServer
|
|
32
|
+
* @property {Server} server Node's server.
|
|
33
|
+
* @property {number} port The port it listens on.
|
|
34
|
+
* @property {string} origin `http://127.0.0.1:<port>`.
|
|
35
|
+
* @property {() => Promise<void>} close Drop open connections and stop listening.
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
const HTML = 'text/html; charset=utf-8';
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The reply for a path.
|
|
42
|
+
*
|
|
43
|
+
* @param {Routes} routes - The site.
|
|
44
|
+
* @param {string} pathname - The request's path.
|
|
45
|
+
* @param {IncomingMessage} request - The request.
|
|
46
|
+
* @returns {Promise<Reply | undefined>} What to send; `undefined` for a 404.
|
|
47
|
+
*/
|
|
48
|
+
async function replyFor(routes, pathname, request) {
|
|
49
|
+
if (typeof routes === 'function') return routes(pathname, request);
|
|
50
|
+
if (routes instanceof Map) return routes.get(pathname);
|
|
51
|
+
const table = /** @type {Readonly<Record<string, Reply>>} */ (routes);
|
|
52
|
+
return Object.hasOwn(table, pathname) ? table[pathname] : undefined;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Serve `routes` on 127.0.0.1.
|
|
57
|
+
*
|
|
58
|
+
* @param {Routes} routes - Path → page, or a function of the path.
|
|
59
|
+
* @param {{port?: number}} [options] - `port`: a fixed port (default: any free one).
|
|
60
|
+
* @returns {Promise<TestServer>} The listening server.
|
|
61
|
+
* @example
|
|
62
|
+
* const site = await serve({'/': '<!doctype html><textarea></textarea>'});
|
|
63
|
+
* await page.goto(`${site.origin}/`);
|
|
64
|
+
* await site.close();
|
|
65
|
+
*/
|
|
66
|
+
export async function serve(routes, { port = 0 } = {}) {
|
|
67
|
+
const server = createServer(async (request, response) => {
|
|
68
|
+
const { pathname } = new URL(request.url ?? '/', 'http://127.0.0.1');
|
|
69
|
+
const reply = await replyFor(routes, pathname, request);
|
|
70
|
+
if (reply === undefined) {
|
|
71
|
+
response.writeHead(404, { 'Content-Type': HTML });
|
|
72
|
+
response.end('');
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
const { body, type = HTML, status = 200, headers = {} } = typeof reply === 'string' || reply instanceof Uint8Array ? { body: reply } : reply;
|
|
76
|
+
response.writeHead(status, { 'Content-Type': type, ...headers });
|
|
77
|
+
response.end(body);
|
|
78
|
+
});
|
|
79
|
+
await new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve(undefined)));
|
|
80
|
+
const address = /** @type {import('node:net').AddressInfo} */ (server.address());
|
|
81
|
+
return {
|
|
82
|
+
server,
|
|
83
|
+
port: address.port,
|
|
84
|
+
origin: `http://127.0.0.1:${address.port}`,
|
|
85
|
+
close() {
|
|
86
|
+
server.closeAllConnections();
|
|
87
|
+
return new Promise((resolve) => server.close(() => resolve()));
|
|
88
|
+
},
|
|
89
|
+
};
|
|
90
|
+
}
|