@arraypress/extension-kit 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
- * The kit's browser half: what an extension's pages, offscreen document and
3
- * service worker load, Chrome's page components (the ⋮ menu, the one-field
4
- * dialog, the toast with Undo, a settings card's site list) and per-site
5
- * lists' sites included. Every function takes the `chrome`
2
+ * The kit's browser half: what an extension's pages, offscreen document,
3
+ * service worker and content scripts load, Chrome's page components (the ⋮
4
+ * menu, the text dialog, the toast with Undo, a settings card's site list,
5
+ * the built-in AI row), per-site lists' sites, Chrome's on-device model, and
6
+ * a content script's slices and change watcher included. Every function takes the `chrome`
6
7
  * APIs it uses as arguments, so tests run the real logic against fakes.
7
8
  *
8
9
  * Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
@@ -12,7 +13,10 @@
12
13
  * @module extension-kit
13
14
  */
14
15
  export { githubAboutLinks, isPlaceholder } from './about/links.js';
16
+ export { MODEL_MESSAGES, chromeLanguageModel, createModelSession, describeModel, downloadModel, modelAvailability, } from './ai/model.js';
15
17
  export { setShortcuts } from './commands/shortcuts.js';
18
+ export { BLOCK_CHARS, SLICE_MS, blocksOf, inSlices } from './content/slices.js';
19
+ export { MAX_WAIT_MS, SETTLE_MS, watchChanges } from './content/watch.js';
16
20
  export { focusedElement } from './dom/focus.js';
17
21
  export { localize, translator } from './dom/i18n.js';
18
22
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
@@ -28,9 +32,21 @@ export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
28
32
  export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
29
33
  export { OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen, } from './toolbar/theme.js';
30
34
  export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
35
+ export { createModelRow } from './ui/model-row.js';
31
36
  export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
32
37
  export { askFields, askText, checkText, fieldIds } from './ui/text-dialog.js';
33
38
  export { TOAST_MS, createToast } from './ui/toast.js';
39
+ export type Availability = import('./ai/model.js').Availability;
40
+ export type InitialPrompt = import('./ai/model.js').InitialPrompt;
41
+ export type LanguageModelOptions = import('./ai/model.js').LanguageModelOptions;
42
+ export type LanguageModelSession = import('./ai/model.js').LanguageModelSession;
43
+ export type LanguageModelStatic = import('./ai/model.js').LanguageModelStatic;
44
+ export type ModelAnswer = import('./ai/model.js').ModelAnswer;
45
+ export type ModelSession = import('./ai/model.js').ModelSession;
46
+ export type ModelStatus = import('./ai/model.js').ModelStatus;
47
+ export type SliceOptions = import('./content/slices.js').SliceOptions;
48
+ export type WatchHandlers = import('./content/watch.js').WatchHandlers;
49
+ export type ModelRow = import('./ui/model-row.js').ModelRow;
34
50
  export type ContentScript = import('./scripting/registration.js').ContentScript;
35
51
  export type ActionMenu = import('./ui/menu.js').ActionMenu;
36
52
  export type MenuEntry = import('./ui/menu.js').MenuEntry;
