@arraypress/extension-kit 1.10.0 → 1.11.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 (68) hide show
  1. package/README.md +97 -20
  2. package/bin/extension-kit.js +2 -1
  3. package/package.json +1 -1
  4. package/src/ai/host.d.ts +183 -0
  5. package/src/ai/host.js +225 -0
  6. package/src/ai/model.d.ts +2 -1
  7. package/src/ai/model.js +10 -2
  8. package/src/build/vendor.d.ts +29 -8
  9. package/src/build/vendor.js +136 -40
  10. package/src/content/highlight.d.ts +15 -6
  11. package/src/content/highlight.js +42 -17
  12. package/src/content/overlay.d.ts +19 -4
  13. package/src/content/overlay.js +44 -6
  14. package/src/content/slices.d.ts +3 -1
  15. package/src/content/slices.js +29 -14
  16. package/src/content/watch.d.ts +4 -2
  17. package/src/content/watch.js +17 -3
  18. package/src/dom/focus.d.ts +11 -0
  19. package/src/dom/focus.js +15 -0
  20. package/src/frame/window.d.ts +1 -1
  21. package/src/frame/window.js +8 -7
  22. package/src/index.d.ts +10 -3
  23. package/src/index.js +8 -3
  24. package/src/panel/show-on-page.d.ts +82 -0
  25. package/src/panel/show-on-page.js +83 -0
  26. package/src/scripting/call.d.ts +29 -9
  27. package/src/scripting/call.js +35 -11
  28. package/src/scripting/context-menus.d.ts +45 -0
  29. package/src/scripting/context-menus.js +53 -0
  30. package/src/scripting/registration.d.ts +2 -1
  31. package/src/scripting/registration.js +2 -1
  32. package/src/scripting/tab.d.ts +17 -5
  33. package/src/scripting/tab.js +15 -7
  34. package/src/settings/saver.js +16 -6
  35. package/src/settings/storage.d.ts +33 -2
  36. package/src/settings/storage.js +73 -3
  37. package/src/settings/store.d.ts +17 -0
  38. package/src/settings/store.js +87 -20
  39. package/src/sites/sites.d.ts +6 -1
  40. package/src/sites/sites.js +20 -3
  41. package/src/styles/chrome-ui.css +56 -30
  42. package/src/testing/e2e.d.ts +35 -4
  43. package/src/testing/e2e.js +67 -5
  44. package/src/testing/fake-model.d.ts +9 -1
  45. package/src/testing/fake-model.js +44 -23
  46. package/src/testing/fake-storage.d.ts +41 -13
  47. package/src/testing/fake-storage.js +163 -51
  48. package/src/testing/index.d.ts +3 -3
  49. package/src/testing/index.js +5 -3
  50. package/src/testing/live-run.d.ts +23 -2
  51. package/src/testing/live-run.js +64 -18
  52. package/src/testing/messages.d.ts +7 -3
  53. package/src/testing/messages.js +50 -9
  54. package/src/testing/package.d.ts +56 -7
  55. package/src/testing/package.js +57 -18
  56. package/src/testing/prompt.d.ts +4 -2
  57. package/src/testing/prompt.js +7 -3
  58. package/src/time/pool.d.ts +2 -1
  59. package/src/time/pool.js +4 -2
  60. package/src/toolbar/theme.d.ts +10 -2
  61. package/src/toolbar/theme.js +12 -4
  62. package/src/ui/menu.d.ts +13 -3
  63. package/src/ui/menu.js +31 -11
  64. package/src/ui/model-row.js +5 -1
  65. package/src/ui/site-list.js +12 -3
  66. package/src/ui/text-dialog.d.ts +3 -2
  67. package/src/ui/text-dialog.js +10 -3
  68. package/src/ui/toast.js +2 -0
@@ -96,7 +96,11 @@ export async function extensionProfile(chromium, dir, { prefix = 'extension-', a
96
96
  ...options,
97
97
  args: [`--disable-extensions-except=${dir}`, `--load-extension=${dir}`, ...args],
98
98
  });
99
- const worker = context.serviceWorkers()[0] ?? await context.waitForEvent('serviceworker');
99
+ const worker = context.serviceWorkers()[0] ?? await context.waitForEvent('serviceworker', { timeout: 15_000 }).catch(async () => {
100
+ // Chrome refused the extension (a manifest or `_locales` error) or it has no worker: nothing to wait for.
101
+ await context.close();
102
+ throw new Error(`The extension at ${dir} didn't start a service worker: Chrome may have refused to load it (check its manifest and _locales at chrome://extensions)`);
103
+ });
100
104
  const extensionId = new URL(worker.url()).host;
101
105
  return {
102
106
  context,
@@ -137,6 +141,56 @@ export async function launchExtension(chromium, dir, options = {}) {
137
141
  };
138
142
  }
139
143
 
