@arraypress/extension-kit 1.6.0 → 1.8.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), Chrome's window drawn around a capture, 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,8 +13,12 @@
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';
21
+ export { THEMES, WINDOW_BARS, WINDOW_W, letterIcon, windowChrome, windowChromeSvg } from './frame/window.js';
17
22
  export { localize, translator } from './dom/i18n.js';
18
23
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
19
24
  export { setUpPage } from './dom/setup.js';
@@ -28,9 +33,23 @@ export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
28
33
  export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
29
34
  export { OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen, } from './toolbar/theme.js';
30
35
  export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
36
+ export { createModelRow } from './ui/model-row.js';
31
37
  export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
32
38
  export { askFields, askText, checkText, fieldIds } from './ui/text-dialog.js';
33
39
  export { TOAST_MS, createToast } from './ui/toast.js';
40
+ export type Availability = import('./ai/model.js').Availability;
41
+ export type InitialPrompt = import('./ai/model.js').InitialPrompt;
42
+ export type LanguageModelOptions = import('./ai/model.js').LanguageModelOptions;
43
+ export type LanguageModelSession = import('./ai/model.js').LanguageModelSession;
44
+ export type LanguageModelStatic = import('./ai/model.js').LanguageModelStatic;
45
+ export type ModelAnswer = import('./ai/model.js').ModelAnswer;
46
+ export type ModelSession = import('./ai/model.js').ModelSession;
47
+ export type ModelStatus = import('./ai/model.js').ModelStatus;
48
+ export type SliceOptions = import('./content/slices.js').SliceOptions;
49
+ export type WatchHandlers = import('./content/watch.js').WatchHandlers;
50
+ export type WindowTab = import('./frame/window.js').Tab;
51
+ export type WindowTheme = import('./frame/window.js').Theme;
52
+ export type ModelRow = import('./ui/model-row.js').ModelRow;
34
53
  export type ContentScript = import('./scripting/registration.js').ContentScript;
35
54
  export type ActionMenu = import('./ui/menu.js').ActionMenu;
36
55
  export type MenuEntry = import('./ui/menu.js').MenuEntry;
@@ -44,6 +63,19 @@ export type Toast = import('./ui/toast.js').Toast;
44
63
  /**
45
64
  * The page helpers' types, for a page's JSDoc.
46
65
  *
66
+ * @typedef {import('./ai/model.js').Availability} Availability
67
+ * @typedef {import('./ai/model.js').InitialPrompt} InitialPrompt
68
+ * @typedef {import('./ai/model.js').LanguageModelOptions} LanguageModelOptions
69
+ * @typedef {import('./ai/model.js').LanguageModelSession} LanguageModelSession
70
+ * @typedef {import('./ai/model.js').LanguageModelStatic} LanguageModelStatic
71
+ * @typedef {import('./ai/model.js').ModelAnswer} ModelAnswer
72
+ * @typedef {import('./ai/model.js').ModelSession} ModelSession
73
+ * @typedef {import('./ai/model.js').ModelStatus} ModelStatus
74
+ * @typedef {import('./content/slices.js').SliceOptions} SliceOptions
75
+ * @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers
76
+ * @typedef {import('./frame/window.js').Tab} WindowTab
77
+ * @typedef {import('./frame/window.js').Theme} WindowTheme
78
+ * @typedef {import('./ui/model-row.js').ModelRow} ModelRow
47
79
  * @typedef {import('./scripting/registration.js').ContentScript} ContentScript
48
80
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
49
81
  * @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), Chrome's window drawn around a capture, 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,8 +14,14 @@
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';
24
+ export { THEMES, WINDOW_BARS, WINDOW_W, letterIcon, windowChrome, windowChromeSvg } from './frame/window.js';
18
25
  export { localize, translator } from './dom/i18n.js';
19
26
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
20
27
  export { setUpPage } from './dom/setup.js';
@@ -33,6 +40,7 @@ export {
33
40
  OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen,
34
41
  } from './toolbar/theme.js';
35
42
  export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
43
+ export { createModelRow } from './ui/model-row.js';
36
44
  export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
37
45
  export { askFields, askText, checkText, fieldIds } from './ui/text-dialog.js';
38
46
  export { TOAST_MS, createToast } from './ui/toast.js';
@@ -40,6 +48,19 @@ export { TOAST_MS, createToast } from './ui/toast.js';
40
48
  /**
41
49
  * The page helpers' types, for a page's JSDoc.
42
50
  *
51
+ * @typedef {import('./ai/model.js').Availability} Availability
52
+ * @typedef {import('./ai/model.js').InitialPrompt} InitialPrompt
53
+ * @typedef {import('./ai/model.js').LanguageModelOptions} LanguageModelOptions
54
+ * @typedef {import('./ai/model.js').LanguageModelSession} LanguageModelSession
55
+ * @typedef {import('./ai/model.js').LanguageModelStatic} LanguageModelStatic
56
+ * @typedef {import('./ai/model.js').ModelAnswer} ModelAnswer
57
+ * @typedef {import('./ai/model.js').ModelSession} ModelSession
58
+ * @typedef {import('./ai/model.js').ModelStatus} ModelStatus
59
+ * @typedef {import('./content/slices.js').SliceOptions} SliceOptions
60
+ * @typedef {import('./content/watch.js').WatchHandlers} WatchHandlers
61
+ * @typedef {import('./frame/window.js').Tab} WindowTab
62
+ * @typedef {import('./frame/window.js').Theme} WindowTheme
63
+ * @typedef {import('./ui/model-row.js').ModelRow} ModelRow
43
64
  * @typedef {import('./scripting/registration.js').ContentScript} ContentScript
44
65
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
45
66
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
@@ -41,6 +41,14 @@ export type SettingsSaver<S extends Record<string, unknown>> = {
41
41
  * Cancel the timers (tests, or a page being torn down).
42
42
  */
