@arraypress/extension-kit 0.0.0-stage → 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/LICENSE +21 -0
- package/README.md +217 -2
- package/bin/extension-kit.js +35 -0
- package/package.json +67 -4
- package/src/about/links.d.ts +51 -0
- package/src/about/links.js +48 -0
- package/src/build/duplication.d.ts +116 -0
- package/src/build/duplication.js +119 -0
- package/src/build/index.d.ts +11 -0
- package/src/build/index.js +12 -0
- package/src/build/release-checks.d.ts +36 -0
- package/src/build/release-checks.js +61 -0
- package/src/build/toolbar-icons.d.ts +96 -0
- package/src/build/toolbar-icons.js +101 -0
- package/src/build/vendor.d.ts +26 -0
- package/src/build/vendor.js +43 -0
- package/src/build/zip.d.ts +31 -0
- package/src/build/zip.js +47 -0
- package/src/commands/shortcuts.d.ts +39 -0
- package/src/commands/shortcuts.js +31 -0
- package/src/dom/i18n.d.ts +39 -0
- package/src/dom/i18n.js +49 -0
- package/src/dom/page.d.ts +59 -0
- package/src/dom/page.js +93 -0
- package/src/index.d.ts +21 -0
- package/src/index.js +24 -0
- package/src/settings/saver.d.ts +76 -0
- package/src/settings/saver.js +98 -0
- package/src/settings/storage.d.ts +91 -0
- package/src/settings/storage.js +76 -0
- package/src/settings/store.d.ts +138 -0
- package/src/settings/store.js +173 -0
- package/src/styles/chrome-ui.css +1094 -0
- package/src/testing/e2e.d.ts +222 -0
- package/src/testing/e2e.js +327 -0
- package/src/testing/fake-storage.d.ts +71 -0
- package/src/testing/fake-storage.js +145 -0
- package/src/testing/index.d.ts +29 -0
- package/src/testing/index.js +24 -0
- package/src/testing/package.d.ts +39 -0
- package/src/testing/package.js +96 -0
- package/src/testing/server.d.ts +47 -0
- package/src/testing/server.js +90 -0
- package/src/toolbar/offscreen.d.ts +30 -0
- package/src/toolbar/offscreen.js +37 -0
- package/src/toolbar/scheme.d.ts +81 -0
- package/src/toolbar/scheme.js +87 -0
- package/src/toolbar/theme.d.ts +187 -0
- package/src/toolbar/theme.js +176 -0
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for end-to-end tests that load an unpacked extension into
|
|
3
|
+
* Playwright's Chromium: launching it, waiting for a condition, emulating
|
|
4
|
+
* the colour scheme inside the offscreen document (which Playwright doesn't
|
|
5
|
+
* list as a page), and recording `chrome.action.setIcon` calls (Chrome has no
|
|
6
|
+
* getter for an action's icon).
|
|
7
|
+
*
|
|
8
|
+
* @module testing/e2e
|
|
9
|
+
*/
|
|
10
|
+
import type { BrowserContext, BrowserType, CDPSession, Page, Worker } from 'playwright';
|
|
11
|
+
export type LaunchedExtension = {
|
|
12
|
+
/**
|
|
13
|
+
* The persistent context it's loaded in.
|
|
14
|
+
*/
|
|
15
|
+
context: BrowserContext;
|
|
16
|
+
/**
|
|
17
|
+
* Its service worker.
|
|
18
|
+
*/
|
|
19
|
+
worker: Worker;
|
|
20
|
+
/**
|
|
21
|
+
* Its id, the host of its pages.
|
|
22
|
+
*/
|
|
23
|
+
extensionId: string;
|
|
24
|
+
/**
|
|
25
|
+
* `chrome-extension://<id>/<page>`.
|
|
26
|
+
*/
|
|
27
|
+
url: (page?: string) => string;
|
|
28
|
+
/**
|
|
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.
|
|
34
|
+
*/
|
|
35
|
+
close: () => Promise<void>;
|
|
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
|
+
};
|
|
51
|
+
export type LaunchOptions = NonNullable<Parameters<BrowserType['launchPersistentContext']>[1]> & {
|
|
52
|
+
prefix?: string;
|
|
53
|
+
};
|
|
54
|
+
/** @import { BrowserContext, BrowserType, CDPSession, Page, Worker } from 'playwright' */
|
|
55
|
+
/**
|
|
56
|
+
* A launched extension.
|
|
57
|
+
*
|
|
58
|
+
* @typedef {object} LaunchedExtension
|
|
59
|
+
* @property {BrowserContext} context The persistent context it's loaded in.
|
|
60
|
+
* @property {Worker} worker Its service worker.
|
|
61
|
+
* @property {string} extensionId Its id, the host of its pages.
|
|
62
|
+
* @property {(page?: string) => string} url `chrome-extension://<id>/<page>`.
|
|
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).
|
|
74
|
+
*/
|
|
75
|
+
/**
|
|
76
|
+
* What `launchExtension` takes: Playwright's persistent-context options, a
|
|
77
|
+
* name prefix for the temporary profile, and more Chromium switches.
|
|
78
|
+
*
|
|
79
|
+
* @typedef {NonNullable<Parameters<BrowserType['launchPersistentContext']>[1]> & {prefix?: string}} LaunchOptions
|
|
80
|
+
*/
|
|
81
|
+
/**
|
|
82
|
+
* Poll `fn` until it returns something truthy.
|
|
83
|
+
*
|
|
84
|
+
* @template T
|
|
85
|
+
* @param {() => T | Promise<T>} fn - The check.
|
|
86
|
+
* @param {string} what - What's awaited, for the timeout's message.
|
|
87
|
+
* @param {number} [timeout] - Give up after this many ms. Default 5000.
|
|
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).
|
|
90
|
+
* @throws {Error} "Timed out waiting for <what>".
|
|
91
|
+
*/
|
|
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>;
|
|
113
|
+
/**
|
|
114
|
+
* Load the unpacked extension at `dir` into a fresh profile and wait for its
|
|
115
|
+
* service worker.
|
|
116
|
+
*
|
|
117
|
+
* @param {BrowserType} chromium - Playwright's `chromium`.
|
|
118
|
+
* @param {string} dir - The unpacked extension.
|
|
119
|
+
* @param {LaunchOptions} [options] - Playwright's context options (`locale`, `viewport`, …), plus
|
|
120
|
+
* `prefix` (the temporary profile's name prefix) and `args` (more Chromium switches).
|
|
121
|
+
* @returns {Promise<LaunchedExtension>} The running extension; `close()` deletes the profile too.
|
|
122
|
+
* @example
|
|
123
|
+
* const { context, worker, url, close } = await launchExtension(chromium, 'extension');
|
|
124
|
+
* const page = await context.newPage();
|
|
125
|
+
* await page.goto(url('options/options.html'));
|
|
126
|
+
*/
|
|
127
|
+
export declare function launchExtension(chromium: BrowserType, dir: string, options?: LaunchOptions): Promise<LaunchedExtension>;
|
|
128
|
+
/**
|
|
129
|
+
* Emulate colour schemes in the offscreen document: one CDP session attached
|
|
130
|
+
* to it, reused for every change.
|
|
131
|
+
*
|
|
132
|
+
* @param {CDPSession} cdp - A browser-level session (`context.newCDPSession(page)`).
|
|
133
|
+
* @param {string} [suffix] - How the document's address ends. Default `/offscreen/offscreen.html`.
|
|
134
|
+
* @returns {Promise<(scheme: 'light' | 'dark') => Promise<unknown>>} Sets the document's scheme.
|
|
135
|
+
* @throws {Error} When no such document is open.
|
|
136
|
+
*/
|
|
137
|
+
export declare function offscreenEmulator(cdp: CDPSession, suffix?: string): Promise<(scheme: 'light' | 'dark') => Promise<unknown>>;
|
|
138
|
+
/**
|
|
139
|
+
* Wrap the worker's real `chrome.action.setIcon` so each call is recorded once
|
|
140
|
+
* Chrome has taken it (a path Chrome can't load fails, and isn't recorded).
|
|
141
|
+
*
|
|
142
|
+
* @param {Worker} worker - The extension's service worker.
|
|
143
|
+
* @returns {Promise<() => Promise<unknown[]>>} Reads the recorded `path`s, oldest first.
|
|
144
|
+
*/
|
|
145
|
+
export declare function recordIconCalls(worker: Worker): Promise<() => Promise<unknown[]>>;
|
|
146
|
+
export type CheckRunner = {
|
|
147
|
+
/**
|
|
148
|
+
* Run one check; resolves to whether it passed.
|
|
149
|
+
*/
|
|
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;
|
|
156
|
+
/**
|
|
157
|
+
* Print the tally, and exit with 1 if anything failed.
|
|
158
|
+
*/
|
|
159
|
+
finish: () => void;
|
|
160
|
+
};
|
|
161
|
+
/**
|
|
162
|
+
* A runner for a script of named checks: each failure is reported and the
|
|
163
|
+
* script goes on, so one run shows everything that's wrong.
|
|
164
|
+
*
|
|
165
|
+
* @typedef {object} CheckRunner
|
|
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.
|
|
169
|
+
* @property {() => void} finish Print the tally, and exit with 1 if anything failed.
|
|
170
|
+
*/
|
|
171
|
+
/**
|
|
172
|
+
* Make a {@link CheckRunner}: "✓ name" for a pass, "✗ name" and the error's
|
|
173
|
+
* message, indented, for a failure.
|
|
174
|
+
*
|
|
175
|
+
* @returns {CheckRunner} The runner.
|
|
176
|
+
* @example
|
|
177
|
+
* const { check, fail, finish } = checkRunner();
|
|
178
|
+
* page.on('pageerror', (error) => fail(`page error: ${error.message}`));
|
|
179
|
+
* await check('the menu opens', async () => { … });
|
|
180
|
+
* finish();
|
|
181
|
+
*/
|
|
182
|
+
export declare function checkRunner(): CheckRunner;
|
|
183
|
+
export type SchemePage = {
|
|
184
|
+
/**
|
|
185
|
+
* From the extension's root, e.g. `options/options.html`.
|
|
186
|
+
*/
|
|
187
|
+
path: string;
|
|
188
|
+
/**
|
|
189
|
+
* Resolves once the
|
|
190
|
+
* page's script has run. Default: the load event.
|
|
191
|
+
*/
|
|
192
|
+
ready?: (page: Page) => Promise<unknown>;
|
|
193
|
+
};
|
|
194
|
+
/**
|
|
195
|
+
* A page of the extension that reports its colour scheme.
|
|
196
|
+
*
|
|
197
|
+
* @typedef {object} SchemePage
|
|
198
|
+
* @property {string} path From the extension's root, e.g. `options/options.html`.
|
|
199
|
+
* @property {(page: Page) => Promise<unknown>} [ready] Resolves once the
|
|
200
|
+
* page's script has run. Default: the load event.
|
|
201
|
+
*/
|
|
202
|
+
/**
|
|
203
|
+
* The light/dark toolbar glyph, end to end, for an extension wired with
|
|
204
|
+
* `startToolbarTheme`, `watchSchemeOffscreen` and `watchColorScheme`:
|
|
205
|
+
*
|
|
206
|
+
* 1. the worker keeps exactly one offscreen document open, for MATCH_MEDIA;
|
|
207
|
+
* 2. each page shows the dark glyph when dark and the light one when light,
|
|
208
|
+
* a second page in an unchanged scheme sends nothing, and `toolbarDark`
|
|
209
|
+
* stays out of Chrome Sync;
|
|
210
|
+
* 3. with no page open, the offscreen document alone follows a change.
|
|
211
|
+
*
|
|
212
|
+
* Throws (an assertion) at the first thing that's wrong; logs a ✔ line per step.
|
|
213
|
+
*
|
|
214
|
+
* @param {LaunchedExtension} launched - From {@link launchExtension}.
|
|
215
|
+
* @param {readonly SchemePage[]} pages - The pages that report; the first is opened twice.
|
|
216
|
+
* @param {{light: unknown, dark: unknown}} icons - `toolbarIcon(false)` and `toolbarIcon(true)`.
|
|
217
|
+
* @returns {Promise<void>}
|
|
218
|
+
*/
|
|
219
|
+
export declare function checkToolbarTheme({ context, worker, url }: LaunchedExtension, pages: readonly SchemePage[], icons: {
|
|
220
|
+
light: unknown;
|
|
221
|
+
dark: unknown;
|
|
222
|
+
}): Promise<void>;
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Helpers for end-to-end tests that load an unpacked extension into
|
|
3
|
+
* Playwright's Chromium: launching it, waiting for a condition, emulating
|
|
4
|
+
* the colour scheme inside the offscreen document (which Playwright doesn't
|
|
5
|
+
* list as a page), and recording `chrome.action.setIcon` calls (Chrome has no
|
|
6
|
+
* getter for an action's icon).
|
|
7
|
+
*
|
|
8
|
+
* @module testing/e2e
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import path from 'node:path';
|
|
15
|
+
|
|
16
|
+
/** @import { BrowserContext, BrowserType, CDPSession, Page, Worker } from 'playwright' */
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A launched extension.
|
|
20
|
+
*
|
|
21
|
+
* @typedef {object} LaunchedExtension
|
|
22
|
+
* @property {BrowserContext} context The persistent context it's loaded in.
|
|
23
|
+
* @property {Worker} worker Its service worker.
|
|
24
|
+
* @property {string} extensionId Its id, the host of its pages.
|
|
25
|
+
* @property {(page?: string) => string} url `chrome-extension://<id>/<page>`.
|
|
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).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* What `launchExtension` takes: Playwright's persistent-context options, a
|
|
42
|
+
* name prefix for the temporary profile, and more Chromium switches.
|
|
43
|
+
*
|
|
44
|
+
* @typedef {NonNullable<Parameters<BrowserType['launchPersistentContext']>[1]> & {prefix?: string}} LaunchOptions
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Poll `fn` until it returns something truthy.
|
|
49
|
+
*
|
|
50
|
+
* @template T
|
|
51
|
+
* @param {() => T | Promise<T>} fn - The check.
|
|
52
|
+
* @param {string} what - What's awaited, for the timeout's message.
|
|
53
|
+
* @param {number} [timeout] - Give up after this many ms. Default 5000.
|
|
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).
|
|
56
|
+
* @throws {Error} "Timed out waiting for <what>".
|
|
57
|
+
*/
|
|
58
|
+
export async function until(fn, what, timeout = 5000) {
|
|
59
|
+
const end = Date.now() + timeout;
|
|
60
|
+
for (;;) {
|
|
61
|
+
const value = await fn();
|
|
62
|
+
if (value) return /** @type {Exclude<T, false | 0 | 0n | '' | null | undefined>} */ (value);
|
|
63
|
+
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
|
|
64
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
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
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Load the unpacked extension at `dir` into a fresh profile and wait for its
|
|
115
|
+
* service worker.
|
|
116
|
+
*
|
|
117
|
+
* @param {BrowserType} chromium - Playwright's `chromium`.
|
|
118
|
+
* @param {string} dir - The unpacked extension.
|
|
119
|
+
* @param {LaunchOptions} [options] - Playwright's context options (`locale`, `viewport`, …), plus
|
|
120
|
+
* `prefix` (the temporary profile's name prefix) and `args` (more Chromium switches).
|
|
121
|
+
* @returns {Promise<LaunchedExtension>} The running extension; `close()` deletes the profile too.
|
|
122
|
+
* @example
|
|
123
|
+
* const { context, worker, url, close } = await launchExtension(chromium, 'extension');
|
|
124
|
+
* const page = await context.newPage();
|
|
125
|
+
* await page.goto(url('options/options.html'));
|
|
126
|
+
*/
|
|
127
|
+
export async function launchExtension(chromium, dir, options = {}) {
|
|
128
|
+
const profile = await extensionProfile(chromium, dir, options);
|
|
129
|
+
const launched = await profile.launch();
|
|
130
|
+
return {
|
|
131
|
+
...launched,
|
|
132
|
+
async close() {
|
|
133
|
+
await launched.close();
|
|
134
|
+
await profile.remove();
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Emulate colour schemes in the offscreen document: one CDP session attached
|
|
141
|
+
* to it, reused for every change.
|
|
142
|
+
*
|
|
143
|
+
* @param {CDPSession} cdp - A browser-level session (`context.newCDPSession(page)`).
|
|
144
|
+
* @param {string} [suffix] - How the document's address ends. Default `/offscreen/offscreen.html`.
|
|
145
|
+
* @returns {Promise<(scheme: 'light' | 'dark') => Promise<unknown>>} Sets the document's scheme.
|
|
146
|
+
* @throws {Error} When no such document is open.
|
|
147
|
+
*/
|
|
148
|
+
export async function offscreenEmulator(cdp, suffix = '/offscreen/offscreen.html') {
|
|
149
|
+
const { targetInfos } = await cdp.send('Target.getTargets');
|
|
150
|
+
const target = targetInfos.find((info) => info.url.endsWith(suffix));
|
|
151
|
+
if (!target) throw new Error(`No offscreen document ending ${suffix} is open`);
|
|
152
|
+
const { sessionId } = await cdp.send('Target.attachToTarget', { targetId: target.targetId, flatten: false });
|
|
153
|
+
let id = 0;
|
|
154
|
+
return (scheme) => cdp.send('Target.sendMessageToTarget', {
|
|
155
|
+
sessionId,
|
|
156
|
+
message: JSON.stringify({
|
|
157
|
+
id: ++id,
|
|
158
|
+
method: 'Emulation.setEmulatedMedia',
|
|
159
|
+
params: { features: [{ name: 'prefers-color-scheme', value: scheme }] },
|
|
160
|
+
}),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Wrap the worker's real `chrome.action.setIcon` so each call is recorded once
|
|
166
|
+
* Chrome has taken it (a path Chrome can't load fails, and isn't recorded).
|
|
167
|
+
*
|
|
168
|
+
* @param {Worker} worker - The extension's service worker.
|
|
169
|
+
* @returns {Promise<() => Promise<unknown[]>>} Reads the recorded `path`s, oldest first.
|
|
170
|
+
*/
|
|
171
|
+
export async function recordIconCalls(worker) {
|
|
172
|
+
await worker.evaluate(() => {
|
|
173
|
+
const calls = /** @type {unknown[]} */ ([]);
|
|
174
|
+
Object.assign(globalThis, { iconCalls: calls });
|
|
175
|
+
const real = chrome.action.setIcon.bind(chrome.action);
|
|
176
|
+
chrome.action.setIcon = /** @type {typeof chrome.action.setIcon} */ (async (/** @type {chrome.action.TabIconDetails} */ details) => {
|
|
177
|
+
await real(details);
|
|
178
|
+
calls.push(details.path);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
return () => worker.evaluate(() => /** @type {{iconCalls: unknown[]}} */ (/** @type {unknown} */ (globalThis)).iconCalls);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* A runner for a script of named checks: each failure is reported and the
|
|
186
|
+
* script goes on, so one run shows everything that's wrong.
|
|
187
|
+
*
|
|
188
|
+
* @typedef {object} CheckRunner
|
|
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.
|
|
192
|
+
* @property {() => void} finish Print the tally, and exit with 1 if anything failed.
|
|
193
|
+
*/
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Make a {@link CheckRunner}: "✓ name" for a pass, "✗ name" and the error's
|
|
197
|
+
* message, indented, for a failure.
|
|
198
|
+
*
|
|
199
|
+
* @returns {CheckRunner} The runner.
|
|
200
|
+
* @example
|
|
201
|
+
* const { check, fail, finish } = checkRunner();
|
|
202
|
+
* page.on('pageerror', (error) => fail(`page error: ${error.message}`));
|
|
203
|
+
* await check('the menu opens', async () => { … });
|
|
204
|
+
* finish();
|
|
205
|
+
*/
|
|
206
|
+
export function checkRunner() {
|
|
207
|
+
/** @type {string[]} */
|
|
208
|
+
const failures = [];
|
|
209
|
+
let passed = 0;
|
|
210
|
+
return {
|
|
211
|
+
async check(name, fn) {
|
|
212
|
+
try {
|
|
213
|
+
await fn();
|
|
214
|
+
passed++;
|
|
215
|
+
console.log(` ✓ ${name}`);
|
|
216
|
+
return true;
|
|
217
|
+
} catch (error) {
|
|
218
|
+
failures.push(name);
|
|
219
|
+
console.log(` ✗ ${name}\n ${String(/** @type {Error} */ (error)?.message ?? error).split('\n').join('\n ')}`);
|
|
220
|
+
return false;
|
|
221
|
+
}
|
|
222
|
+
},
|
|
223
|
+
fail(name) {
|
|
224
|
+
failures.push(name);
|
|
225
|
+
console.log(` ✗ ${name}`);
|
|
226
|
+
},
|
|
227
|
+
finish() {
|
|
228
|
+
console.log(`\n${passed} passed, ${failures.length} failed`);
|
|
229
|
+
if (!failures.length) return;
|
|
230
|
+
for (const failure of failures) console.log(` - ${failure}`);
|
|
231
|
+
process.exit(1);
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* A page of the extension that reports its colour scheme.
|
|
238
|
+
*
|
|
239
|
+
* @typedef {object} SchemePage
|
|
240
|
+
* @property {string} path From the extension's root, e.g. `options/options.html`.
|
|
241
|
+
* @property {(page: Page) => Promise<unknown>} [ready] Resolves once the
|
|
242
|
+
* page's script has run. Default: the load event.
|
|
243
|
+
*/
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* The light/dark toolbar glyph, end to end, for an extension wired with
|
|
247
|
+
* `startToolbarTheme`, `watchSchemeOffscreen` and `watchColorScheme`:
|
|
248
|
+
*
|
|
249
|
+
* 1. the worker keeps exactly one offscreen document open, for MATCH_MEDIA;
|
|
250
|
+
* 2. each page shows the dark glyph when dark and the light one when light,
|
|
251
|
+
* a second page in an unchanged scheme sends nothing, and `toolbarDark`
|
|
252
|
+
* stays out of Chrome Sync;
|
|
253
|
+
* 3. with no page open, the offscreen document alone follows a change.
|
|
254
|
+
*
|
|
255
|
+
* Throws (an assertion) at the first thing that's wrong; logs a ✔ line per step.
|
|
256
|
+
*
|
|
257
|
+
* @param {LaunchedExtension} launched - From {@link launchExtension}.
|
|
258
|
+
* @param {readonly SchemePage[]} pages - The pages that report; the first is opened twice.
|
|
259
|
+
* @param {{light: unknown, dark: unknown}} icons - `toolbarIcon(false)` and `toolbarIcon(true)`.
|
|
260
|
+
* @returns {Promise<void>}
|
|
261
|
+
*/
|
|
262
|
+
export async function checkToolbarTheme({ context, worker, url }, pages, icons) {
|
|
263
|
+
/** @param {Page} page @param {SchemePage} entry */
|
|
264
|
+
const open = async (page, entry) => {
|
|
265
|
+
await page.goto(url(entry.path));
|
|
266
|
+
await (entry.ready ? entry.ready(page) : page.waitForLoadState('load'));
|
|
267
|
+
};
|
|
268
|
+
|
|
269
|
+
// 1. The watcher. Right after creation Chrome can list the document before its address is set.
|
|
270
|
+
assert.equal(await worker.evaluate(() => chrome.offscreen.Reason.MATCH_MEDIA), 'MATCH_MEDIA');
|
|
271
|
+
const offscreen = await until(async () => {
|
|
272
|
+
const found = await worker.evaluate(() => chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] }));
|
|
273
|
+
return found.length > 0 && found.every((info) => info.documentUrl?.endsWith('/offscreen/offscreen.html')) ? found : null;
|
|
274
|
+
}, 'the offscreen document');
|
|
275
|
+
assert.equal(offscreen.length, 1);
|
|
276
|
+
assert.equal(offscreen[0].documentUrl, url('offscreen/offscreen.html'));
|
|
277
|
+
console.log('✔ the background keeps one offscreen document open, for MATCH_MEDIA');
|
|
278
|
+
|
|
279
|
+
const calls = await recordIconCalls(worker);
|
|
280
|
+
const stored = () => worker.evaluate(async () => (await chrome.storage.local.get('toolbarDark')).toolbarDark);
|
|
281
|
+
const last = async () => JSON.stringify((await calls()).at(-1));
|
|
282
|
+
/** @param {unknown} icon @param {string} what */
|
|
283
|
+
const untilIcon = (icon, what) => until(async () => (await last()) === JSON.stringify(icon), what);
|
|
284
|
+
|
|
285
|
+
// The offscreen document follows this machine's real scheme: take it to dark, then light, so
|
|
286
|
+
// every step below starts from a known state (stored light, the document last saw light).
|
|
287
|
+
const blank = await context.newPage();
|
|
288
|
+
const cdp = await context.newCDPSession(blank);
|
|
289
|
+
const emulateOffscreen = await offscreenEmulator(cdp);
|
|
290
|
+
await emulateOffscreen('dark');
|
|
291
|
+
await until(async () => (await stored()) === true, 'the offscreen document to report dark');
|
|
292
|
+
await emulateOffscreen('light');
|
|
293
|
+
await until(async () => (await stored()) === false, 'the offscreen document to report light');
|
|
294
|
+
|
|
295
|
+
// 2. Each page, dark then light; a second page in the same scheme sends nothing.
|
|
296
|
+
for (const entry of pages) {
|
|
297
|
+
const page = await context.newPage();
|
|
298
|
+
await page.emulateMedia({ colorScheme: 'light' });
|
|
299
|
+
await open(page, entry);
|
|
300
|
+
await page.emulateMedia({ colorScheme: 'dark' });
|
|
301
|
+
await until(async () => (await stored()) === true, `${entry.path}: the dark scheme stored`);
|
|
302
|
+
await untilIcon(icons.dark, `${entry.path}: the dark glyph`);
|
|
303
|
+
await page.emulateMedia({ colorScheme: 'light' });
|
|
304
|
+
await until(async () => (await stored()) === false, `${entry.path}: the light scheme stored`);
|
|
305
|
+
await untilIcon(icons.light, `${entry.path}: the light glyph`);
|
|
306
|
+
await page.close();
|
|
307
|
+
}
|
|
308
|
+
const before = (await calls()).length;
|
|
309
|
+
const again = await context.newPage();
|
|
310
|
+
await again.emulateMedia({ colorScheme: 'light' });
|
|
311
|
+
await open(again, pages[0]);
|
|
312
|
+
await again.waitForTimeout(300);
|
|
313
|
+
assert.equal((await calls()).length, before);
|
|
314
|
+
await again.close();
|
|
315
|
+
assert.equal('toolbarDark' in await worker.evaluate(() => chrome.storage.sync.get(null)), false);
|
|
316
|
+
console.log('✔ a dark page shows the dark glyph, light the light one, a second light page nothing; toolbarDark stays in local');
|
|
317
|
+
|
|
318
|
+
// 3. No page open: the offscreen document alone follows a change.
|
|
319
|
+
for (const page of context.pages()) if (page.url().startsWith('chrome-extension://')) await page.close();
|
|
320
|
+
await emulateOffscreen('dark');
|
|
321
|
+
await untilIcon(icons.dark, 'the dark glyph from the offscreen document');
|
|
322
|
+
await emulateOffscreen('light');
|
|
323
|
+
await untilIcon(icons.light, 'the light glyph from the offscreen document');
|
|
324
|
+
await cdp.detach();
|
|
325
|
+
await blank.close();
|
|
326
|
+
console.log('✔ with no page open, the offscreen document swaps the glyph on a scheme change');
|
|
327
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An in-memory `chrome.storage` for tests, behaving as Chrome does: values
|
|
3
|
+
* are stored as JSON, `onChanged` fires only for values that changed, and
|
|
4
|
+
* sync refuses an item over its 8,192-byte quota and more than 120 writes
|
|
5
|
+
* (set or remove) a minute. Tests then check outcomes (what's stored), not
|
|
6
|
+
* calls.
|
|
7
|
+
*
|
|
8
|
+
* @module testing/fake-storage
|
|
9
|
+
*/
|
|
10
|
+
import type { StorageChange } from '../settings/storage.js';
|
|
11
|
+
/** @import { StorageChange } from '../settings/storage.js' */
|
|
12
|
+
/** Chrome Sync's write limit: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE`. */
|
|
13
|
+
export declare const SYNC_WRITES_PER_MINUTE = 120;
|
|
14
|
+
export type ChangeListener = (changes: Record<string, StorageChange>, area: string) => unknown;
|
|
15
|
+
export type FakeArea = {
|
|
16
|
+
/**
|
|
17
|
+
* What it holds, to inspect (and seed) in tests.
|
|
18
|
+
*/
|
|
19
|
+
data: Record<string, any>;
|
|
20
|
+
/**
|
|
21
|
+
* How many writes it has taken.
|
|
22
|
+
*/
|
|
23
|
+
writes: number;
|
|
24
|
+
get: (keys?: string | string[] | null) => Promise<Record<string, any>>;
|
|
25
|
+
set: (items: Record<string, unknown>) => Promise<void>;
|
|
26
|
+
remove: (keys: string | string[]) => Promise<void>;
|
|
27
|
+
};
|
|
28
|
+
export type FakeChromeStorage = {
|
|
29
|
+
/**
|
|
30
|
+
* `chrome.storage.local`.
|
|
31
|
+
*/
|
|
32
|
+
local: FakeArea;
|
|
33
|
+
/**
|
|
34
|
+
* `chrome.storage.sync`, with Chrome's quotas.
|
|
35
|
+
*/
|
|
36
|
+
sync: FakeArea;
|
|
37
|
+
/**
|
|
38
|
+
* `chrome.storage.session`.
|
|
39
|
+
*/
|
|
40
|
+
session: FakeArea;
|
|
41
|
+
/**
|
|
42
|
+
* `chrome.storage.onChanged`.
|
|
43
|
+
*/
|
|
44
|
+
onChanged: {
|
|
45
|
+
addListener(listener: ChangeListener): void;
|
|
46
|
+
};
|
|
47
|
+
/**
|
|
48
|
+
* Waits for every listener, including the changes those listeners make.
|
|
49
|
+
*/
|
|
50
|
+
settled: () => Promise<void>;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* Make a fake `chrome.storage`.
|
|
54
|
+
*
|
|
55
|
+
* @param {object} [initial] - Starting contents and the clock.
|
|
56
|
+
* @param {Record<string, unknown>} [initial.local] - What local holds.
|
|
57
|
+
* @param {Record<string, unknown>} [initial.sync] - What sync holds.
|
|
58
|
+
* @param {Record<string, unknown>} [initial.session] - What session holds.
|
|
59
|
+
* @param {() => number} [initial.now] - The clock sync's write limit reads. Default `Date.now`.
|
|
60
|
+
* @returns {FakeChromeStorage} The fake.
|
|
61
|
+
* @example
|
|
62
|
+
* const storage = fakeChromeStorage({ sync: { days: 3 } });
|
|
63
|
+
* await store.save(storage, { days: 5 });
|
|
64
|
+
* assert.equal(storage.sync.data.days, 5);
|
|
65
|
+
*/
|
|
66
|
+
export declare function fakeChromeStorage({ local, sync, session, now }?: {
|
|
67
|
+
local?: Record<string, unknown>;
|
|
68
|
+
sync?: Record<string, unknown>;
|
|
69
|
+
session?: Record<string, unknown>;
|
|
70
|
+
now?: () => number;
|
|
71
|
+
}): FakeChromeStorage;
|