144
+ /**
145
+ * Stop the extension's service worker, as Chrome does after 30 seconds idle: its globals,
146
+ * timers and anything else kept in memory are gone; storage, alarms and registered listeners
147
+ * stay. Chrome never does this by itself in an e2e run (Playwright's debugger keeps the worker
148
+ * alive), so a check that state survives a restart has to ask for it. Done through the
149
+ * DevTools protocol from a page of the extension (`ServiceWorker.stopWorker`), and, unless
150
+ * `start` is false, the worker is started again with no event (`ServiceWorker.startWorker`).
151
+ *
152
+ * `launched.worker` stays the handle to use: Playwright keeps it across the restart. Anything a
153
+ * test put in the old worker (`recordIconCalls`, a patched `chrome.*` function) is gone with it.
154
+ *
155
+ * @param {Pick<LaunchedExtension, 'context' | 'worker' | 'extensionId' | 'url'>} launched - From {@link launchExtension}.
156
+ * @param {{start?: boolean, page?: string}} [options] - `start: false` leaves the worker stopped, for
157
+ * the next event (a message, an alarm, a tab) to start it as on a person's computer; `page`: a file
158
+ * of the extension to open for the protocol session (default `manifest.json`).
159
+ * @returns {Promise<void>} Once the worker has stopped (and, with `start`, is running again).
160
+ * @throws {Error} When the worker isn't running, or doesn't stop or start in 10 s.
161
+ *
162
+ * @example
163
+ * await worker.evaluate(() => { globalThis.cache = { tab: 7 }; });
164
+ * await stopWorker(launched);
165
+ * await worker.evaluate(() => globalThis.cache); // undefined: what wasn't stored is gone
166
+ */
167
+ export async function stopWorker({ context, worker, extensionId, url }, { start = true, page: file = 'manifest.json' } = {}) {
168
+ const page = await context.newPage();
169
+ try {
170
+ await page.goto(url(file));
171
+ const cdp = await context.newCDPSession(page);
172
+ /** @type {Map<string, {versionId: string, scriptURL: string, runningStatus: string}>} */
173
+ const versions = new Map();
174
+ cdp.on('ServiceWorker.workerVersionUpdated', (event) => {
175
+ for (const version of event.versions) versions.set(version.versionId, version);
176
+ });
177
+ await cdp.send('ServiceWorker.enable');
178
+ const own = () => [...versions.values()].filter((version) => version.scriptURL === worker.url());
179
+ const running = await until(() => own().find((version) => version.runningStatus === 'running'), 'the service worker to be running', 10_000);
180
+ await cdp.send('ServiceWorker.stopWorker', { versionId: running.versionId });
181
+ await until(() => own().every((version) => version.runningStatus === 'stopped'), 'the service worker to stop', 10_000);
182
+ if (start) {
183
+ await cdp.send('ServiceWorker.startWorker', { scopeURL: `chrome-extension://${extensionId}/` });
184
+ await until(() => own().some((version) => version.runningStatus === 'running'), 'the service worker to start again', 10_000);
185
+ // Running, and answering: its script has run again.
186
+ await worker.evaluate(() => true);
187
+ }
188
+ await cdp.detach();
189
+ } finally {
190
+ await page.close();
191
+ }
192
+ }
193
+
140
194
  /**
141
195
  * Emulate colour schemes in the offscreen document: one CDP session attached
142
196
  * to it, reused for every change.
@@ -164,10 +218,12 @@ export async function offscreenEmulator(cdp, suffix = '/offscreen/offscreen.html
164
218
 
165
219
  /**
166
220
  * Wrap the worker's real `chrome.action.setIcon` so each call is recorded once
167
- * Chrome has taken it (a path Chrome can't load fails, and isn't recorded).
221
+ * Chrome has taken it (a path Chrome can't load fails, and isn't recorded):
222
+ * its `path`, or `'imageData'` for an icon given as pixels. The record lives
223
+ * in the worker: it's gone when the worker restarts ({@link stopWorker}).
168
224
  *
169
225
  * @param {Worker} worker - The extension's service worker.
170
- * @returns {Promise<() => Promise<unknown[]>>} Reads the recorded `path`s, oldest first.
226
+ * @returns {Promise<() => Promise<unknown[]>>} Reads the recorded icons, oldest first.
171
227
  */