@@ -44,6 +60,17 @@ export type Toast = import('./ui/toast.js').Toast;
44
60
  /**
45
61
  * The page helpers' types, for a page's JSDoc.
46
62
  *
63
+ * @typedef {import('./ai/model.js').Availability} Availability
64
+ * @typedef {import('./ai/model.js').InitialPrompt} InitialPrompt
65
+ * @typedef {import('./ai/model.js').LanguageModelOptions} LanguageModelOptions
66
+ * @typedef {import('./ai/model.js').LanguageModelSession} LanguageModelSession
67
+ * @typedef {import('./ai/model.js').LanguageModelStatic} LanguageModelStatic
68
+ * @typedef {import('./ai/model.js').ModelAnswer} ModelAnswer
69
+ * @typedef {import('./ai/model.js').ModelSession} ModelSession
70
+ * @typedef {import('./ai/model.js').ModelStatus} ModelStatus
71
+ * @typedef {import('./content/slices.js').SliceOptions} SliceOptions
72
+ * @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers
73
+ * @typedef {import('./ui/model-row.js').ModelRow} ModelRow
47
74
  * @typedef {import('./scripting/registration.js').ContentScript} ContentScript
48
75
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
49
76
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
package/src/index.js CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
- * The kit's browser half: what an extension's pages, offscreen document and
3
- * service worker load, Chrome's page components (the ⋮ menu, the one-field
4
- * dialog, the toast with Undo, a settings card's site list) and per-site
5
- * lists' sites included. Every function takes the `chrome`
2
+ * The kit's browser half: what an extension's pages, offscreen document,
3
+ * service worker and content scripts load, Chrome's page components (the ⋮
4
+ * menu, the text dialog, the toast with Undo, a settings card's site list,
5
+ * the built-in AI row), per-site lists' sites, Chrome's on-device model, and
6
+ * a content script's slices and change watcher included. Every function takes the `chrome`
6
7
  * APIs it uses as arguments, so tests run the real logic against fakes.
7
8
  *
8
9
  * Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
@@ -13,7 +14,12 @@
13
14
  */
14
15
 
15
16
  export { githubAboutLinks, isPlaceholder } from './about/links.js';
17
+ export {
18
+ MODEL_MESSAGES, chromeLanguageModel, createModelSession, describeModel, downloadModel, modelAvailability,
19
+ } from './ai/model.js';
16
20
  export { setShortcuts } from './commands/shortcuts.js';
21
+ export { BLOCK_CHARS, SLICE_MS, blocksOf, inSlices } from './content/slices.js';
22
+ export { MAX_WAIT_MS, SETTLE_MS, watchChanges } from './content/watch.js';
17
23
  export { focusedElement } from './dom/focus.js';
18
24
  export { localize, translator } from './dom/i18n.js';
19
25
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
@@ -33,6 +39,7 @@ export {
33
39
  OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen,
34
40
  } from './toolbar/theme.js';
35
41
  export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
42
+ export { createModelRow } from './ui/model-row.js';
36
43
  export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
37
44
  export { askFields, askText, checkText, fieldIds } from './ui/text-dialog.js';
38
45
  export { TOAST_MS, createToast } from './ui/toast.js';
@@ -40,6 +47,17 @@ export { TOAST_MS, createToast } from './ui/toast.js';
40
47
  /**
41
48
  * The page helpers' types, for a page's JSDoc.
42
49
  *
50
+ * @typedef {import('./ai/model.js').Availability} Availability
51
+ * @typedef {import('./ai/model.js').InitialPrompt} InitialPrompt
52
+ * @typedef {import('./ai/model.js').LanguageModelOptions} LanguageModelOptions
53
+ * @typedef {import('./ai/model.js').LanguageModelSession} LanguageModelSession
54
+ * @typedef {import('./ai/model.js').LanguageModelStatic} LanguageModelStatic
55
+ * @typedef {import('./ai/model.js').ModelAnswer} ModelAnswer
56
+ * @typedef {import('./ai/model.js').ModelSession} ModelSession
57
+ * @typedef {import('./ai/model.js').ModelStatus} ModelStatus
58
+ * @typedef {import('./content/slices.js').SliceOptions} SliceOptions
59
+ * @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers
60
+ * @typedef {import('./ui/model-row.js').ModelRow} ModelRow
43
61
  * @typedef {import('./scripting/registration.js').ContentScript} ContentScript
44
62
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
45
63
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
@@ -139,6 +139,7 @@
139
139
  /* Icons for link rows (cr:open-in-new, cr:arrow-right / subpage arrow) and ⋮ buttons (cr:more-vert). */
140
140
  --icon-open-in-new: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M19 19H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z'/%3E%3C/svg%3E");
