@arraypress/extension-kit 1.5.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,23 +13,41 @@
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';
19
23
  export { setUpPage } from './dom/setup.js';
24
+ export { contentScriptSync } from './scripting/registration.js';
20
25
  export { createSettingsSaver } from './settings/saver.js';
21
26
  export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
22
27
  export { createSettingsStore } from './settings/store.js';
23
28
  export { MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite, } from './sites/sites.js';
29
+ export { eachLimited } from './time/pool.js';
24
30
  export { within } from './time/time.js';
25
31
  export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
26
32
  export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
27
33
  export { OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen, } from './toolbar/theme.js';
28
34
  export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
35
+ export { createModelRow } from './ui/model-row.js';
29
36
  export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
30
37
  export { askFields, askText, checkText, fieldIds } from './ui/text-dialog.js';
31
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;
50
+ export type ContentScript = import('./scripting/registration.js').ContentScript;
32
51
  export type ActionMenu = import('./ui/menu.js').ActionMenu;
33
52
  export type MenuEntry = import('./ui/menu.js').MenuEntry;
34
53
  export type MenuItem = import('./ui/menu.js').MenuItem;
@@ -41,6 +60,18 @@ export type Toast = import('./ui/toast.js').Toast;
41
60
  /**
42
61
  * The page helpers' types, for a page's JSDoc.
43
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
74
+ * @typedef {import('./scripting/registration.js').ContentScript} ContentScript
44
75
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
45
76
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
46
77
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
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,17 +14,24 @@
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';
20
26
  export { setUpPage } from './dom/setup.js';
27
+ export { contentScriptSync } from './scripting/registration.js';
21
28
  export { createSettingsSaver } from './settings/saver.js';
22
29
  export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
23
30
  export { createSettingsStore } from './settings/store.js';
24
31
  export {
25
32
  MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite,
26
33
  } from './sites/sites.js';
34
+ export { eachLimited } from './time/pool.js';
27
35
  export { within } from './time/time.js';
28
36
  export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
29
37
  export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
@@ -31,6 +39,7 @@ export {
31
39
  OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen,
32
40
  } from './toolbar/theme.js';
33
41
  export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
42
+ export { createModelRow } from './ui/model-row.js';
34
43
  export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
35
44
  export { askFields, askText, checkText, fieldIds } from './ui/text-dialog.js';
36
45
  export { TOAST_MS, createToast } from './ui/toast.js';
@@ -38,6 +47,18 @@ export { TOAST_MS, createToast } from './ui/toast.js';
38
47
  /**
39
48
  * The page helpers' types, for a page's JSDoc.
40
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
61
+ * @typedef {import('./scripting/registration.js').ContentScript} ContentScript
41
62
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
42
63
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
43
64
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
@@ -0,0 +1,36 @@
1
+ /**
2
+ * One registered content script kept in step with what may run: registered,
3
+ * updated or unregistered to match, each change after the last, so a quick
4
+ * run of setting and permission changes can't register and unregister out
5
+ * of order. For an extension whose content script runs on every site only
6
+ * while an optional all-sites grant is held, less a "Never on" list.
7
+ *
8
+ * @module scripting/registration
9
+ */
10
+ export type ContentScript = Omit<chrome.scripting.RegisteredContentScript, 'id' | 'js'> & {
11
+ js: string[];
12
+ };
13
+ export type Scripting = Pick<typeof chrome.scripting, 'getRegisteredContentScripts' | 'registerContentScripts' | 'updateContentScripts' | 'unregisterContentScripts'>;
14
+ /** @typedef {Omit<chrome.scripting.RegisteredContentScript, 'id' | 'js'> & {js: string[]}} ContentScript The script, but its id: its `js`, `matches` and the rest. */
15
+ /**
16
+ * The parts of `chrome.scripting` a registration uses.
17
+ *
18
+ * @typedef {Pick<typeof chrome.scripting, 'getRegisteredContentScripts' | 'registerContentScripts' | 'updateContentScripts' | 'unregisterContentScripts'>} Scripting
19
+ */
20
+ /**
21
+ * Keep the content script `id` registered as `want()` says: the script, or
22
+ * null for none. Each call asks `want()` afresh once the change before it is
23
+ * done, so the last call always wins.
24
+ *
25
+ * @param {Scripting} scripting - `chrome.scripting` (or a fake in tests).
26
+ * @param {string} id - The registered script's id.
27
+ * @param {() => Promise<ContentScript | null>} want - What should be registered now (settings and
28
+ * grants read at the time).
29
+ * @returns {() => Promise<void>} Makes the registration match; rejects if Chrome refuses.
30
+ * @example
31
+ * const sync = contentScriptSync(chrome.scripting, 'prices', async () => (await chrome.permissions.contains({ origins: ALL_SITES })
32
+ * ? { js: ['content/loader.js'], matches: ALL_SITES, excludeMatches: neverSites.flatMap(hostPatterns), runAt: 'document_idle' }
33
+ * : null));
34
+ * chrome.permissions.onAdded.addListener(() => sync());
35
+ */
36
+ export declare function contentScriptSync(scripting: Scripting, id: string, want: () => Promise<ContentScript | null>): () => Promise<void>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * One registered content script kept in step with what may run: registered,
3
+ * updated or unregistered to match, each change after the last, so a quick
4
+ * run of setting and permission changes can't register and unregister out
5
+ * of order. For an extension whose content script runs on every site only
6
+ * while an optional all-sites grant is held, less a "Never on" list.
7
+ *
8
+ * @module scripting/registration
9
+ */
10
+
11
+ /** @typedef {Omit<chrome.scripting.RegisteredContentScript, 'id' | 'js'> & {js: string[]}} ContentScript The script, but its id: its `js`, `matches` and the rest. */
12
+
13
+ /**
14
+ * The parts of `chrome.scripting` a registration uses.
15
+ *
16
+ * @typedef {Pick<typeof chrome.scripting, 'getRegisteredContentScripts' | 'registerContentScripts' | 'updateContentScripts' | 'unregisterContentScripts'>} Scripting
17
+ */
18
+
19
+ /**
20
+ * Keep the content script `id` registered as `want()` says: the script, or
21
+ * null for none. Each call asks `want()` afresh once the change before it is
22
+ * done, so the last call always wins.
23
+ *
24
+ * @param {Scripting} scripting - `chrome.scripting` (or a fake in tests).
25
+ * @param {string} id - The registered script's id.
26
+ * @param {() => Promise<ContentScript | null>} want - What should be registered now (settings and
27
+ * grants read at the time).
28
+ * @returns {() => Promise<void>} Makes the registration match; rejects if Chrome refuses.
29
+ * @example
30
+ * const sync = contentScriptSync(chrome.scripting, 'prices', async () => (await chrome.permissions.contains({ origins: ALL_SITES })
31
+ * ? { js: ['content/loader.js'], matches: ALL_SITES, excludeMatches: neverSites.flatMap(hostPatterns), runAt: 'document_idle' }
32
+ * : null));
33
+ * chrome.permissions.onAdded.addListener(() => sync());
34
+ */
35
+ export function contentScriptSync(scripting, id, want) {
36
+ /** The last change, so the next waits for it. */
37
+ let queue = Promise.resolve();
38
+ const apply = async () => {
39
+ const script = await want();
40
+ const registered = (await scripting.getRegisteredContentScripts({ ids: [id] })).length > 0;
41
+ if (!script) {
42
+ if (registered) await scripting.unregisterContentScripts({ ids: [id] });
43
+ return;
44
+ }
45
+ const full = { ...script, id };
46
+ if (registered) await scripting.updateContentScripts([full]);
47
+ else await scripting.registerContentScripts([full]);
48
+ };
49
+ return () => {
50
+ const run = queue.then(apply, apply);
51
+ queue = run.catch(() => {});
52
+ return run;
53
+ };
54
+ }
@@ -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 */
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A browser that downloads for real, for an extension that names, files or
3
+ * makes downloads. Playwright normally takes downloads over (CDP's
4
+ * `Browser.setDownloadBehavior` set to `allowAndName`), saving them under
5
+ * made-up names where no extension sees them: `downloads.onDeterminingFilename`
6
+ * never fires, and `chrome.downloads.download`'s names are lost. So the
7
+ * profile gets its own Downloads folder (Chrome's `download.default_directory`
8
+ * preference, written before the first start) and the behaviour goes back to
9
+ * Chrome's default: files land on disk as on a person's computer.
10
+ *
11
+ * @module testing/downloads
12
+ */
13
+ import type { BrowserType } from 'playwright';
14
+ import type { LaunchOptions, LaunchedExtension } from './e2e.js';
15
+ /** @import { BrowserType } from 'playwright' */
16
+ /** @import { LaunchOptions, LaunchedExtension } from './e2e.js' */
17
+ /**
18
+ * Every file under a folder, as paths from it (Chrome's `.crdownload` files
19
+ * of downloads still running left out), sorted.
20
+ *
21
+ * @param {string} dir - The folder.
22
+ * @returns {Promise<string[]>} The files.
23
+ * @example
24
+ * await filesIn(downloads); // ['Trips/Lake.jpg', 'Trips/Lake 2.jpg']
25
+ */
26
+ export declare function filesIn(dir: string): Promise<string[]>;
27
+ /**
28
+ * Load the unpacked extension at `dir` into a fresh profile whose downloads
29
+ * go to a folder of their own, really.
30
+ *
31
+ * @param {BrowserType} chromium - Playwright's `chromium`.
32
+ * @param {string} dir - The unpacked extension.
33
+ * @param {LaunchOptions} [options] - As `launchExtension`'s.
34
+ * @returns {Promise<LaunchedExtension & {downloads: string}>} The running extension, and its
35
+ * Downloads folder; `close()` deletes both.
36
+ * @example
37
+ * const { context, downloads, close } = await launchDownloading(chromium, 'extension');
38
+ */
39
+ export declare function launchDownloading(chromium: BrowserType, dir: string, options?: LaunchOptions): Promise<LaunchedExtension & {
40
+ downloads: string;
41
+ }>;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * A browser that downloads for real, for an extension that names, files or
3
+ * makes downloads. Playwright normally takes downloads over (CDP's
4
+ * `Browser.setDownloadBehavior` set to `allowAndName`), saving them under
5
+ * made-up names where no extension sees them: `downloads.onDeterminingFilename`
6
+ * never fires, and `chrome.downloads.download`'s names are lost. So the
7
+ * profile gets its own Downloads folder (Chrome's `download.default_directory`
8
+ * preference, written before the first start) and the behaviour goes back to
9
+ * Chrome's default: files land on disk as on a person's computer.
10
+ *
11
+ * @module testing/downloads
12
+ */
13
+
14
+ import { mkdir, readdir, writeFile } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { extensionProfile } from './e2e.js';
17
+
18
+ /** @import { BrowserType } from 'playwright' */
19
+ /** @import { LaunchOptions, LaunchedExtension } from './e2e.js' */
20
+
21
+ /**
22
+ * Every file under a folder, as paths from it (Chrome's `.crdownload` files
23
+ * of downloads still running left out), sorted.
24
+ *
25
+ * @param {string} dir - The folder.
26
+ * @returns {Promise<string[]>} The files.
27
+ * @example
28
+ * await filesIn(downloads); // ['Trips/Lake.jpg', 'Trips/Lake 2.jpg']
29
+ */
30
+ export async function filesIn(dir) {
31
+ const entries = await readdir(dir, { recursive: true, withFileTypes: true });
32
+ return entries.filter((entry) => entry.isFile() && !entry.name.endsWith('.crdownload'))
33
+ .map((entry) => path.relative(dir, path.join(entry.parentPath, entry.name))).sort();
34
+ }
35
+
36
+ /**
37
+ * Load the unpacked extension at `dir` into a fresh profile whose downloads
38
+ * go to a folder of their own, really.
39
+ *
40
+ * @param {BrowserType} chromium - Playwright's `chromium`.
41
+ * @param {string} dir - The unpacked extension.
42
+ * @param {LaunchOptions} [options] - As `launchExtension`'s.
43
+ * @returns {Promise<LaunchedExtension & {downloads: string}>} The running extension, and its
44
+ * Downloads folder; `close()` deletes both.
45
+ * @example
46
+ * const { context, downloads, close } = await launchDownloading(chromium, 'extension');
47
+ */
48
+ export async function launchDownloading(chromium, dir, options = {}) {
49
+ const profile = await extensionProfile(chromium, dir, options);
50
+ const downloads = path.join(path.dirname(profile.dir), 'Downloads');
51
+ await mkdir(path.join(profile.dir, 'Default'), { recursive: true });
52
+ await mkdir(downloads, { recursive: true });
53
+ await writeFile(path.join(profile.dir, 'Default', 'Preferences'), JSON.stringify({
54
+ download: { default_directory: downloads, prompt_for_download: false, directory_upgrade: true },
55
+ }));
56
+ const launched = await profile.launch();
57
+ const page = await launched.context.newPage();
58
+ const cdp = await launched.context.newCDPSession(page);
59
+ await cdp.send('Browser.setDownloadBehavior', { behavior: 'default' });
60
+ await cdp.detach();
61
+ await page.close();
62
+ return {
63
+ ...launched,
64
+ downloads,
65
+ async close() {
66
+ await launched.close();
67
+ await profile.remove();
68
+ },
69
+ };
70
+ }
@@ -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;