172
228
  export async function recordIconCalls(worker) {
173
229
  await worker.evaluate(() => {
@@ -176,7 +232,7 @@ export async function recordIconCalls(worker) {
176
232
  const real = chrome.action.setIcon.bind(chrome.action);
177
233
  chrome.action.setIcon = /** @type {typeof chrome.action.setIcon} */ (async (/** @type {chrome.action.TabIconDetails} */ details) => {
178
234
  await real(details);
179
- calls.push(details.path);
235
+ calls.push(details.path ?? (details.imageData ? 'imageData' : undefined));
180
236
  });
181
237
  });
182
238
  return () => worker.evaluate(() => /** @type {{iconCalls: unknown[]}} */ (/** @type {unknown} */ (globalThis)).iconCalls);
@@ -195,7 +251,8 @@ export const CHECK_MS = 60_000;
195
251
  * whether it passed.
196
252
  * @property {(name: string) => void} fail Count a failure found outside any check (a page's
197
253
  * uncaught error, say): printed now, listed and counted at the end.
198
- * @property {() => void} finish Print the tally, and exit with 1 if anything failed.
254
+ * @property {() => void} finish Print the tally, and exit with 1 if anything failed or no check ran at all
255
+ * (a script that returned early, or whose filter matched nothing, hasn't passed).
199
256
  */
200
257
 
201
258
  /**
@@ -238,6 +295,11 @@ export function checkRunner({ timeout: runnerTimeout = CHECK_MS } = {}) {
238
295
  },
239
296
  finish() {
240
297
  console.log(`\n${passed} passed, ${failures.length} failed`);
298
+ if (!passed && !failures.length) {
299
+ console.log(' No checks ran.');
300
+ process.exit(1);
301
+ return;
302
+ }
241
303
  if (!failures.length) return;
242
304
  for (const failure of failures) console.log(` - ${failure}`);
243
305
  process.exit(1);
@@ -4,7 +4,9 @@
4
4
  * worker's or page's `globalThis`: Playwright's Chromium has no model). It
5
5
  * acts out what a real Chrome does: a model still to download (with
6
6
  * progress), one this computer can't run, a slow first session (the cold
7
- * start), a slow answer, an answer cut short by its signal, and a failure.
7
+ * start), a slow answer, an answer cut short by its signal, a failure, a
8
+ * session that's been destroyed (it answers nothing more), and a download
9
+ * asked for without a click (Chrome refuses it).
8
10
  * Moved here from Better Downloads' tests, so every extension that uses the
9
11
  * model tests against the same one.
10
12
  *
@@ -38,6 +40,12 @@ export type FakeModelState = {
38
40
  * Whether a download fails part way.
39
41
  */
40
42
  failDownload: boolean;
43
+ /**
44
+ * Whether a click is behind a `create()` that has to download the
45
+ * model (Chrome refuses one without: `NotAllowedError`). `null`, the default, reads the page's own
46
+ * `navigator.userActivation` (and takes it as given where there's none: Node, a worker).
47
+ */
48
+ userActivation: boolean | null;
41
49
  };
42
50
  export type FakeModelLog = {
43
51
  /**
@@ -4,7 +4,9 @@
4
4
  * worker's or page's `globalThis`: Playwright's Chromium has no model). It
5
5
  * acts out what a real Chrome does: a model still to download (with
6
6
  * progress), one this computer can't run, a slow first session (the cold
7
- * start), a slow answer, an answer cut short by its signal, and a failure.
7
+ * start), a slow answer, an answer cut short by its signal, a failure, a
8
+ * session that's been destroyed (it answers nothing more), and a download
9
+ * asked for without a click (Chrome refuses it).
8
10
  * Moved here from Better Downloads' tests, so every extension that uses the
9
11
  * model tests against the same one.
10
12
  *
@@ -25,6 +27,9 @@
25
27
  * @property {(prompt: string, options: PromptOptions) => string} answer The answer to a prompt.
26
28
  * @property {boolean} failPrompt Whether answers fail (the model gave up).
27
29
  * @property {boolean} failDownload Whether a download fails part way.
30
+ * @property {boolean | null} userActivation Whether a click is behind a `create()` that has to download the
31
+ * model (Chrome refuses one without: `NotAllowedError`). `null`, the default, reads the page's own
32
+ * `navigator.userActivation` (and takes it as given where there's none: Node, a worker).
28
33
  */
29
34
 
30
35
  /**
@@ -77,7 +82,7 @@ function wait(ms, signal) {
77
82
  export function fakeLanguageModel(initial = {}) {
78
83
  /** @type {FakeModelState} */
79
84
  const state = {
80
- availability: 'available', createMs: 0, answerMs: 0, answer: () => 'Fake answer', failPrompt: false, failDownload: false, ...initial,
85
+ availability: 'available', createMs: 0, answerMs: 0, answer: () => 'Fake answer', failPrompt: false, failDownload: false, userActivation: null, ...initial,
81
86
  };
82
87
  /** @type {FakeModelLog} */
83
88
  const log = { availability: [], created: [], prompts: [], promptOptions: [], cloned: 0, aborted: 0, destroyed: 0 };
@@ -87,27 +92,40 @@ export function fakeLanguageModel(initial = {}) {
87
92
  *
88
93
  * @returns {LanguageModelSession} It.
89
94
  */
90
- const session = () => ({
91
- async prompt(prompt, options = {}) {
92
- log.prompts.push(prompt);
93
- log.promptOptions.push(options);
94
- try {
95
- await wait(state.answerMs, options.signal);
96
- } catch (error) {
97
- log.aborted++;
98
- throw error;
99
- }
100
- if (state.failPrompt) throw new DOMException('The model failed', 'UnknownError');
101
- return state.answer(prompt, options);
102
- },
103
- async clone() {
104
- log.cloned++;
105
- return session();
106
- },
107
- destroy() {
108
- log.destroyed++;
109
- },
110
- });
95
+ const session = () => {
96
+ let destroyed = false;
97
+ /** Chrome's refusal to use a session that's been destroyed. */
98
+ const refuseDestroyed = () => {
99
+ if (destroyed) throw new DOMException('The session has been destroyed.', 'InvalidStateError');
100
+ };
101
+ return /** @type {LanguageModelSession} */ ({
102
+ async prompt(prompt, options = {}) {
103
+ refuseDestroyed();
104
+ log.prompts.push(prompt);
105
+ log.promptOptions.push(options);
106
+ try {
107
+ await wait(state.answerMs, options.signal);
108
+ } catch (error) {
109
+ log.aborted++;
110
+ throw error;
111
+ }
112
+ // Destroyed while it was answering: Chrome stops the answer.
113
+ if (destroyed) throw new DOMException('The session has been destroyed.', 'AbortError');
114
+ if (state.failPrompt) throw new DOMException('The model failed', 'UnknownError');
115
+ return state.answer(prompt, options);
116
+ },
117
+ async clone() {
118
+ refuseDestroyed();
119
+ log.cloned++;
120
+ return session();
121
+ },
122
+ destroy() {
123
+ if (destroyed) return;
124
+ destroyed = true;
125
+ log.destroyed++;
126
+ },
127
+ });
128
+ };
111
129
 
112
130
  return {
113
131
  state,
@@ -121,6 +139,9 @@ export function fakeLanguageModel(initial = {}) {
121
139
  log.created.push(options);
122
140
  if (state.availability === 'unavailable') throw new DOMException('Not supported', 'NotSupportedError');
123
141
  if (state.availability !== 'available') {
142
+ // Chrome starts a download only from a click (transient user activation).
143
+ const active = state.userActivation ?? /** @type {{navigator?: {userActivation?: {isActive: boolean}}}} */ (globalThis).navigator?.userActivation?.isActive ?? true;
144
+ if (!active) throw new DOMException('Requires a user gesture when availability is "downloading" or "downloadable".', 'NotAllowedError');
124
145
  // A download, reported as Chrome reports it, then the model is here.
125
146
  const monitor = new EventTarget();
126
147
  options.monitor?.(monitor);
@@ -1,29 +1,60 @@
1
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
2
+ * An in-memory `chrome.storage` for tests, behaving as Chrome does (each point
3
+ * checked against Chromium): values are stored as Chrome converts them (what
4
+ * JSON can't hold is dropped, a `Date` is `{}`), `get` takes a key, keys, an
5
+ * object of defaults or nothing, `onChanged` fires only for values that
6
+ * changed (whatever order an object's keys come in), and every area has
7
+ * Chrome's quotas. Sync refuses an item over 8,192 bytes as Chrome counts them
8
+ * (`syncItemBytes`: UTF-8, `<` six bytes), more than 102,400 bytes or 512
9
+ * items in all, and more than 120 calls a minute or 1,800 an hour of `set`,
10
+ * `remove` or `clear` (each counted by itself, refused calls included); local
11
+ * and session hold 10 MB. Tests then check outcomes (what's stored), not
6
12
  * calls.
7
13
  *
8
14
  * @module testing/fake-storage
9
15
  */
10
16
  import type { StorageChange } from '../settings/storage.js';
11
17
  /** @import { StorageChange } from '../settings/storage.js' */
12
- /** Chrome Sync's write limit: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE`. */
18
+ /** Chrome Sync's write limit a minute: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE`. */
13
19
  export declare const SYNC_WRITES_PER_MINUTE = 120;
20
+ /** Chrome Sync's write limit an hour: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_HOUR`. */
21
+ export declare const SYNC_WRITES_PER_HOUR = 1800;
22
+ /** All of sync's items together may take this many bytes (`chrome.storage.sync.QUOTA_BYTES`). */
23
+ export declare const SYNC_QUOTA_BYTES = 102400;
24
+ /** The most items sync holds (`chrome.storage.sync.MAX_ITEMS`). */
25
+ export declare const SYNC_MAX_ITEMS = 512;
26
+ /** What local and session each hold (`chrome.storage.local.QUOTA_BYTES`), without `unlimitedStorage`. */
27
+ export declare const LOCAL_QUOTA_BYTES = 10485760;
14
28
  export type ChangeListener = (changes: Record<string, StorageChange>, area: string) => unknown;
29
+ export type FakeEvent<L extends (...args: any[]) => unknown> = {
30
+ addListener: (listener: L) => void;
31
+ removeListener: (listener: L) => void;
32
+ hasListener: (listener: L) => boolean;
33
+ };
15
34
  export type FakeArea = {
16
35
  /**
17
36
  * What it holds, to inspect (and seed) in tests.
18
37
  */
19
38
  data: Record<string, any>;
20
39
  /**
21
- * How many writes it has taken.
40
+ * How many writes (`set`, `remove`, `clear`) it has taken.
22
41
  */
23
42
  writes: number;
24
- get: (keys?: string | string[] | null) => Promise<Record<string, any>>;
43
+ /**
44
+ * A key's or keys' items, an object's keys with its values for the missing ones, or everything.
45
+ */
46
+ get: (keys?: string | string[] | Record<string, unknown> | null) => Promise<Record<string, any>>;
25
47
  set: (items: Record<string, unknown>) => Promise<void>;
26
48
  remove: (keys: string | string[]) => Promise<void>;
49
+ clear: () => Promise<void>;
50
+ /**
51
+ * The bytes the keys' items (or all) take.
52
+ */
53
+ getBytesInUse: (keys?: string | string[] | null) => Promise<number>;
54
+ /**
55
+ * This area's changes.
56
+ */
57
+ onChanged: FakeEvent<(changes: Record<string, StorageChange>) => unknown>;
27
58
  };
28
59
  export type FakeChromeStorage = {
29
60
  /**
@@ -41,16 +72,12 @@ export type FakeChromeStorage = {
41
72
  /**
42
73
  * `chrome.storage.onChanged`.
43
74
  */
44
- onChanged: {
45
- addListener(listener: ChangeListener): void;
46
- };
75
+ onChanged: FakeEvent<ChangeListener>;
47
76
  /**
48
77
  * Waits for every listener, including the changes those listeners make.
49
78
  */
50
79
  settled: () => Promise<void>;
51
80
  };
52
- /** All of sync's items together may take this many bytes (`chrome.storage.sync.QUOTA_BYTES`). */
53
- export declare const SYNC_QUOTA_BYTES = 102400;
54
81
  /**
55
82
  * Make a fake `chrome.storage`.
56
83
  *
@@ -58,8 +85,9 @@ export declare const SYNC_QUOTA_BYTES = 102400;
58
85
  * @param {Record<string, unknown>} [initial.local] - What local holds.
59
86
  * @param {Record<string, unknown>} [initial.sync] - What sync holds.
60
87
  * @param {Record<string, unknown>} [initial.session] - What session holds.
61
- * @param {() => number} [initial.now] - The clock sync's write limit reads. Default `Date.now`.
88
+ * @param {() => number} [initial.now] - The clock sync's write limits read. Default `Date.now`.
62
89
  * @returns {FakeChromeStorage} The fake.
90
+ *
63
91
  * @example
64
92
  * const storage = fakeChromeStorage({ sync: { days: 3 } });
65
93
  * await store.save(storage, { days: 5 });
@@ -1,33 +1,65 @@
1
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
2
+ * An in-memory `chrome.storage` for tests, behaving as Chrome does (each point
3
+ * checked against Chromium): values are stored as Chrome converts them (what
4
+ * JSON can't hold is dropped, a `Date` is `{}`), `get` takes a key, keys, an
5
+ * object of defaults or nothing, `onChanged` fires only for values that
6
+ * changed (whatever order an object's keys come in), and every area has
7
+ * Chrome's quotas. Sync refuses an item over 8,192 bytes as Chrome counts them
8
+ * (`syncItemBytes`: UTF-8, `<` six bytes), more than 102,400 bytes or 512
9
+ * items in all, and more than 120 calls a minute or 1,800 an hour of `set`,
10
+ * `remove` or `clear` (each counted by itself, refused calls included); local
11
+ * and session hold 10 MB. Tests then check outcomes (what's stored), not
6
12
  * calls.
7
13
  *
8
14
  * @module testing/fake-storage
9
15
  */
10
16
 
11
- import { MAX_SYNC_ITEM_BYTES } from '../settings/storage.js';
17
+ import { MAX_SYNC_ITEM_BYTES, sameValue, syncItemBytes } from '../settings/storage.js';
12
18
 
13
19
  /** @import { StorageChange } from '../settings/storage.js' */
14
20
 
15
- /** Chrome Sync's write limit: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE`. */
21
+ /** Chrome Sync's write limit a minute: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE`. */
16
22
  export const SYNC_WRITES_PER_MINUTE = 120;
17
23
 
24
+ /** Chrome Sync's write limit an hour: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_HOUR`. */
25
+ export const SYNC_WRITES_PER_HOUR = 1800;
26
+
27
+ /** All of sync's items together may take this many bytes (`chrome.storage.sync.QUOTA_BYTES`). */
28
+ export const SYNC_QUOTA_BYTES = 102_400;
29
+
30
+ /** The most items sync holds (`chrome.storage.sync.MAX_ITEMS`). */
31
+ export const SYNC_MAX_ITEMS = 512;
32
+
33
+ /** What local and session each hold (`chrome.storage.local.QUOTA_BYTES`), without `unlimitedStorage`. */
34
+ export const LOCAL_QUOTA_BYTES = 10_485_760;
35
+
18
36
  /**
19
37
  * @typedef {(changes: Record<string, StorageChange>, area: string) => unknown} ChangeListener
20
38
  */
21
39
 
40
+ /**
41
+ * An `onChanged` event.
42
+ *
43
+ * @template {(...args: any[]) => unknown} L
44
+ * @typedef {object} FakeEvent
45
+ * @property {(listener: L) => void} addListener
46
+ * @property {(listener: L) => void} removeListener
47
+ * @property {(listener: L) => boolean} hasListener
48
+ */
49
+
22
50
  /**
23
51
  * One fake storage area.
24
52
  *
25
53
  * @typedef {object} FakeArea
26
54
  * @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
55
+ * @property {number} writes How many writes (`set`, `remove`, `clear`) it has taken.
56
+ * @property {(keys?: string | string[] | Record<string, unknown> | null) => Promise<Record<string, any>>} get
57
+ * A key's or keys' items, an object's keys with its values for the missing ones, or everything.
29
58
  * @property {(items: Record<string, unknown>) => Promise<void>} set
30
59
  * @property {(keys: string | string[]) => Promise<void>} remove
60
+ * @property {() => Promise<void>} clear
61
+ * @property {(keys?: string | string[] | null) => Promise<number>} getBytesInUse The bytes the keys' items (or all) take.
62
+ * @property {FakeEvent<(changes: Record<string, StorageChange>) => unknown>} onChanged This area's changes.
31
63
  */
32
64
 
33
65
  /**
@@ -37,28 +69,79 @@ export const SYNC_WRITES_PER_MINUTE = 120;
37
69
  * @property {FakeArea} local `chrome.storage.local`.
38
70
  * @property {FakeArea} sync `chrome.storage.sync`, with Chrome's quotas.
39
71
  * @property {FakeArea} session `chrome.storage.session`.
40
- * @property {{addListener(listener: ChangeListener): void}} onChanged `chrome.storage.onChanged`.
72
+ * @property {FakeEvent<ChangeListener>} onChanged `chrome.storage.onChanged`.
41
73
  * @property {() => Promise<void>} settled Waits for every listener, including the changes those listeners make.
42
74
  */
43
75
 
76
+ /** Stands for a value Chrome doesn't store (`undefined`, a function, `NaN`). */
77
+ const DROPPED = Symbol('dropped');
78
+
44
79
  /**
45
- * Copy a value the way storage does, through JSON.
80
+ * A value as Chrome stores it: what JSON holds, copied; `undefined`, functions, symbols and
81
+ * numbers that aren't finite dropped from objects (`null` in arrays); any other object (a
82
+ * `Date`, a `RegExp`, a `Map`) as `{}`.
46
83
  *
47
- * @param {unknown} value - Anything JSON-able, or undefined.
84
+ * @param {unknown} value - What an extension gave `set`.
85
+ * @returns {unknown} The stored copy, or {@link DROPPED}.
86
+ */
87
+ function asStored(value) {
88
+ if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
89
+ if (typeof value === 'number') return Number.isFinite(value) ? value : DROPPED;
90
+ if (typeof value !== 'object') return DROPPED;
91
+ if (Array.isArray(value)) {
92
+ return value.map((item) => {
93
+ const stored = asStored(item);
94
+ return stored === DROPPED ? null : stored;
95
+ });
96
+ }
97
+ const prototype = Object.getPrototypeOf(value);
98
+ if (prototype !== Object.prototype && prototype !== null) return {};
99
+ return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, asStored(inner)]).filter(([, inner]) => inner !== DROPPED));
100
+ }
101
+
102
+ /**
103
+ * A copy of what's stored, as `get` and `onChanged` hand it out.
104
+ *
105
+ * @param {unknown} value - A stored value, or undefined.
48
106
  * @returns {any} The copy.
49
107
  */
50
- const viaJson = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
108
+ const copied = (value) => (value === undefined ? undefined : structuredClone(value));
51
109
 
52
- /** All of sync's items together may take this many bytes (`chrome.storage.sync.QUOTA_BYTES`). */
53
- export const SYNC_QUOTA_BYTES = 102_400;
110
+ /**
111
+ * An event's listeners.
112
+ *
113
+ * @template {(...args: any[]) => unknown} L
114
+ * @returns {FakeEvent<L> & {listeners: L[]}} The event, and who listens.
115
+ */
116
+ function fakeEvent() {
117
+ /** @type {L[]} */
118
+ const listeners = [];
119
+ return {
120
+ listeners,
121
+ addListener: (listener) => { if (!listeners.includes(listener)) listeners.push(listener); },
122
+ removeListener: (listener) => { if (listeners.includes(listener)) listeners.splice(listeners.indexOf(listener), 1); },
123
+ hasListener: (listener) => listeners.includes(listener),
124
+ };
125
+ }
54
126
 
55
127
  /**
56
- * The bytes an area's items take, as Chrome counts sync's: each key plus its value's JSON, in UTF-8.
128
+ * A limit on calls in a window of time, as Chrome's quota service keeps one: the window opens
129
+ * with the first call and takes so many; the first call after it has passed opens the next.
57
130
  *
58
- * @param {Record<string, unknown>} items - The items.
59
- * @returns {number} Bytes.
131
+ * @param {number} most - Calls a window.
132
+ * @param {number} ms - The window.
133
+ * @param {() => number} now - The clock.
134
+ * @returns {() => boolean} Counts a call; false when it's over the limit.
60
135
  */
61
- const totalBytes = (items) => Object.entries(items).reduce((sum, [key, value]) => sum + new TextEncoder().encode(key + JSON.stringify(value)).length, 0);
136
+ function callLimit(most, ms, now) {
137
+ let opened = -Infinity;
138
+ let left = 0;
139
+ return () => {
140
+ if (now() >= opened + ms) [opened, left] = [now(), most];
141
+ left -= 1;
142
+ return left >= 0;
143
+ };
144
+ }
62
145
 
63
146
  /**
64
147
  * Make a fake `chrome.storage`.
@@ -67,71 +150,89 @@ const totalBytes = (items) => Object.entries(items).reduce((sum, [key, value]) =
67
150
  * @param {Record<string, unknown>} [initial.local] - What local holds.
68
151
  * @param {Record<string, unknown>} [initial.sync] - What sync holds.
69
152
  * @param {Record<string, unknown>} [initial.session] - What session holds.
70
- * @param {() => number} [initial.now] - The clock sync's write limit reads. Default `Date.now`.
153
+ * @param {() => number} [initial.now] - The clock sync's write limits read. Default `Date.now`.
71
154
  * @returns {FakeChromeStorage} The fake.
155
+ *
72
156
  * @example
73
157
  * const storage = fakeChromeStorage({ sync: { days: 3 } });
74
158
  * await store.save(storage, { days: 5 });
75
159
  * assert.equal(storage.sync.data.days, 5);
76
160
  */
77
161
  export function fakeChromeStorage({ local = {}, sync = {}, session = {}, now = Date.now } = {}) {
78
- /** @type {ChangeListener[]} */
79
- const listeners = [];
162
+ /** @type {ReturnType<typeof fakeEvent<ChangeListener>>} */
163
+ const onChanged = fakeEvent();
80
164
  /** @type {unknown[]} */
81
165
  const running = [];
82
166
 
83
167
  /**
84
168
  * @param {string} name
85
169
  * @param {Record<string, unknown>} initialData
86
- * @param {{quotaPerItem?: number, quotaBytes?: number, writesPerMinute?: number}} [limits]
170
+ * @param {{quotaPerItem?: number, quotaBytes: number, maxItems?: number, limited?: boolean}} limits
87
171
  * @returns {FakeArea}
88
172
  */
89
- function area(name, initialData, { quotaPerItem, quotaBytes, writesPerMinute } = {}) {
173
+ function area(name, initialData, { quotaPerItem, quotaBytes, maxItems, limited = false }) {
90
174
  /** @type {Record<string, any>} */
91
- const data = viaJson(initialData);
92
- /** @type {number[]} */
93
- const recent = [];
175
+ const data = /** @type {Record<string, any>} */ (asStored(initialData));
176
+ /** @type {ReturnType<typeof fakeEvent<(changes: Record<string, StorageChange>) => unknown>>} */
177
+ const areaChanged = fakeEvent();
178
+ /** Each function's own limits, as Chrome counts them. @type {Map<string, (() => boolean)[]>} */
179
+ const limits = new Map();
94
180
  /** @param {Record<string, StorageChange>} changes */
95
181
  const fire = (changes) => {
96
182
  if (!Object.keys(changes).length) return;
97
- for (const listener of listeners) running.push(listener(viaJson(changes), name));
183
+ for (const listener of [...onChanged.listeners]) running.push(listener(copied(changes), name));
184
+ for (const listener of [...areaChanged.listeners]) running.push(listener(copied(changes)));
98
185
  };
99
- /** A write, counted against the per-minute limit; refused over it, as Chrome does. */
100
- const take = () => {
101
- if (writesPerMinute) {
102
- while (recent.length && recent[0] <= now() - 60_000) recent.shift();
103
- if (recent.length >= writesPerMinute) throw new Error('This request exceeds the MAX_WRITE_OPERATIONS_PER_MINUTE quota.');
104
- recent.push(now());
105
- }
106
- fake.writes++;
186
+ /**
187
+ * A call counted against the function's limits (a minute's, an hour's); refused over one, as Chrome does.
188
+ *
189
+ * @param {string} method - `set`, `remove` or `clear`.
190
+ */
191
+ const count = (method) => {
192
+ if (!limited) return;
193
+ if (!limits.has(method)) limits.set(method, [callLimit(SYNC_WRITES_PER_MINUTE, 60_000, now), callLimit(SYNC_WRITES_PER_HOUR, 3_600_000, now)]);
194
+ const [minute, hour] = /** @type {(() => boolean)[]} */ (limits.get(method)).map((take) => take());
195
+ if (!minute) throw new Error('This request exceeds the MAX_WRITE_OPERATIONS_PER_MINUTE quota.');
196
+ if (!hour) throw new Error('This request exceeds the MAX_WRITE_OPERATIONS_PER_HOUR quota.');
107
197
  };
198
+ /** @param {Record<string, unknown>} items @returns {number} */
199
+ const bytes = (items) => Object.entries(items).reduce((sum, [key, value]) => sum + syncItemBytes(key, value), 0);
108
200
  /** @type {FakeArea} */
109
201
  const fake = {
110
202
  data,
111
203
  writes: 0,
204
+ onChanged: areaChanged,
112
205
  async get(keys) {
206
+ const defaults = keys !== null && typeof keys === 'object' && !Array.isArray(keys) ? /** @type {Record<string, unknown>} */ (asStored(keys)) : {};
207
+ const wanted = keys == null ? Object.keys(data) : typeof keys === 'string' ? [keys] : Array.isArray(keys) ? keys : Object.keys(keys);
208
+ return Object.fromEntries(wanted.filter((key) => key in data || key in defaults).map((key) => [key, copied(key in data ? data[key] : defaults[key])]));
209
+ },
210
+ async getBytesInUse(keys) {
113
211
  const wanted = keys == null ? Object.keys(data) : [keys].flat();
114
- return Object.fromEntries(wanted.filter((key) => key in data).map((key) => [key, viaJson(data[key])]));
212
+ return bytes(Object.fromEntries(wanted.filter((key) => key in data).map((key) => [key, data[key]])));
115
213
  },
116
214
  async set(items) {
117
- for (const [key, value] of Object.entries(items)) {
118
- if (quotaPerItem && key.length + JSON.stringify(value).length > quotaPerItem) {
119
- throw new Error('Resource::kQuotaBytesPerItem quota exceeded');
120
- }
215
+ count('set');
216
+ const stored = /** @type {Record<string, unknown>} */ (asStored(items));
217
+ for (const [key, value] of Object.entries(stored)) {
218
+ if (quotaPerItem && syncItemBytes(key, value) > quotaPerItem) throw new Error('Resource::kQuotaBytesPerItem quota exceeded');
121
219
  }
122
- if (quotaBytes && totalBytes({ ...data, ...items }) > quotaBytes) throw new Error('Resource::kQuotaBytes quota exceeded');
123
- take();
220
+ const next = { ...data, ...stored };
221
+ if (bytes(next) > quotaBytes) throw new Error('Resource::kQuotaBytes quota exceeded');
222
+ if (maxItems && Object.keys(next).length > maxItems) throw new Error('Resource::kMaxItems quota exceeded');
223
+ fake.writes++;
124
224
  /** @type {Record<string, StorageChange>} */
125
225
  const changes = {};
126
- for (const [key, value] of Object.entries(items)) {
127
- if (JSON.stringify(data[key]) === JSON.stringify(value)) continue;
128
- changes[key] = { oldValue: data[key], newValue: viaJson(value) };
129
- data[key] = viaJson(value);
226
+ for (const [key, value] of Object.entries(stored)) {
227
+ if (key in data && sameValue(data[key], value)) continue;
228
+ changes[key] = { ...(key in data ? { oldValue: data[key] } : {}), newValue: value };
229
+ data[key] = value;
130
230
  }
131
231
  fire(changes);
132
232
  },
133
233
  async remove(keys) {
134
- take();
234
+ count('remove');
235
+ fake.writes++;
135
236
  /** @type {Record<string, StorageChange>} */
136
237
  const changes = {};
137
238
  for (const key of [keys].flat()) {
@@ -141,15 +242,26 @@ export function fakeChromeStorage({ local = {}, sync = {}, session = {}, now = D
141
242
  }
142
243
  fire(changes);
143
244
  },
245
+ async clear() {
246
+ count('clear');
247
+ fake.writes++;
248
+ /** @type {Record<string, StorageChange>} */
249
+ const changes = {};
250
+ for (const key of Object.keys(data)) {
251
+ changes[key] = { oldValue: data[key] };
252
+ delete data[key];
253
+ }
254
+ fire(changes);
255
+ },
144
256
  };
145
257
  return fake;
146
258
  }
147
259
 
148
260
  return {
149
- local: area('local', local),
150
- sync: area('sync', sync, { quotaPerItem: MAX_SYNC_ITEM_BYTES, quotaBytes: SYNC_QUOTA_BYTES, writesPerMinute: SYNC_WRITES_PER_MINUTE }),
151
- session: area('session', session),
152
- onChanged: { addListener: (listener) => listeners.push(listener) },
261
+ local: area('local', local, { quotaBytes: LOCAL_QUOTA_BYTES }),
262
+ sync: area('sync', sync, { quotaPerItem: MAX_SYNC_ITEM_BYTES, quotaBytes: SYNC_QUOTA_BYTES, maxItems: SYNC_MAX_ITEMS, limited: true }),
263
+ session: area('session', session, { quotaBytes: LOCAL_QUOTA_BYTES }),
264
+ onChanged,
153
265
  async settled() {
154
266
  while (running.length) await Promise.all(running.splice(0));
155
267
  },