141
141
  --icon-arrow-right: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z'/%3E%3C/svg%3E");
142
+ --icon-arrow-back: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z'/%3E%3C/svg%3E");
142
143
  --icon-more-vert: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z'/%3E%3C/svg%3E");
143
144
 
144
145
  /* Sizes. */
@@ -300,6 +301,16 @@ button.link:hover { background: none; text-decoration: underline; }
300
301
  /* The ⋮ icon without a sprite: <span class="icon icon-more-vert" aria-hidden="true">. */
301
302
  .icon-more-vert { display: inline-block; background: currentColor; mask: var(--icon-more-vert) center / contain no-repeat; }
302
303
 
304
+ /* The back arrow (cr:arrow-back), for a subpage's header. */
305
+ .icon-arrow-back { display: inline-block; background: currentColor; mask: var(--icon-arrow-back) center / contain no-repeat; }
306
+ .icon-arrow-back:dir(rtl) { transform: scaleX(-1); }
307
+
308
+ /* settings-subpage's header, at the top of its card: the back button, then the subpage's title.
309
+ <div class="subpage-head"><button class="icon-button" aria-label="Back"><span class="icon icon-arrow-back"></span></button><h2>Cookies</h2></div> */
310
+ .subpage-head { display: flex; align-items: center; gap: 8px; min-height: 56px; padding: 0 20px 0 8px; }
311
+ .subpage-head h2 { margin: 0; font-size: 15px; font-weight: 400; color: var(--text); overflow-wrap: anywhere; }
312
+ .subpage-head .icon-button { color: var(--icon-muted); }
313
+
303
314
  .sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
304
315
 
305
316
  /* --------------------------------------------------------------- Fields */