43
43
  stop: () => void;
44
+ /**
45
+ * Hear the settings change elsewhere (another tab, another computer through sync):
46
+ * `onChange` gets them all, this page's changes not yet written laid over them. `events` is
47
+ * `chrome.storage.onChanged`.
48
+ */
49
+ follow: (onChange: (settings: S) => void, events: {
50
+ addListener(listener: (changes: Record<string, import('./storage.js').StorageChange>, area: string) => void): void;
51
+ }) => void;
44
52
  };
45
53
  /** @import { SettingsStore } from './store.js' */
46
54
  /** @import { SyncedStorage } from './storage.js' */
@@ -59,6 +67,10 @@ export type SettingsSaver<S extends Record<string, unknown>> = {
59
67
  * @property {() => Promise<unknown>} flush Write what's queued now (call it as the page is hidden);
60
68
  * resolves once every write so far has finished.
61
69
  * @property {() => void} stop Cancel the timers (tests, or a page being torn down).
70
+ * @property {(onChange: (settings: S) => void, events: {addListener(listener: (changes: Record<string, import('./storage.js').StorageChange>, area: string) => void): void}) => void} follow
71
+ * Hear the settings change elsewhere (another tab, another computer through sync):
72
+ * `onChange` gets them all, this page's changes not yet written laid over them. `events` is
73
+ * `chrome.storage.onChanged`.
62
74
  */
63
75
  /**
64
76
  * Make a debounced saver over a settings store.
@@ -72,5 +84,6 @@ export type SettingsSaver<S extends Record<string, unknown>> = {
72
84
  * const saver = createSettingsSaver(store, chrome.storage, { onSaved: showSaved });
73
85
  * toggle.addEventListener('change', () => saver.save({ compact: toggle.checked }));
74
86
  * addEventListener('pagehide', () => saver.flush());
87
+ * saver.follow((settings) => show(settings), chrome.storage.onChanged);
75
88
  */
76
89
  export declare function createSettingsSaver<S extends Record<string, unknown>>(store: SettingsStore<S>, storage: SyncedStorage, { delay, retryAfter, onSaved }?: SaverOptions<S>): SettingsSaver<S>;
@@ -27,6 +27,10 @@
27
27
  * @property {() => Promise<unknown>} flush Write what's queued now (call it as the page is hidden);
28
28
  * resolves once every write so far has finished.
29
29
  * @property {() => void} stop Cancel the timers (tests, or a page being torn down).
30
+ * @property {(onChange: (settings: S) => void, events: {addListener(listener: (changes: Record<string, import('./storage.js').StorageChange>, area: string) => void): void}) => void} follow
31
+ * Hear the settings change elsewhere (another tab, another computer through sync):
32
+ * `onChange` gets them all, this page's changes not yet written laid over them. `events` is
33
+ * `chrome.storage.onChanged`.
30
34
  */
31
35
 
32
36
  /**
@@ -41,6 +45,7 @@
41
45
  * const saver = createSettingsSaver(store, chrome.storage, { onSaved: showSaved });
42
46
  * toggle.addEventListener('change', () => saver.save({ compact: toggle.checked }));
43
47
  * addEventListener('pagehide', () => saver.flush());
48
+ * saver.follow((settings) => show(settings), chrome.storage.onChanged);
44
49
  */
45
50
  export function createSettingsSaver(store, storage, { delay = 1000, retryAfter = 60_000, onSaved = () => {} } = {}) {
46
51
  /** @type {Partial<S>} */
@@ -90,6 +95,12 @@ export function createSettingsSaver(store, storage, { delay = 1000, retryAfter =
90
95
  timer = setTimeout(flush, delay);
91
96
  },
92
97
  flush,
98
+ follow(onChange, events) {
99
+ events.addListener(async (changes, area) => {
100
+ if (!store.changed(changes, area).length) return;
101
+ onChange(store.normalize({ ...(await store.load(storage)), ...pending }));
102
+ });
103
+ },
93
104
  stop() {
94
105
  clearTimeout(timer);
95
106
  clearTimeout(retryTimer);
@@ -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() {
@@ -0,0 +1,23 @@
1
+ /**
2
+ * How long a page's main thread is held, for performance checks: a heartbeat in the page. From
3
+ * Topicless' and Better Screenshots' e2e helpers.
4
+ *
5
+ * @module testing/heartbeat
6
+ */
7
+ import type { Page } from 'playwright';
8
+ /** @import { Page } from 'playwright' */
9
+ /**
10
+ * Start a heartbeat in a page: a timer that fires again and again, costing nothing between
11
+ * beats; the longest gap between two is the longest the main thread was held (the Long Tasks
12
+ * API misses an extension's isolated world's work, and a `MessageChannel` heartbeat counts as
13
+ * task time itself). Gaps while the tab is hidden (another tab in front) don't count: Chrome
14
+ * throttles a hidden tab's timers.
15
+ *
16
+ * @param {Page} page - The page.
17
+ * @returns {Promise<() => Promise<number>>} Stops it and gives the longest gap (ms).
18
+ * @example
19
+ * const stop = await heartbeat(page);
20
+ * await doTheWork();
21
+ * const held = await stop(); // e.g. 17
22
+ */
23
+ export declare function heartbeat(page: Page): Promise<() => Promise<number>>;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * How long a page's main thread is held, for performance checks: a heartbeat in the page. From
3
+ * Topicless' and Better Screenshots' e2e helpers.
4
+ *
5
+ * @module testing/heartbeat
6
+ */
7
+
8
+ /** @import { Page } from 'playwright' */
9
+
10
+ /**
11
+ * Start a heartbeat in a page: a timer that fires again and again, costing nothing between
12
+ * beats; the longest gap between two is the longest the main thread was held (the Long Tasks
13
+ * API misses an extension's isolated world's work, and a `MessageChannel` heartbeat counts as
14
+ * task time itself). Gaps while the tab is hidden (another tab in front) don't count: Chrome
15
+ * throttles a hidden tab's timers.
16
+ *
17
+ * @param {Page} page - The page.
18
+ * @returns {Promise<() => Promise<number>>} Stops it and gives the longest gap (ms).
19
+ * @example
20
+ * const stop = await heartbeat(page);
21
+ * await doTheWork();
22
+ * const held = await stop(); // e.g. 17
23
+ */
24
+ export async function heartbeat(page) {
25
+ await page.evaluate(() => {
26
+ const beat = { longest: 0, running: true };
27
+ Object.assign(window, { extensionKitBeat: beat });
28
+ let last = performance.now();
29
+ const tick = () => {
30
+ const now = performance.now();
31
+ // A hidden tab's timers are throttled by Chrome: those gaps aren't the page being held.
32
+ if (!document.hidden) beat.longest = Math.max(beat.longest, now - last);
33
+ last = now;
34
+ if (beat.running) setTimeout(tick, 0);
35
+ };
36
+ setTimeout(tick, 0);
37
+ });
38
+ return () => page.evaluate(() => {
39
+ const beat = /** @type {{longest: number, running: boolean}} */ (/** @type {any} */ (window).extensionKitBeat);
40
+ beat.running = false;
41
+ return beat.longest;
42
+ });
43
+ }