@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/README.md +117 -4
- package/package.json +2 -2
- package/src/ai/model.d.ts +317 -0
- package/src/ai/model.js +312 -0
- package/src/build/store-frame.d.ts +10 -91
- package/src/build/store-frame.js +11 -159
- package/src/build/vendor.js +1 -1
- package/src/content/slices.d.ts +63 -0
- package/src/content/slices.js +80 -0
- package/src/content/watch.d.ts +71 -0
- package/src/content/watch.js +95 -0
- package/src/frame/window.d.ts +120 -0
- package/src/frame/window.js +197 -0
- package/src/index.d.ts +36 -4
- package/src/index.js +25 -4
- package/src/settings/saver.d.ts +13 -0
- package/src/settings/saver.js +11 -0
- package/src/styles/chrome-ui.css +11 -0
- package/src/testing/e2e.d.ts +15 -0
- package/src/testing/e2e.js +26 -1
- package/src/testing/fake-model.d.ts +87 -0
- package/src/testing/fake-model.js +142 -0
- package/src/testing/fake-storage.d.ts +2 -0
- package/src/testing/fake-storage.js +15 -3
- package/src/testing/heartbeat.d.ts +23 -0
- package/src/testing/heartbeat.js +43 -0
- package/src/testing/index.d.ts +13 -3
- package/src/testing/index.js +10 -3
- package/src/testing/messages.d.ts +33 -0
- package/src/testing/messages.js +51 -0
- package/src/testing/prompt.d.ts +20 -0
- package/src/testing/prompt.js +29 -0
- package/src/ui/model-row.d.ts +111 -0
- package/src/ui/model-row.js +108 -0
- package/src/ui/text-dialog.d.ts +44 -7
- package/src/ui/text-dialog.js +54 -16
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
|
|
3
|
-
* service worker load, Chrome's page components (the ⋮
|
|
4
|
-
* dialog, the toast with Undo, a settings card's site list
|
|
5
|
-
* lists' sites
|
|
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
|
|
3
|
-
* service worker load, Chrome's page components (the ⋮
|
|
4
|
-
* dialog, the toast with Undo, a settings card's site list
|
|
5
|
-
* lists' sites
|
|
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
|
package/src/settings/saver.d.ts
CHANGED
|
@@ -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>;
|
package/src/settings/saver.js
CHANGED
|
@@ -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);
|
package/src/styles/chrome-ui.css
CHANGED
|
@@ -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 */
|
package/src/testing/e2e.d.ts
CHANGED
|
@@ -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`.
|
package/src/testing/e2e.js
CHANGED
|
@@ -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
|
+
}
|