@@ -193,6 +193,21 @@ export type CheckRunner = {
193
193
  export declare function checkRunner({ timeout: runnerTimeout }?: {
194
194
  timeout?: number;
195
195
  }): CheckRunner;
196
+ /**
197
+ * Run a script's checks: an error outside any check is a failure, the
198
+ * clean-ups (closing browsers and servers) always run, in order, then the
199
+ * tally prints (and exits 1 on a failure). From the copies in Better Prices,
200
+ * Better Downloads, Better Saves and Clean Links (`test/e2e/helpers.mjs`).
201
+ *
202
+ * @param {CheckRunner} runner - From {@link checkRunner}.
203
+ * @param {() => Promise<void>} body - The checks.
204
+ * @param {readonly (() => Promise<unknown>)[]} cleanUps - Closing browsers and servers, in order.
205
+ * @returns {Promise<void>}
206
+ * @example
207
+ * const runner = checkRunner();
208
+ * await runChecks(runner, async () => { await runner.check('it hides', async () => { … }); }, [close, site.close]);
209
+ */
210
+ export declare function runChecks(runner: CheckRunner, body: () => Promise<void>, cleanUps: readonly (() => Promise<unknown>)[]): Promise<void>;
196
211
  export type SchemePage = {
197
212
  /**
198
213
  * From the extension's root, e.g. `options/options.html`.
@@ -12,7 +12,7 @@ import assert from 'node:assert/strict';
12
12
  import { mkdtemp, rm } from 'node:fs/promises';
13
13
  import { tmpdir } from 'node:os';
14
14
  import path from 'node:path';
15
- import { deadline } from '../time/time.js';
15
+ import { deadline, within } from '../time/time.js';
16
16
 
17
17
  /** @import { BrowserContext, BrowserType, CDPSession, Page, Worker } from 'playwright' */
18
18
 
@@ -245,6 +245,31 @@ export function checkRunner({ timeout: runnerTimeout = CHECK_MS } = {}) {
245
245
  };
246
246
  }
247
247
 
248
+ /**
249
+ * Run a script's checks: an error outside any check is a failure, the
250
+ * clean-ups (closing browsers and servers) always run, in order, then the
251
+ * tally prints (and exits 1 on a failure). From the copies in Better Prices,
252
+ * Better Downloads, Better Saves and Clean Links (`test/e2e/helpers.mjs`).
253
+ *
254
+ * @param {CheckRunner} runner - From {@link checkRunner}.
255
+ * @param {() => Promise<void>} body - The checks.
256
+ * @param {readonly (() => Promise<unknown>)[]} cleanUps - Closing browsers and servers, in order.
257
+ * @returns {Promise<void>}
258
+ * @example
259
+ * const runner = checkRunner();
260
+ * await runChecks(runner, async () => { await runner.check('it hides', async () => { … }); }, [close, site.close]);
261
+ */
262
+ export async function runChecks(runner, body, cleanUps) {
263
+ try {
264
+ await body();
265
+ } catch (error) {
266
+ runner.fail(`unexpected: ${error instanceof Error ? error.stack : error}`);
267
+ } finally {
268
+ for (const cleanUp of cleanUps) await within(Promise.resolve().then(cleanUp), 30_000, undefined);
269
+ }
270
+ runner.finish();
271
+ }
272
+
248
273
  /**
249
274
  * A page of the extension that reports its colour scheme.
250
275
  *
@@ -0,0 +1,87 @@
1
+ /**
2
+ * A stand-in for Chrome's `LanguageModel` (the Prompt API), with its shape,
3
+ * for an extension's unit tests and its e2e build (whose hook puts it on the
4
+ * worker's or page's `globalThis`: Playwright's Chromium has no model). It
5
+ * acts out what a real Chrome does: a model still to download (with
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.
8
+ * Moved here from Better Downloads' tests, so every extension that uses the
9
+ * model tests against the same one.
10
+ *
11
+ * Plain browser JavaScript with no Node imports, so a test build can copy it in.
12
+ *
13
+ * @module testing/fake-model
14
+ */
15
+ import type { LanguageModelOptions, LanguageModelStatic, PromptOptions } from '../ai/model.js';
16
+ export type FakeModelState = {
17
+ /**
18
+ * What `availability()` says.
19
+ */
20
+ availability: 'available' | 'downloadable' | 'downloading' | 'unavailable';
21
+ /**
22
+ * How long making a session takes (a cold start is ~18 s on a real Chrome).
23
+ */
24
+ createMs: number;
25
+ /**
26
+ * How long an answer takes.
27
+ */
28
+ answerMs: number;
29
+ /**
30
+ * The answer to a prompt.
31
+ */
32
+ answer: (prompt: string, options: PromptOptions) => string;
33
+ /**
34
+ * Whether answers fail (the model gave up).
35
+ */
36
+ failPrompt: boolean;
37
+ /**
38
+ * Whether a download fails part way.
39
+ */
40
+ failDownload: boolean;
41
+ };
42
+ export type FakeModelLog = {
43
+ /**
44
+ * Each `availability()`'s options.
45
+ */
46
+ availability: LanguageModelOptions[];
47
+ /**
48
+ * Each `create()`'s options.
49
+ */
50
+ created: LanguageModelOptions[];
51
+ /**
52
+ * Each prompt.
53
+ */
54
+ prompts: string[];
55
+ /**
56
+ * Each prompt's options (signal, responseConstraint).
57
+ */
58
+ promptOptions: PromptOptions[];
59
+ /**
60
+ * Sessions cloned.
61
+ */
62
+ cloned: number;
63
+ /**
64
+ * Prompts and creates stopped by their signal.
65
+ */
66
+ aborted: number;
67
+ /**
68
+ * Sessions let go.
69
+ */
70
+ destroyed: number;
71
+ };
72
+ export type FakeLanguageModel = LanguageModelStatic & {
73
+ state: FakeModelState;
74
+ log: FakeModelLog;
75
+ set: (patch: Partial<FakeModelState>) => void;
76
+ };
77
+ /**
78
+ * A fake Prompt API.
79
+ *
80
+ * @param {Partial<FakeModelState>} [initial] - How it starts (default: ready, instant, answers “Fake answer”).
81
+ * @returns {FakeLanguageModel} The API, its state and log.
82
+ * @example
83
+ * const model = fakeLanguageModel({ createMs: 18_000 }); // a cold start
84
+ * const sessions = createModelSession({ model: () => model, options });
85
+ * (await sessions.ask('Hi', { timeoutMs: 2500 })).reason; // 'late'
86
+ */
87
+ export declare function fakeLanguageModel(initial?: Partial<FakeModelState>): FakeLanguageModel;
@@ -0,0 +1,142 @@
1
+ /**
2
+ * A stand-in for Chrome's `LanguageModel` (the Prompt API), with its shape,
3
+ * for an extension's unit tests and its e2e build (whose hook puts it on the
4
+ * worker's or page's `globalThis`: Playwright's Chromium has no model). It
5
+ * acts out what a real Chrome does: a model still to download (with
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.
8
+ * Moved here from Better Downloads' tests, so every extension that uses the
9
+ * model tests against the same one.
10
+ *
11
+ * Plain browser JavaScript with no Node imports, so a test build can copy it in.
12
+ *
13
+ * @module testing/fake-model
14
+ */
15
+
16
+ /** @import { LanguageModelOptions, LanguageModelSession, LanguageModelStatic, PromptOptions } from '../ai/model.js' */
17
+
18
+ /**
19
+ * How the fake behaves; change it at any time with `set`.
20
+ *
21
+ * @typedef {object} FakeModelState
22
+ * @property {'available' | 'downloadable' | 'downloading' | 'unavailable'} availability What `availability()` says.
23
+ * @property {number} createMs How long making a session takes (a cold start is ~18 s on a real Chrome).
24
+ * @property {number} answerMs How long an answer takes.
25
+ * @property {(prompt: string, options: PromptOptions) => string} answer The answer to a prompt.
26
+ * @property {boolean} failPrompt Whether answers fail (the model gave up).
27
+ * @property {boolean} failDownload Whether a download fails part way.
28
+ */
29
+
30
+ /**
31
+ * What the fake was asked, for tests to look at.
32
+ *
33
+ * @typedef {object} FakeModelLog
34
+ * @property {LanguageModelOptions[]} availability Each `availability()`'s options.
35
+ * @property {LanguageModelOptions[]} created Each `create()`'s options.
36
+ * @property {string[]} prompts Each prompt.
37
+ * @property {PromptOptions[]} promptOptions Each prompt's options (signal, responseConstraint).
38
+ * @property {number} cloned Sessions cloned.
39
+ * @property {number} aborted Prompts and creates stopped by their signal.
40
+ * @property {number} destroyed Sessions let go.
41
+ */
42
+
43
+ /**
44
+ * A fake Prompt API with its state, its log, and `set` to change the state.
45
+ *
46
+ * @typedef {LanguageModelStatic & {state: FakeModelState, log: FakeModelLog, set: (patch: Partial<FakeModelState>) => void}} FakeLanguageModel
47
+ */
48
+
49
+ /**
50
+ * Wait, unless the signal stops it first.
51
+ *
52
+ * @param {number} ms - How long.
53
+ * @param {AbortSignal | undefined} signal - Stops it.
54
+ * @returns {Promise<void>} Resolves after `ms`; rejects with an AbortError when stopped.
55
+ */
56
+ function wait(ms, signal) {
57
+ return new Promise((resolve, reject) => {
58
+ if (signal?.aborted) return reject(new DOMException('Aborted', 'AbortError'));
59
+ const timer = setTimeout(resolve, ms);
60
+ signal?.addEventListener('abort', () => {
61
+ clearTimeout(timer);
62
+ reject(new DOMException('Aborted', 'AbortError'));
63
+ }, { once: true });
64
+ });
65
+ }
66
+
67
+ /**
68
+ * A fake Prompt API.
69
+ *
70
+ * @param {Partial<FakeModelState>} [initial] - How it starts (default: ready, instant, answers “Fake answer”).
71
+ * @returns {FakeLanguageModel} The API, its state and log.
72
+ * @example
73
+ * const model = fakeLanguageModel({ createMs: 18_000 }); // a cold start
74
+ * const sessions = createModelSession({ model: () => model, options });
75
+ * (await sessions.ask('Hi', { timeoutMs: 2500 })).reason; // 'late'
76
+ */
77
+ export function fakeLanguageModel(initial = {}) {
78
+ /** @type {FakeModelState} */
79
+ const state = {
80
+ availability: 'available', createMs: 0, answerMs: 0, answer: () => 'Fake answer', failPrompt: false, failDownload: false, ...initial,
81
+ };
82
+ /** @type {FakeModelLog} */
83
+ const log = { availability: [], created: [], prompts: [], promptOptions: [], cloned: 0, aborted: 0, destroyed: 0 };
84
+
85
+ /**
86
+ * A session.
87
+ *
88
+ * @returns {LanguageModelSession} It.
89
+ */
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
+ });
111
+
112
+ return {
113
+ state,
114
+ log,
115
+ set: (patch) => Object.assign(state, patch),
116
+ async availability(options = {}) {
117
+ log.availability.push(options);
118
+ return state.availability;
119
+ },
120
+ async create(options = {}) {
121
+ log.created.push(options);
122
+ if (state.availability === 'unavailable') throw new DOMException('Not supported', 'NotSupportedError');
123
+ if (state.availability !== 'available') {
124
+ // A download, reported as Chrome reports it, then the model is here.
125
+ const monitor = new EventTarget();
126
+ options.monitor?.(monitor);
127
+ for (const loaded of [0, 0.5, 1]) {
128
+ if (state.failDownload && loaded === 1) throw new DOMException('Download failed', 'NetworkError');
129
+ monitor.dispatchEvent(Object.assign(new Event('downloadprogress'), { loaded }));
130
+ }
131
+ state.availability = 'available';
132
+ }
133
+ try {
134
+ await wait(state.createMs, options.signal);
135
+ } catch (error) {
136
+ log.aborted++;
137
+ throw error;
138
+ }
139
+ return session();
140
+ },
141
+ };
142
+ }
@@ -49,6 +49,8 @@ export type FakeChromeStorage = {
49
49
  */
50
50
  settled: () => Promise<void>;
51
51
  };
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;
52
54
  /**
53
55
  * Make a fake `chrome.storage`.
54
56
  *
@@ -49,6 +49,17 @@ export const SYNC_WRITES_PER_MINUTE = 120;
49
49
  */
