@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/README.md +120 -4
- package/package.json +2 -2
- package/src/ai/model.d.ts +317 -0
- package/src/ai/model.js +312 -0
- 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/index.d.ts +35 -4
- package/src/index.js +25 -4
- package/src/scripting/registration.d.ts +36 -0
- package/src/scripting/registration.js +54 -0
- package/src/styles/chrome-ui.css +11 -0
- package/src/testing/downloads.d.ts +41 -0
- package/src/testing/downloads.js +70 -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/index.d.ts +19 -5
- package/src/testing/index.js +14 -5
- package/src/testing/live.d.ts +146 -0
- package/src/testing/live.js +151 -0
- package/src/testing/messages.d.ts +33 -0
- package/src/testing/messages.js +51 -0
- package/src/testing/server.d.ts +23 -3
- package/src/testing/server.js +36 -6
- package/src/{testing → time}/pool.d.ts +4 -3
- package/src/{testing → time}/pool.js +4 -3
- 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), 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
|
|
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), 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
|
+
}
|
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 */
|
|
@@ -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
|
+
}
|
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;
|