@arraypress/extension-kit 0.0.0-stage → 1.0.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 (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +214 -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 +165 -0
  35. package/src/testing/e2e.js +272 -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 +9 -0
  39. package/src/testing/index.js +10 -0
  40. package/src/testing/package.d.ts +39 -0
  41. package/src/testing/package.js +96 -0
  42. package/src/toolbar/offscreen.d.ts +30 -0
  43. package/src/toolbar/offscreen.js +37 -0
  44. package/src/toolbar/scheme.d.ts +81 -0
  45. package/src/toolbar/scheme.js +87 -0
  46. package/src/toolbar/theme.d.ts +187 -0
  47. package/src/toolbar/theme.js +176 -0
@@ -0,0 +1,165 @@
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
+ * Close the browser and delete its profile.
30
+ */
31
+ close: () => Promise<void>;
32
+ };
33
+ export type LaunchOptions = NonNullable<Parameters<BrowserType['launchPersistentContext']>[1]> & {
34
+ prefix?: string;
35
+ };
36
+ /** @import { BrowserContext, BrowserType, CDPSession, Page, Worker } from 'playwright' */
37
+ /**
38
+ * A launched extension.
39
+ *
40
+ * @typedef {object} LaunchedExtension
41
+ * @property {BrowserContext} context The persistent context it's loaded in.
42
+ * @property {Worker} worker Its service worker.
43
+ * @property {string} extensionId Its id, the host of its pages.
44
+ * @property {(page?: string) => string} url `chrome-extension://<id>/<page>`.
45
+ * @property {() => Promise<void>} close Close the browser and delete its profile.
46
+ */
47
+ /**
48
+ * What `launchExtension` takes: Playwright's persistent-context options, a
49
+ * name prefix for the temporary profile, and more Chromium switches.
50
+ *
51
+ * @typedef {NonNullable<Parameters<BrowserType['launchPersistentContext']>[1]> & {prefix?: string}} LaunchOptions
52
+ */
53
+ /**
54
+ * Poll `fn` until it returns something truthy.
55
+ *
56
+ * @template T
57
+ * @param {() => T | Promise<T>} fn - The check.
58
+ * @param {string} what - What's awaited, for the timeout's message.
59
+ * @param {number} [timeout] - Give up after this many ms. Default 5000.
60
+ * @returns {Promise<NonNullable<T>>} The first truthy value.
61
+ * @throws {Error} "Timed out waiting for <what>".
62
+ */
63
+ export declare function until<T>(fn: () => T | Promise<T>, what: string, timeout?: number): Promise<NonNullable<T>>;
64
+ /**
65
+ * Load the unpacked extension at `dir` into a fresh profile and wait for its
66
+ * service worker.
67
+ *
68
+ * @param {BrowserType} chromium - Playwright's `chromium`.
69
+ * @param {string} dir - The unpacked extension.
70
+ * @param {LaunchOptions} [options] - Playwright's context options (`locale`, `viewport`, …), plus
71
+ * `prefix` (the temporary profile's name prefix) and `args` (more Chromium switches).
72
+ * @returns {Promise<LaunchedExtension>} The running extension.
73
+ * @example
74
+ * const { context, worker, url, close } = await launchExtension(chromium, 'extension');
75
+ * const page = await context.newPage();
76
+ * await page.goto(url('options/options.html'));
77
+ */
78
+ export declare function launchExtension(chromium: BrowserType, dir: string, { prefix, args, ...options }?: LaunchOptions): Promise<LaunchedExtension>;
79
+ /**
80
+ * Emulate colour schemes in the offscreen document: one CDP session attached
81
+ * to it, reused for every change.
82
+ *
83
+ * @param {CDPSession} cdp - A browser-level session (`context.newCDPSession(page)`).
84
+ * @param {string} [suffix] - How the document's address ends. Default `/offscreen/offscreen.html`.
85
+ * @returns {Promise<(scheme: 'light' | 'dark') => Promise<unknown>>} Sets the document's scheme.
86
+ * @throws {Error} When no such document is open.
87
+ */
88
+ export declare function offscreenEmulator(cdp: CDPSession, suffix?: string): Promise<(scheme: 'light' | 'dark') => Promise<unknown>>;
89
+ /**
90
+ * Wrap the worker's real `chrome.action.setIcon` so each call is recorded once
91
+ * Chrome has taken it (a path Chrome can't load fails, and isn't recorded).
92
+ *
93
+ * @param {Worker} worker - The extension's service worker.
94
+ * @returns {Promise<() => Promise<unknown[]>>} Reads the recorded `path`s, oldest first.
95
+ */
96
+ export declare function recordIconCalls(worker: Worker): Promise<() => Promise<unknown[]>>;
97
+ export type CheckRunner = {
98
+ /**
99
+ * Run one check; resolves to whether it passed.
100
+ */
101
+ check: (name: string, fn: () => unknown) => Promise<boolean>;
102
+ /**
103
+ * Print the tally, and exit with 1 if anything failed.
104
+ */
105
+ finish: () => void;
106
+ };
107
+ /**
108
+ * A runner for a script of named checks: each failure is reported and the
109
+ * script goes on, so one run shows everything that's wrong.
110
+ *
111
+ * @typedef {object} CheckRunner
112
+ * @property {(name: string, fn: () => unknown) => Promise<boolean>} check Run one check; resolves to whether it passed.
113
+ * @property {() => void} finish Print the tally, and exit with 1 if anything failed.
114
+ */
115
+ /**
116
+ * Make a {@link CheckRunner}: "✓ name" for a pass, "✗ name" and the error's
117
+ * message, indented, for a failure.
118
+ *
119
+ * @returns {CheckRunner} The runner.
120
+ * @example
121
+ * const { check, finish } = checkRunner();
122
+ * await check('the menu opens', async () => { … });
123
+ * finish();
124
+ */
125
+ export declare function checkRunner(): CheckRunner;
126
+ export type SchemePage = {
127
+ /**
128
+ * From the extension's root, e.g. `options/options.html`.
129
+ */
130
+ path: string;
131
+ /**
132
+ * Resolves once the
133
+ * page's script has run. Default: the load event.
134
+ */
135
+ ready?: (page: Page) => Promise<unknown>;
136
+ };
137
+ /**
138
+ * A page of the extension that reports its colour scheme.
139
+ *
140
+ * @typedef {object} SchemePage
141
+ * @property {string} path From the extension's root, e.g. `options/options.html`.
142
+ * @property {(page: Page) => Promise<unknown>} [ready] Resolves once the
143
+ * page's script has run. Default: the load event.
144
+ */
145
+ /**
146
+ * The light/dark toolbar glyph, end to end, for an extension wired with
147
+ * `startToolbarTheme`, `watchSchemeOffscreen` and `watchColorScheme`:
148
+ *
149
+ * 1. the worker keeps exactly one offscreen document open, for MATCH_MEDIA;
150
+ * 2. each page shows the dark glyph when dark and the light one when light,
151
+ * a second page in an unchanged scheme sends nothing, and `toolbarDark`
152
+ * stays out of Chrome Sync;
153
+ * 3. with no page open, the offscreen document alone follows a change.
154
+ *
155
+ * Throws (an assertion) at the first thing that's wrong; logs a ✔ line per step.
156
+ *
157
+ * @param {LaunchedExtension} launched - From {@link launchExtension}.
158
+ * @param {readonly SchemePage[]} pages - The pages that report; the first is opened twice.
159
+ * @param {{light: unknown, dark: unknown}} icons - `toolbarIcon(false)` and `toolbarIcon(true)`.
160
+ * @returns {Promise<void>}
161
+ */
162
+ export declare function checkToolbarTheme({ context, worker, url }: LaunchedExtension, pages: readonly SchemePage[], icons: {
163
+ light: unknown;
164
+ dark: unknown;
165
+ }): Promise<void>;
@@ -0,0 +1,272 @@
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 {() => Promise<void>} close Close the browser and delete its profile.
27
+ */
28
+
29
+ /**
30
+ * What `launchExtension` takes: Playwright's persistent-context options, a
31
+ * name prefix for the temporary profile, and more Chromium switches.
32
+ *
33
+ * @typedef {NonNullable<Parameters<BrowserType['launchPersistentContext']>[1]> & {prefix?: string}} LaunchOptions
34
+ */
35
+
36
+ /**
37
+ * Poll `fn` until it returns something truthy.
38
+ *
39
+ * @template T
40
+ * @param {() => T | Promise<T>} fn - The check.
41
+ * @param {string} what - What's awaited, for the timeout's message.
42
+ * @param {number} [timeout] - Give up after this many ms. Default 5000.
43
+ * @returns {Promise<NonNullable<T>>} The first truthy value.
44
+ * @throws {Error} "Timed out waiting for <what>".
45
+ */
46
+ export async function until(fn, what, timeout = 5000) {
47
+ const end = Date.now() + timeout;
48
+ for (;;) {
49
+ const value = await fn();
50
+ if (value) return /** @type {NonNullable<T>} */ (value);
51
+ if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
52
+ await new Promise((resolve) => setTimeout(resolve, 50));
53
+ }
54
+ }
55
+
56
+ /**
57
+ * Load the unpacked extension at `dir` into a fresh profile and wait for its
58
+ * service worker.
59
+ *
60
+ * @param {BrowserType} chromium - Playwright's `chromium`.
61
+ * @param {string} dir - The unpacked extension.
62
+ * @param {LaunchOptions} [options] - Playwright's context options (`locale`, `viewport`, …), plus
63
+ * `prefix` (the temporary profile's name prefix) and `args` (more Chromium switches).
64
+ * @returns {Promise<LaunchedExtension>} The running extension.
65
+ * @example
66
+ * const { context, worker, url, close } = await launchExtension(chromium, 'extension');
67
+ * const page = await context.newPage();
68
+ * await page.goto(url('options/options.html'));
69
+ */
70
+ export async function launchExtension(chromium, dir, { prefix = 'extension-', args = [], ...options } = {}) {
71
+ const tmp = await mkdtemp(path.join(tmpdir(), prefix));
72
+ const context = await chromium.launchPersistentContext(path.join(tmp, 'profile'), {
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;
79
+ return {
80
+ context,
81
+ worker,
82
+ extensionId,
83
+ url: (page = '') => `chrome-extension://${extensionId}/${page}`,
84
+ async close() {
85
+ await context.close();
86
+ await rm(tmp, { recursive: true, force: true });
87
+ },
88
+ };
89
+ }
90
+
91
+ /**
92
+ * Emulate colour schemes in the offscreen document: one CDP session attached
93
+ * to it, reused for every change.
94
+ *
95
+ * @param {CDPSession} cdp - A browser-level session (`context.newCDPSession(page)`).
96
+ * @param {string} [suffix] - How the document's address ends. Default `/offscreen/offscreen.html`.
97
+ * @returns {Promise<(scheme: 'light' | 'dark') => Promise<unknown>>} Sets the document's scheme.
98
+ * @throws {Error} When no such document is open.
99
+ */
100
+ export async function offscreenEmulator(cdp, suffix = '/offscreen/offscreen.html') {
101
+ const { targetInfos } = await cdp.send('Target.getTargets');
102
+ const target = targetInfos.find((info) => info.url.endsWith(suffix));
103
+ if (!target) throw new Error(`No offscreen document ending ${suffix} is open`);
104
+ const { sessionId } = await cdp.send('Target.attachToTarget', { targetId: target.targetId, flatten: false });
105
+ let id = 0;
106
+ return (scheme) => cdp.send('Target.sendMessageToTarget', {
107
+ sessionId,
108
+ message: JSON.stringify({
109
+ id: ++id,
110
+ method: 'Emulation.setEmulatedMedia',
111
+ params: { features: [{ name: 'prefers-color-scheme', value: scheme }] },
112
+ }),
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Wrap the worker's real `chrome.action.setIcon` so each call is recorded once
118
+ * Chrome has taken it (a path Chrome can't load fails, and isn't recorded).
119
+ *
120
+ * @param {Worker} worker - The extension's service worker.
121
+ * @returns {Promise<() => Promise<unknown[]>>} Reads the recorded `path`s, oldest first.
122
+ */
123
+ export async function recordIconCalls(worker) {
124
+ await worker.evaluate(() => {
125
+ const calls = /** @type {unknown[]} */ ([]);
126
+ Object.assign(globalThis, { iconCalls: calls });
127
+ const real = chrome.action.setIcon.bind(chrome.action);
128
+ chrome.action.setIcon = /** @type {typeof chrome.action.setIcon} */ (async (/** @type {chrome.action.TabIconDetails} */ details) => {
129
+ await real(details);
130
+ calls.push(details.path);
131
+ });
132
+ });
133
+ return () => worker.evaluate(() => /** @type {{iconCalls: unknown[]}} */ (/** @type {unknown} */ (globalThis)).iconCalls);
134
+ }
135
+
136
+ /**
137
+ * A runner for a script of named checks: each failure is reported and the
138
+ * script goes on, so one run shows everything that's wrong.
139
+ *
140
+ * @typedef {object} CheckRunner
141
+ * @property {(name: string, fn: () => unknown) => Promise<boolean>} check Run one check; resolves to whether it passed.
142
+ * @property {() => void} finish Print the tally, and exit with 1 if anything failed.
143
+ */
144
+
145
+ /**
146
+ * Make a {@link CheckRunner}: "✓ name" for a pass, "✗ name" and the error's
147
+ * message, indented, for a failure.
148
+ *
149
+ * @returns {CheckRunner} The runner.
150
+ * @example
151
+ * const { check, finish } = checkRunner();
152
+ * await check('the menu opens', async () => { … });
153
+ * finish();
154
+ */
155
+ export function checkRunner() {
156
+ /** @type {string[]} */
157
+ const failures = [];
158
+ let passed = 0;
159
+ return {
160
+ async check(name, fn) {
161
+ try {
162
+ await fn();
163
+ passed++;
164
+ console.log(` ✓ ${name}`);
165
+ return true;
166
+ } catch (error) {
167
+ failures.push(name);
168
+ console.log(` ✗ ${name}\n ${String(/** @type {Error} */ (error)?.message ?? error).split('\n').join('\n ')}`);
169
+ return false;
170
+ }
171
+ },
172
+ finish() {
173
+ console.log(`\n${passed} passed, ${failures.length} failed`);
174
+ if (!failures.length) return;
175
+ for (const failure of failures) console.log(` - ${failure}`);
176
+ process.exit(1);
177
+ },
178
+ };
179
+ }
180
+
181
+ /**
182
+ * A page of the extension that reports its colour scheme.
183
+ *
184
+ * @typedef {object} SchemePage
185
+ * @property {string} path From the extension's root, e.g. `options/options.html`.
186
+ * @property {(page: Page) => Promise<unknown>} [ready] Resolves once the
187
+ * page's script has run. Default: the load event.
188
+ */
189
+
190
+ /**
191
+ * The light/dark toolbar glyph, end to end, for an extension wired with
192
+ * `startToolbarTheme`, `watchSchemeOffscreen` and `watchColorScheme`:
193
+ *
194
+ * 1. the worker keeps exactly one offscreen document open, for MATCH_MEDIA;
195
+ * 2. each page shows the dark glyph when dark and the light one when light,
196
+ * a second page in an unchanged scheme sends nothing, and `toolbarDark`
197
+ * stays out of Chrome Sync;
198
+ * 3. with no page open, the offscreen document alone follows a change.
199
+ *
200
+ * Throws (an assertion) at the first thing that's wrong; logs a ✔ line per step.
201
+ *
202
+ * @param {LaunchedExtension} launched - From {@link launchExtension}.
203
+ * @param {readonly SchemePage[]} pages - The pages that report; the first is opened twice.
204
+ * @param {{light: unknown, dark: unknown}} icons - `toolbarIcon(false)` and `toolbarIcon(true)`.
205
+ * @returns {Promise<void>}
206
+ */
207
+ export async function checkToolbarTheme({ context, worker, url }, pages, icons) {
208
+ /** @param {Page} page @param {SchemePage} entry */
209
+ const open = async (page, entry) => {
210
+ await page.goto(url(entry.path));
211
+ await (entry.ready ? entry.ready(page) : page.waitForLoadState('load'));
212
+ };
213
+
214
+ // 1. The watcher. Right after creation Chrome can list the document before its address is set.
215
+ assert.equal(await worker.evaluate(() => chrome.offscreen.Reason.MATCH_MEDIA), 'MATCH_MEDIA');
216
+ const offscreen = await until(async () => {
217
+ const found = await worker.evaluate(() => chrome.runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] }));
218
+ return found.length > 0 && found.every((info) => info.documentUrl?.endsWith('/offscreen/offscreen.html')) ? found : null;
219
+ }, 'the offscreen document');
220
+ assert.equal(offscreen.length, 1);
221
+ assert.equal(offscreen[0].documentUrl, url('offscreen/offscreen.html'));
222
+ console.log('✔ the background keeps one offscreen document open, for MATCH_MEDIA');
223
+
224
+ const calls = await recordIconCalls(worker);
225
+ const stored = () => worker.evaluate(async () => (await chrome.storage.local.get('toolbarDark')).toolbarDark);
226
+ const last = async () => JSON.stringify((await calls()).at(-1));
227
+ /** @param {unknown} icon @param {string} what */
228
+ const untilIcon = (icon, what) => until(async () => (await last()) === JSON.stringify(icon), what);
229
+
230
+ // The offscreen document follows this machine's real scheme: take it to dark, then light, so
231
+ // every step below starts from a known state (stored light, the document last saw light).
232
+ const blank = await context.newPage();
233
+ const cdp = await context.newCDPSession(blank);
234
+ const emulateOffscreen = await offscreenEmulator(cdp);
235
+ await emulateOffscreen('dark');
236
+ await until(async () => (await stored()) === true, 'the offscreen document to report dark');
237
+ await emulateOffscreen('light');
238
+ await until(async () => (await stored()) === false, 'the offscreen document to report light');
239
+
240
+ // 2. Each page, dark then light; a second page in the same scheme sends nothing.
241
+ for (const entry of pages) {
242
+ const page = await context.newPage();
243
+ await page.emulateMedia({ colorScheme: 'light' });
244
+ await open(page, entry);
245
+ await page.emulateMedia({ colorScheme: 'dark' });
246
+ await until(async () => (await stored()) === true, `${entry.path}: the dark scheme stored`);
247
+ await untilIcon(icons.dark, `${entry.path}: the dark glyph`);
248
+ await page.emulateMedia({ colorScheme: 'light' });
249
+ await until(async () => (await stored()) === false, `${entry.path}: the light scheme stored`);
250
+ await untilIcon(icons.light, `${entry.path}: the light glyph`);
251
+ await page.close();
252
+ }
253
+ const before = (await calls()).length;
254
+ const again = await context.newPage();
255
+ await again.emulateMedia({ colorScheme: 'light' });
256
+ await open(again, pages[0]);
257
+ await again.waitForTimeout(300);
258
+ assert.equal((await calls()).length, before);
259
+ await again.close();
260
+ assert.equal('toolbarDark' in await worker.evaluate(() => chrome.storage.sync.get(null)), false);
261
+ console.log('✔ a dark page shows the dark glyph, light the light one, a second light page nothing; toolbarDark stays in local');
262
+
263
+ // 3. No page open: the offscreen document alone follows a change.
264
+ for (const page of context.pages()) if (page.url().startsWith('chrome-extension://')) await page.close();
265
+ await emulateOffscreen('dark');
266
+ await untilIcon(icons.dark, 'the dark glyph from the offscreen document');
267
+ await emulateOffscreen('light');
268
+ await untilIcon(icons.light, 'the light glyph from the offscreen document');
269
+ await cdp.detach();
270
+ await blank.close();
271
+ console.log('✔ with no page open, the offscreen document swaps the glyph on a scheme change');
272
+ }
@@ -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;
@@ -0,0 +1,145 @@
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
+
11
+ import { MAX_SYNC_ITEM_BYTES } from '../settings/storage.js';
12
+
13
+ /** @import { StorageChange } from '../settings/storage.js' */
14
+
15
+ /** Chrome Sync's write limit: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE`. */
16
+ export const SYNC_WRITES_PER_MINUTE = 120;
17
+
18
+ /**
19
+ * @typedef {(changes: Record<string, StorageChange>, area: string) => unknown} ChangeListener
20
+ */
21
+
22
+ /**
23
+ * One fake storage area.
24
+ *
25
+ * @typedef {object} FakeArea
26
+ * @property {Record<string, any>} data What it holds, to inspect (and seed) in tests.
27
+ * @property {number} writes How many writes it has taken.
28
+ * @property {(keys?: string | string[] | null) => Promise<Record<string, any>>} get
29
+ * @property {(items: Record<string, unknown>) => Promise<void>} set
30
+ * @property {(keys: string | string[]) => Promise<void>} remove
31
+ */
32
+
33
+ /**
34
+ * The fake `chrome.storage`.
35
+ *
36
+ * @typedef {object} FakeChromeStorage
37
+ * @property {FakeArea} local `chrome.storage.local`.
38
+ * @property {FakeArea} sync `chrome.storage.sync`, with Chrome's quotas.
39
+ * @property {FakeArea} session `chrome.storage.session`.
40
+ * @property {{addListener(listener: ChangeListener): void}} onChanged `chrome.storage.onChanged`.
41
+ * @property {() => Promise<void>} settled Waits for every listener, including the changes those listeners make.
42
+ */
43
+
44
+ /**
45
+ * Copy a value the way storage does, through JSON.
46
+ *
47
+ * @param {unknown} value - Anything JSON-able, or undefined.
48
+ * @returns {any} The copy.
49
+ */
50
+ const viaJson = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
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 function fakeChromeStorage({ local = {}, sync = {}, session = {}, now = Date.now } = {}) {
67
+ /** @type {ChangeListener[]} */
68
+ const listeners = [];
69
+ /** @type {unknown[]} */
70
+ const running = [];
71
+
72
+ /**
73
+ * @param {string} name
74
+ * @param {Record<string, unknown>} initialData
75
+ * @param {{quotaPerItem?: number, writesPerMinute?: number}} [limits]
76
+ * @returns {FakeArea}
77
+ */
78
+ function area(name, initialData, { quotaPerItem, writesPerMinute } = {}) {
79
+ /** @type {Record<string, any>} */
80
+ const data = viaJson(initialData);
81
+ /** @type {number[]} */
82
+ const recent = [];
83
+ /** @param {Record<string, StorageChange>} changes */
84
+ const fire = (changes) => {
85
+ if (!Object.keys(changes).length) return;
86
+ for (const listener of listeners) running.push(listener(viaJson(changes), name));
87
+ };
88
+ /** A write, counted against the per-minute limit; refused over it, as Chrome does. */
89
+ const take = () => {
90
+ if (writesPerMinute) {
91
+ while (recent.length && recent[0] <= now() - 60_000) recent.shift();
92
+ if (recent.length >= writesPerMinute) throw new Error('This request exceeds the MAX_WRITE_OPERATIONS_PER_MINUTE quota.');
93
+ recent.push(now());
94
+ }
95
+ fake.writes++;
96
+ };
97
+ /** @type {FakeArea} */
98
+ const fake = {
99
+ data,
100
+ writes: 0,
101
+ async get(keys) {
102
+ const wanted = keys == null ? Object.keys(data) : [keys].flat();
103
+ return Object.fromEntries(wanted.filter((key) => key in data).map((key) => [key, viaJson(data[key])]));
104
+ },
105
+ async set(items) {
106
+ for (const [key, value] of Object.entries(items)) {
107
+ if (quotaPerItem && key.length + JSON.stringify(value).length > quotaPerItem) {
108
+ throw new Error('Resource::kQuotaBytesPerItem quota exceeded');
109
+ }
110
+ }
111
+ take();
112
+ /** @type {Record<string, StorageChange>} */
113
+ const changes = {};
114
+ for (const [key, value] of Object.entries(items)) {
115
+ if (JSON.stringify(data[key]) === JSON.stringify(value)) continue;
116
+ changes[key] = { oldValue: data[key], newValue: viaJson(value) };
117
+ data[key] = viaJson(value);
118
+ }
119
+ fire(changes);
120
+ },
121
+ async remove(keys) {
122
+ take();
123
+ /** @type {Record<string, StorageChange>} */
124
+ const changes = {};
125
+ for (const key of [keys].flat()) {
126
+ if (!(key in data)) continue;
127
+ changes[key] = { oldValue: data[key] };
128
+ delete data[key];
129
+ }
130
+ fire(changes);
131
+ },
132
+ };
133
+ return fake;
134
+ }
135
+
136
+ return {
137
+ local: area('local', local),
138
+ sync: area('sync', sync, { quotaPerItem: MAX_SYNC_ITEM_BYTES, writesPerMinute: SYNC_WRITES_PER_MINUTE }),
139
+ session: area('session', session),
140
+ onChanged: { addListener: (listener) => listeners.push(listener) },
141
+ async settled() {
142
+ while (running.length) await Promise.all(running.splice(0));
143
+ },
144
+ };
145
+ }
@@ -0,0 +1,9 @@
1
+ /**
2
+ * The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
3
+ * end-to-end tests, and checks for a packaging test. Node only.
4
+ *
5
+ * @module testing
6
+ */
7
+ export { checkRunner, checkToolbarTheme, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
8
+ export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
9
+ export { filesUnder, manifestReferences, missingReferences, zipEntries } from './package.js';
@@ -0,0 +1,10 @@
1
+ /**
2
+ * The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
3
+ * end-to-end tests, and checks for a packaging test. Node only.
4
+ *
5
+ * @module testing
6
+ */
7
+
8
+ export { checkRunner, checkToolbarTheme, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
9
+ export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
10
+ export { filesUnder, manifestReferences, missingReferences, zipEntries } from './package.js';