50
50
  const viaJson = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
51
51
 
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;
54
+
55
+ /**
56
+ * The bytes an area's items take, as Chrome counts sync's: each key plus its value's JSON, in UTF-8.
57
+ *
58
+ * @param {Record<string, unknown>} items - The items.
59
+ * @returns {number} Bytes.
60
+ */
61
+ const totalBytes = (items) => Object.entries(items).reduce((sum, [key, value]) => sum + new TextEncoder().encode(key + JSON.stringify(value)).length, 0);
62
+
52
63
  /**
53
64
  * Make a fake `chrome.storage`.
54
65
  *
@@ -72,10 +83,10 @@ export function fakeChromeStorage({ local = {}, sync = {}, session = {}, now = D
72
83
  /**
73
84
  * @param {string} name
74
85
  * @param {Record<string, unknown>} initialData
75
- * @param {{quotaPerItem?: number, writesPerMinute?: number}} [limits]
86
+ * @param {{quotaPerItem?: number, quotaBytes?: number, writesPerMinute?: number}} [limits]
76
87
  * @returns {FakeArea}
77
88
  */
78
- function area(name, initialData, { quotaPerItem, writesPerMinute } = {}) {
89
+ function area(name, initialData, { quotaPerItem, quotaBytes, writesPerMinute } = {}) {
79
90
  /** @type {Record<string, any>} */
80
91
  const data = viaJson(initialData);
81
92
  /** @type {number[]} */
@@ -108,6 +119,7 @@ export function fakeChromeStorage({ local = {}, sync = {}, session = {}, now = D
108
119
  throw new Error('Resource::kQuotaBytesPerItem quota exceeded');
109
120
  }
110
121
  }
122
+ if (quotaBytes && totalBytes({ ...data, ...items }) > quotaBytes) throw new Error('Resource::kQuotaBytes quota exceeded');
111
123
  take();
112
124
  /** @type {Record<string, StorageChange>} */
113
125
  const changes = {};
@@ -135,7 +147,7 @@ export function fakeChromeStorage({ local = {}, sync = {}, session = {}, now = D
135
147
 
136
148
  return {
137
149
  local: area('local', local),
138
- sync: area('sync', sync, { quotaPerItem: MAX_SYNC_ITEM_BYTES, writesPerMinute: SYNC_WRITES_PER_MINUTE }),
150
+ sync: area('sync', sync, { quotaPerItem: MAX_SYNC_ITEM_BYTES, quotaBytes: SYNC_QUOTA_BYTES, writesPerMinute: SYNC_WRITES_PER_MINUTE }),
139
151
  session: area('session', session),
140
152
  onChanged: { addListener: (listener) => listeners.push(listener) },
141
153
  async settled() {
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
2
+ * The kit's test helpers: a fake `chrome.storage`, a fake on-device model, Playwright helpers for
3
3
  * end-to-end tests (time limits, a pool, a browser that passes for a
4
4
  * person's) and a server for their pages, Chrome's install warnings and
5
5
  * saved tab groups, and checks for a packaging test. Node only.
@@ -8,19 +8,24 @@
8
8
  */
9
9
  export { TEST_BUILDS_ENV, testBuildDir } from '../build/test-build.js';
10
10
  export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
11
- export { CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until, } from './e2e.js';
11
+ export { CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, runChecks, until, } from './e2e.js';
12
12
  export { exitPoint, parseLiveOptions, parseProxy } from './live.js';
13
13
  export { eachLimited } from '../time/pool.js';
14
14
  export { filesIn, launchDownloading } from './downloads.js';
15
+ export { fakeLanguageModel } from './fake-model.js';
16
+ export { messageLookup, readMessages } from './messages.js';
15
17
  export { serve, testCertificate } from './server.js';
16
- export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
18
+ export { SYNC_QUOTA_BYTES, SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
17
19
  export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
18
20
  export { savedTabGroups } from './saved-groups.js';
19
21
  export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
20
22
  export type CheckRunner = import('./e2e.js').CheckRunner;
23
+ export type FakeLanguageModel = import('./fake-model.js').FakeLanguageModel;
24
+ export type FakeModelState = import('./fake-model.js').FakeModelState;
21
25
  export type ExtensionProfile = import('./e2e.js').ExtensionProfile;
22
26
  export type LaunchedExtension = import('./e2e.js').LaunchedExtension;
23
27
  export type LaunchOptions = import('./e2e.js').LaunchOptions;
28
+ export type Messages = import('./messages.js').Messages;
24
29
  export type Exit = import('./live.js').Exit;
25
30
  export type LiveOptions = import('./live.js').LiveOptions;
26
31
  export type SavedTabGroup = import('./saved-groups.js').SavedTabGroup;
@@ -32,9 +37,12 @@ export type TestServer = import('./server.js').TestServer;
32
37
  * The helpers' types, for a test's JSDoc.
33
38
  *
34
39
  * @typedef {import('./e2e.js').CheckRunner} CheckRunner
40
+ * @typedef {import('./fake-model.js').FakeLanguageModel} FakeLanguageModel
41
+ * @typedef {import('./fake-model.js').FakeModelState} FakeModelState
35
42
  * @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
36
43
  * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
37
44
  * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
45
+ * @typedef {import('./messages.js').Messages} Messages
38
46
  * @typedef {import('./live.js').Exit} Exit
39
47
  * @typedef {import('./live.js').LiveOptions} LiveOptions
40
48
  * @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
@@ -1,5 +1,5 @@
1
1
  /**
2
- * The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
2
+ * The kit's test helpers: a fake `chrome.storage`, a fake on-device model, Playwright helpers for
3
3
  * end-to-end tests (time limits, a pool, a browser that passes for a
4
4
  * person's) and a server for their pages, Chrome's install warnings and
5
5
  * saved tab groups, and checks for a packaging test. Node only.
@@ -10,13 +10,15 @@
10
10
  export { TEST_BUILDS_ENV, testBuildDir } from '../build/test-build.js';
11
11
  export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
12
12
  export {
13
- CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until,
13
+ CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, runChecks, until,
14
14
  } from './e2e.js';
15
15
  export { exitPoint, parseLiveOptions, parseProxy } from './live.js';
16
16
  export { eachLimited } from '../time/pool.js';
17
17
  export { filesIn, launchDownloading } from './downloads.js';
18
+ export { fakeLanguageModel } from './fake-model.js';
19
+ export { messageLookup, readMessages } from './messages.js';
18
20
  export { serve, testCertificate } from './server.js';
19
- export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
21
+ export { SYNC_QUOTA_BYTES, SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
20
22
  export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
21
23
  export { savedTabGroups } from './saved-groups.js';
22
24
  export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
@@ -25,9 +27,12 @@ export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings
25
27
  * The helpers' types, for a test's JSDoc.
26
28
  *
27
29
  * @typedef {import('./e2e.js').CheckRunner} CheckRunner
30
+ * @typedef {import('./fake-model.js').FakeLanguageModel} FakeLanguageModel
31
+ * @typedef {import('./fake-model.js').FakeModelState} FakeModelState
28
32
  * @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
29
33
  * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
30
34
  * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
35
+ * @typedef {import('./messages.js').Messages} Messages
31
36
  * @typedef {import('./live.js').Exit} Exit
32
37
  * @typedef {import('./live.js').LiveOptions} LiveOptions
33
38
  * @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
@@ -0,0 +1,33 @@
1
+ export type Messages = Record<string, {
2
+ message: string;
3
+ description?: string;
4
+ placeholders?: Record<string, {
5
+ content: string;
6
+ }>;
7
+ }>;
8
+ /**
9
+ * One locale's messages.json.
10
+ *
11
+ * @typedef {Record<string, {message: string, description?: string, placeholders?: Record<string, {content: string}>}>} Messages
12
+ */
13
+ /**
14
+ * Read a locale's messages.
15
+ *
16
+ * @param {string} extensionDir - The extension's folder (with `_locales/`).
17
+ * @param {string} locale - "en" or "en_GB".
18
+ * @returns {Messages} Its messages.
19
+ * @example
20
+ * readMessages('extension', 'en_GB').optionsColour.message;
21
+ */
22
+ export declare function readMessages(extensionDir: string, locale: string): Messages;
23
+ /**
24
+ * `chrome.i18n.getMessage` over a locale's real messages ('' for a missing key, as Chrome gives).
25
+ *
26
+ * @param {string} extensionDir - The extension's folder.
27
+ * @param {string} [locale] - Default "en"; another locale is read over en.
28
+ * @returns {(key: string, substitutions?: string | string[]) => string} The lookup.
29
+ * @example
30
+ * const t = messageLookup('extension');
31
+ * t('optionsMoreActions', ['example.com']); // 'More actions for example.com'
32
+ */
33
+ export declare function messageLookup(extensionDir: string, locale?: string): (key: string, substitutions?: string | string[]) => string;