@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.
Files changed (49) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +217 -2
  3. package/bin/extension-kit.js +35 -0
  4. package/package.json +67 -4
  5. package/src/about/links.d.ts +51 -0
  6. package/src/about/links.js +48 -0
  7. package/src/build/duplication.d.ts +116 -0
  8. package/src/build/duplication.js +119 -0
  9. package/src/build/index.d.ts +11 -0
  10. package/src/build/index.js +12 -0
  11. package/src/build/release-checks.d.ts +36 -0
  12. package/src/build/release-checks.js +61 -0
  13. package/src/build/toolbar-icons.d.ts +96 -0
  14. package/src/build/toolbar-icons.js +101 -0
  15. package/src/build/vendor.d.ts +26 -0
  16. package/src/build/vendor.js +43 -0
  17. package/src/build/zip.d.ts +31 -0
  18. package/src/build/zip.js +47 -0
  19. package/src/commands/shortcuts.d.ts +39 -0
  20. package/src/commands/shortcuts.js +31 -0
  21. package/src/dom/i18n.d.ts +39 -0
  22. package/src/dom/i18n.js +49 -0
  23. package/src/dom/page.d.ts +59 -0
  24. package/src/dom/page.js +93 -0
  25. package/src/index.d.ts +21 -0
  26. package/src/index.js +24 -0
  27. package/src/settings/saver.d.ts +76 -0
  28. package/src/settings/saver.js +98 -0
  29. package/src/settings/storage.d.ts +91 -0
  30. package/src/settings/storage.js +76 -0
  31. package/src/settings/store.d.ts +138 -0
  32. package/src/settings/store.js +173 -0
  33. package/src/styles/chrome-ui.css +1094 -0
  34. package/src/testing/e2e.d.ts +222 -0
  35. package/src/testing/e2e.js +327 -0
  36. package/src/testing/fake-storage.d.ts +71 -0
  37. package/src/testing/fake-storage.js +145 -0
  38. package/src/testing/index.d.ts +29 -0
  39. package/src/testing/index.js +24 -0
  40. package/src/testing/package.d.ts +39 -0
  41. package/src/testing/package.js +96 -0
  42. package/src/testing/server.d.ts +47 -0
  43. package/src/testing/server.js +90 -0
  44. package/src/toolbar/offscreen.d.ts +30 -0
  45. package/src/toolbar/offscreen.js +37 -0
  46. package/src/toolbar/scheme.d.ts +81 -0
  47. package/src/toolbar/scheme.js +87 -0
  48. package/src/toolbar/theme.d.ts +187 -0
  49. 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;