@arraypress/extension-kit 1.9.0 → 1.11.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 +145 -18
- package/bin/extension-kit.js +2 -1
- package/package.json +1 -1
- package/src/ai/host.d.ts +183 -0
- package/src/ai/host.js +225 -0
- package/src/ai/model.d.ts +2 -1
- package/src/ai/model.js +10 -2
- package/src/build/index.d.ts +2 -1
- package/src/build/index.js +2 -1
- package/src/build/store-shots.d.ts +177 -0
- package/src/build/store-shots.js +173 -0
- package/src/build/test-build.d.ts +13 -1
- package/src/build/test-build.js +41 -2
- package/src/build/vendor.d.ts +29 -8
- package/src/build/vendor.js +136 -40
- package/src/content/elements.d.ts +50 -0
- package/src/content/elements.js +88 -0
- package/src/content/highlight.d.ts +63 -0
- package/src/content/highlight.js +117 -0
- package/src/content/overlay.d.ts +31 -6
- package/src/content/overlay.js +56 -8
- package/src/content/page-toast.d.ts +26 -0
- package/src/content/page-toast.js +72 -0
- package/src/content/slices.d.ts +3 -1
- package/src/content/slices.js +29 -14
- package/src/content/watch.d.ts +4 -2
- package/src/content/watch.js +17 -3
- package/src/dom/copy.d.ts +36 -0
- package/src/dom/copy.js +84 -0
- package/src/dom/focus.d.ts +11 -0
- package/src/dom/focus.js +15 -0
- package/src/dom/i18n.d.ts +17 -0
- package/src/dom/i18n.js +15 -0
- package/src/dom/make.d.ts +53 -0
- package/src/dom/make.js +71 -0
- package/src/frame/window.d.ts +8 -1
- package/src/frame/window.js +11 -8
- package/src/index.d.ts +31 -6
- package/src/index.js +24 -6
- package/src/panel/show-on-page.d.ts +82 -0
- package/src/panel/show-on-page.js +83 -0
- package/src/scripting/call.d.ts +29 -9
- package/src/scripting/call.js +35 -11
- package/src/scripting/context-menus.d.ts +45 -0
- package/src/scripting/context-menus.js +53 -0
- package/src/scripting/registration.d.ts +2 -1
- package/src/scripting/registration.js +2 -1
- package/src/scripting/tab.d.ts +80 -0
- package/src/scripting/tab.js +89 -0
- package/src/settings/saver.js +16 -6
- package/src/settings/storage.d.ts +33 -2
- package/src/settings/storage.js +73 -3
- package/src/settings/store.d.ts +17 -0
- package/src/settings/store.js +87 -20
- package/src/sites/sites.d.ts +6 -1
- package/src/sites/sites.js +20 -3
- package/src/styles/chrome-ui.css +63 -30
- package/src/testing/e2e.d.ts +35 -4
- package/src/testing/e2e.js +67 -5
- package/src/testing/fake-model.d.ts +9 -1
- package/src/testing/fake-model.js +44 -23
- package/src/testing/fake-storage.d.ts +41 -13
- package/src/testing/fake-storage.js +163 -51
- package/src/testing/index.d.ts +8 -2
- package/src/testing/index.js +8 -2
- package/src/testing/live-run.d.ts +151 -0
- package/src/testing/live-run.js +206 -0
- package/src/testing/messages.d.ts +7 -3
- package/src/testing/messages.js +50 -9
- package/src/testing/package.d.ts +56 -7
- package/src/testing/package.js +57 -18
- package/src/testing/pages.d.ts +114 -0
- package/src/testing/pages.js +109 -0
- package/src/testing/prompt.d.ts +4 -2
- package/src/testing/prompt.js +7 -3
- package/src/time/pool.d.ts +2 -1
- package/src/time/pool.js +4 -2
- package/src/toolbar/theme.d.ts +10 -2
- package/src/toolbar/theme.js +12 -4
- package/src/ui/menu.d.ts +13 -3
- package/src/ui/menu.js +31 -11
- package/src/ui/model-row.js +5 -1
- package/src/ui/notice.d.ts +58 -0
- package/src/ui/notice.js +43 -0
- package/src/ui/site-list.js +12 -3
- package/src/ui/text-dialog.d.ts +3 -2
- package/src/ui/text-dialog.js +10 -3
- package/src/ui/toast.js +2 -0
package/src/testing/package.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Helpers for an extension's packaging test: list the folder, list the real
|
|
3
3
|
* zip of it, and find every path the extension names that doesn't resolve to
|
|
4
|
-
* a file
|
|
4
|
+
* a file the zip holds (a broken import, a stylesheet link, a manifest entry).
|
|
5
5
|
*
|
|
6
6
|
* @module testing/package
|
|
7
7
|
*/
|
|
@@ -11,7 +11,7 @@ import { existsSync } from 'node:fs';
|
|
|
11
11
|
import { mkdtemp, readdir, readFile, rm } from 'node:fs/promises';
|
|
12
12
|
import { tmpdir } from 'node:os';
|
|
13
13
|
import path from 'node:path';
|
|
14
|
-
import { zipExtension } from '../build/zip.js';
|
|
14
|
+
import { shipsFile, zipExtension } from '../build/zip.js';
|
|
15
15
|
|
|
16
16
|
/**
|
|
17
17
|
* Every file under `dir`.
|
|
@@ -49,27 +49,43 @@ export async function zipEntries(from, exclude) {
|
|
|
49
49
|
* @typedef {object} ManifestFiles
|
|
50
50
|
* @property {{service_worker?: string}} [background] The worker.
|
|
51
51
|
* @property {{page?: string}} [options_ui] The options page.
|
|
52
|
+
* @property {string} [options_page] The options page, the older way.
|
|
52
53
|
* @property {{default_popup?: string, default_icon?: string | Record<string, string>}} [action] The popup and toolbar icon.
|
|
53
54
|
* @property {{default_path?: string}} [side_panel] The side panel.
|
|
54
55
|
* @property {Record<string, string>} [icons] The extension's icons.
|
|
55
56
|
* @property {{resources: string[]}[]} [web_accessible_resources] Files pages may load.
|
|
57
|
+
* @property {{js?: string[], css?: string[]}[]} [content_scripts] Scripts and styles Chrome puts in pages.
|
|
58
|
+
* @property {string} [default_locale] The locale whose `_locales/<locale>/messages.json` must be there.
|
|
59
|
+
* @property {{rule_resources?: {path: string}[]}} [declarative_net_request] Rule files.
|
|
60
|
+
* @property {Record<string, string>} [chrome_url_overrides] Pages that replace Chrome's (the new tab page).
|
|
61
|
+
* @property {string} [devtools_page] The DevTools page.
|
|
62
|
+
* @property {{pages?: string[]}} [sandbox] Sandboxed pages.
|
|
56
63
|
*/
|
|
57
64
|
|
|
58
65
|
/**
|
|
59
66
|
* The paths a manifest names, from the extension's root.
|
|
60
67
|
*
|
|
61
68
|
* @param {ManifestFiles} manifest - The parsed `manifest.json`.
|
|
62
|
-
* @returns {string[]} Worker, pages, icons, side panel
|
|
69
|
+
* @returns {string[]} Worker, pages, icons, side panel, content scripts and their styles, the default
|
|
70
|
+
* locale's messages, rule files, overridden and DevTools pages, and web-accessible resources
|
|
71
|
+
* (less patterns with `*`).
|
|
63
72
|
*/
|
|
64
73
|
export function manifestReferences(manifest) {
|
|
65
74
|
const icon = manifest.action?.default_icon;
|
|
66
75
|
return [
|
|
67
76
|
manifest.background?.service_worker,
|
|
68
77
|
manifest.options_ui?.page,
|
|
78
|
+
manifest.options_page,
|
|
69
79
|
manifest.action?.default_popup,
|
|
70
80
|
manifest.side_panel?.default_path,
|
|
81
|
+
manifest.devtools_page,
|
|
71
82
|
...Object.values(manifest.icons ?? {}),
|
|
72
83
|
...(typeof icon === 'string' ? [icon] : Object.values(icon ?? {})),
|
|
84
|
+
...(manifest.content_scripts ?? []).flatMap((entry) => [...(entry.js ?? []), ...(entry.css ?? [])]),
|
|
85
|
+
...(manifest.default_locale ? [`_locales/${manifest.default_locale}/messages.json`] : []),
|
|
86
|
+
...(manifest.declarative_net_request?.rule_resources ?? []).map((entry) => entry.path),
|
|
87
|
+
...Object.values(manifest.chrome_url_overrides ?? {}),
|
|
88
|
+
...(manifest.sandbox?.pages ?? []),
|
|
73
89
|
...(manifest.web_accessible_resources ?? []).flatMap((entry) => entry.resources),
|
|
74
90
|
].filter((ref) => typeof ref === 'string').filter((ref) => !ref.includes('*'));
|
|
75
91
|
}
|
|
@@ -88,12 +104,27 @@ export function referencedPath(reference) {
|
|
|
88
104
|
/** A reference with a scheme (`https:`, `data:`, `mailto:`) or protocol-relative: not a file of the extension. */
|
|
89
105
|
const ELSEWHERE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
|
|
90
106
|
|
|
107
|
+
/** A module a script imports: `… from './x.js'`, `import('./x.js')`, or `import './x.js'` alone on its line; either quote. */
|
|
108
|
+
const IMPORTS = [/(?:from|import\()\s*(['"])(\.{1,2}\/[^'"]+)\1/g, /^\s*import\s*(['"])(\.{1,2}\/[^'"]+)\1/gm];
|
|
109
|
+
|
|
110
|
+
/** `getURL('…')`, either quote. */
|
|
111
|
+
const GET_URL = /getURL\(\s*(['"])([^'"]+)\1\s*\)/g;
|
|
112
|
+
|
|
113
|
+
/** A page's `src="…"` or `href="…"`, either quote. */
|
|
114
|
+
const ATTRIBUTES = /\s(?:src|href)\s*=\s*(['"])([^'"]+)\1/g;
|
|
115
|
+
|
|
116
|
+
/** A stylesheet's `url(…)` and `@import "…"`. */
|
|
117
|
+
const STYLE_URLS = [/url\(\s*(['"]?)([^'")]+)\1\s*\)/g, /@import\s+(['"])([^'"]+)\1/g];
|
|
118
|
+
|
|
91
119
|
/**
|
|
92
|
-
* Every path the extension names that isn't a file
|
|
93
|
-
*
|
|
94
|
-
* in its pages,
|
|
95
|
-
*
|
|
96
|
-
* `
|
|
120
|
+
* Every path the extension names that isn't a file it ships: relative imports in its scripts
|
|
121
|
+
* (`from`, `import()` and a bare `import './x.js'`), `chrome.runtime.getURL('…')` paths,
|
|
122
|
+
* `src`/`href` in its pages, `url(…)` and `@import` in its stylesheets, the manifest's entries
|
|
123
|
+
* ({@link manifestReferences}), and any `extra` paths. A file that's there but left out of the
|
|
124
|
+
* zip (`shipsFile`: an SVG under `icons/`, a declaration, a vendor stamp) is as missing as one
|
|
125
|
+
* that isn't: the store build won't have it. A `#fragment` or `?query` is ignored:
|
|
126
|
+
* `getURL('options.html#workspaces')` names `options.html`. A path starting `/` is from the
|
|
127
|
+
* extension's root. Files that don't ship aren't read.
|
|
97
128
|
*
|
|
98
129
|
* @param {string} dir - The extension folder.
|
|
99
130
|
* @param {readonly string[]} [extra] - More paths from the root (say, a script injected by name).
|
|
@@ -107,18 +138,26 @@ export async function missingReferences(dir, extra = []) {
|
|
|
107
138
|
const check = (from, target) => {
|
|
108
139
|
const name = referencedPath(target);
|
|
109
140
|
if (!name || ELSEWHERE.test(target)) return;
|
|
110
|
-
const file = path.resolve(root, from, name);
|
|
111
|
-
|
|
141
|
+
const file = name.startsWith('/') ? path.join(root, name) : path.resolve(root, from, name);
|
|
142
|
+
const inside = path.relative(root, file).split(path.sep).join('/');
|
|
143
|
+
if (inside.startsWith('..') || !existsSync(file) || !shipsFile(inside)) missing.push(`${from || 'root'} → ${target}`);
|
|
144
|
+
};
|
|
145
|
+
/** @param {string} source @param {readonly RegExp[]} patterns @param {string} from */
|
|
146
|
+
const each = (source, patterns, from) => {
|
|
147
|
+
for (const pattern of patterns) for (const match of source.matchAll(pattern)) check(from, match[2]);
|
|
112
148
|
};
|
|
113
|
-
for (const file of await filesUnder(root)) {
|
|
149
|
+
for (const file of (await filesUnder(root)).filter(shipsFile)) {
|
|
114
150
|
const from = path.posix.dirname(file);
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
151
|
+
const kind = path.posix.extname(file);
|
|
152
|
+
if (kind !== '.js' && kind !== '.html' && kind !== '.css') continue;
|
|
153
|
+
const source = await readFile(path.join(root, file), 'utf8');
|
|
154
|
+
if (kind === '.js') {
|
|
155
|
+
each(source, IMPORTS, from);
|
|
156
|
+
each(source, [GET_URL], '');
|
|
157
|
+
} else if (kind === '.html') {
|
|
158
|
+
each(source, [ATTRIBUTES], from);
|
|
159
|
+
} else {
|
|
160
|
+
each(source, STYLE_URLS, from);
|
|
122
161
|
}
|
|
123
162
|
}
|
|
124
163
|
const manifest = JSON.parse(await readFile(path.join(root, 'manifest.json'), 'utf8'));
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaching a tested extension's pages from an e2e test: a Playwright page's
|
|
3
|
+
* tab id (as the extension's worker sees it), a page module called in a
|
|
4
|
+
* tab's frames as the extension calls it, and a page's whole tree through
|
|
5
|
+
* the DevTools protocol, closed shadow roots and frames included (where an
|
|
6
|
+
* extension's overlay lives). Tests only: an extension never needs CDP.
|
|
7
|
+
*
|
|
8
|
+
* @module testing/pages
|
|
9
|
+
*/
|
|
10
|
+
import type { CDPSession, Page, Worker } from 'playwright';
|
|
11
|
+
/** @import { CDPSession, Page, Worker } from 'playwright' */
|
|
12
|
+
/**
|
|
13
|
+
* The tab id of a page: the newest tab at its address (any tab for a `chrome:` page).
|
|
14
|
+
*
|
|
15
|
+
* @param {Worker} worker - The extension's service worker.
|
|
16
|
+
* @param {Page} page - The page.
|
|
17
|
+
* @returns {Promise<number>} Its tab's id.
|
|
18
|
+
* @example
|
|
19
|
+
* const tabId = await tabIdOf(worker, page);
|
|
20
|
+
*/
|
|
21
|
+
export declare function tabIdOf(worker: Worker, page: Page): Promise<number>;
|
|
22
|
+
/**
|
|
23
|
+
* Call an export of the extension's page module in a tab's frames, as `callInPage` does from the
|
|
24
|
+
* worker.
|
|
25
|
+
*
|
|
26
|
+
* @param {Worker} worker - The extension's service worker.
|
|
27
|
+
* @param {number} tabId - The tab.
|
|
28
|
+
* @param {string} module - The module's path in the extension (`content/main.js`).
|
|
29
|
+
* @param {string} name - The export.
|
|
30
|
+
* @param {unknown[]} [args] - Its arguments.
|
|
31
|
+
* @returns {Promise<{frameId: number, result: any}[]>} Each frame's answer.
|
|
32
|
+
* @example
|
|
33
|
+
* const [{ result }] = await callTabModule(worker, tabId, 'content/main.js', 'scan');
|
|
34
|
+
*/
|
|
35
|
+
export declare function callTabModule(worker: Worker, tabId: number, module: string, name: string, args?: unknown[]): Promise<{
|
|
36
|
+
frameId: number;
|
|
37
|
+
result: any;
|
|
38
|
+
}[]>;
|
|
39
|
+
export type CdpNode = {
|
|
40
|
+
/**
|
|
41
|
+
* Its id in the session.
|
|
42
|
+
*/
|
|
43
|
+
nodeId: number;
|
|
44
|
+
/**
|
|
45
|
+
* Its lasting id.
|
|
46
|
+
*/
|
|
47
|
+
backendNodeId: number;
|
|
48
|
+
/**
|
|
49
|
+
* Its DOM node type.
|
|
50
|
+
*/
|
|
51
|
+
nodeType: number;
|
|
52
|
+
/**
|
|
53
|
+
* Its name.
|
|
54
|
+
*/
|
|
55
|
+
nodeName: string;
|
|
56
|
+
/**
|
|
57
|
+
* A text node's text.
|
|
58
|
+
*/
|
|
59
|
+
nodeValue?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Its attributes, name and value in turn.
|
|
62
|
+
*/
|
|
63
|
+
attributes?: string[];
|
|
64
|
+
/**
|
|
65
|
+
* Its children.
|
|
66
|
+
*/
|
|
67
|
+
children?: CdpNode[];
|
|
68
|
+
/**
|
|
69
|
+
* Its shadow roots, closed ones too.
|
|
70
|
+
*/
|
|
71
|
+
shadowRoots?: CdpNode[];
|
|
72
|
+
/**
|
|
73
|
+
* A frame's document.
|
|
74
|
+
*/
|
|
75
|
+
contentDocument?: CdpNode;
|
|
76
|
+
};
|
|
77
|
+
/**
|
|
78
|
+
* A node as the DevTools protocol's `DOM.getDocument` gives it (the parts read here).
|
|
79
|
+
*
|
|
80
|
+
* @typedef {object} CdpNode
|
|
81
|
+
* @property {number} nodeId Its id in the session.
|
|
82
|
+
* @property {number} backendNodeId Its lasting id.
|
|
83
|
+
* @property {number} nodeType Its DOM node type.
|
|
84
|
+
* @property {string} nodeName Its name.
|
|
85
|
+
* @property {string} [nodeValue] A text node's text.
|
|
86
|
+
* @property {string[]} [attributes] Its attributes, name and value in turn.
|
|
87
|
+
* @property {CdpNode[]} [children] Its children.
|
|
88
|
+
* @property {CdpNode[]} [shadowRoots] Its shadow roots, closed ones too.
|
|
89
|
+
* @property {CdpNode} [contentDocument] A frame's document.
|
|
90
|
+
*/
|
|
91
|
+
/**
|
|
92
|
+
* Every node of a page's tree in tree order, closed shadow roots and same-process frames included.
|
|
93
|
+
*
|
|
94
|
+
* @param {CDPSession} cdp - A session on the page.
|
|
95
|
+
* @returns {Promise<CdpNode[]>} The nodes; a node's shadow roots come before its children.
|
|
96
|
+
* @example
|
|
97
|
+
* const boxes = (await pierceNodes(cdp)).filter((node) => nodeAttribute(node, 'class') === 'box');
|
|
98
|
+
*/
|
|
99
|
+
export declare function pierceNodes(cdp: CDPSession): Promise<CdpNode[]>;
|
|
100
|
+
/**
|
|
101
|
+
* A node's attribute.
|
|
102
|
+
*
|
|
103
|
+
* @param {CdpNode} node - The node.
|
|
104
|
+
* @param {string} name - The attribute.
|
|
105
|
+
* @returns {string} Its value, or ''.
|
|
106
|
+
*/
|
|
107
|
+
export declare function nodeAttribute(node: CdpNode, name: string): string;
|
|
108
|
+
/**
|
|
109
|
+
* A node's text: a text node's own, an element's children's joined.
|
|
110
|
+
*
|
|
111
|
+
* @param {CdpNode} node - The node.
|
|
112
|
+
* @returns {string} Its text.
|
|
113
|
+
*/
|
|
114
|
+
export declare const nodeText: (node: CdpNode) => string;
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reaching a tested extension's pages from an e2e test: a Playwright page's
|
|
3
|
+
* tab id (as the extension's worker sees it), a page module called in a
|
|
4
|
+
* tab's frames as the extension calls it, and a page's whole tree through
|
|
5
|
+
* the DevTools protocol, closed shadow roots and frames included (where an
|
|
6
|
+
* extension's overlay lives). Tests only: an extension never needs CDP.
|
|
7
|
+
*
|
|
8
|
+
* @module testing/pages
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** @import { CDPSession, Page, Worker } from 'playwright' */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The tab id of a page: the newest tab at its address (any tab for a `chrome:` page).
|
|
15
|
+
*
|
|
16
|
+
* @param {Worker} worker - The extension's service worker.
|
|
17
|
+
* @param {Page} page - The page.
|
|
18
|
+
* @returns {Promise<number>} Its tab's id.
|
|
19
|
+
* @example
|
|
20
|
+
* const tabId = await tabIdOf(worker, page);
|
|
21
|
+
*/
|
|
22
|
+
export async function tabIdOf(worker, page) {
|
|
23
|
+
return worker.evaluate(async (url) => {
|
|
24
|
+
const query = url.startsWith('chrome:') ? {} : { url: url.replace(/#.*$/, '') };
|
|
25
|
+
const ids = (await chrome.tabs.query(query)).map((tab) => tab.id ?? -1);
|
|
26
|
+
if (!ids.length) throw new Error(`No tab for ${url}`);
|
|
27
|
+
return Math.max(...ids);
|
|
28
|
+
}, page.url());
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Call an export of the extension's page module in a tab's frames, as `callInPage` does from the
|
|
33
|
+
* worker.
|
|
34
|
+
*
|
|
35
|
+
* @param {Worker} worker - The extension's service worker.
|
|
36
|
+
* @param {number} tabId - The tab.
|
|
37
|
+
* @param {string} module - The module's path in the extension (`content/main.js`).
|
|
38
|
+
* @param {string} name - The export.
|
|
39
|
+
* @param {unknown[]} [args] - Its arguments.
|
|
40
|
+
* @returns {Promise<{frameId: number, result: any}[]>} Each frame's answer.
|
|
41
|
+
* @example
|
|
42
|
+
* const [{ result }] = await callTabModule(worker, tabId, 'content/main.js', 'scan');
|
|
43
|
+
*/
|
|
44
|
+
export function callTabModule(worker, tabId, module, name, args = []) {
|
|
45
|
+
return worker.evaluate(({ tabId, module, name, args }) => chrome.scripting.executeScript({
|
|
46
|
+
target: { tabId, allFrames: true },
|
|
47
|
+
injectImmediately: true,
|
|
48
|
+
// As the kit's importAndCall (a worker's evaluate takes no function from outside, and its CSP no eval).
|
|
49
|
+
func: (/** @type {string} */ module, /** @type {string} */ name, /** @type {unknown[]} */ args) => import(chrome.runtime.getURL(module)).then((loaded) => loaded[name](...args)),
|
|
50
|
+
args: [module, name, args],
|
|
51
|
+
}).then((frames) => frames.map(({ frameId, result }) => ({ frameId, result }))), { tabId, module, name, args });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* A node as the DevTools protocol's `DOM.getDocument` gives it (the parts read here).
|
|
56
|
+
*
|
|
57
|
+
* @typedef {object} CdpNode
|
|
58
|
+
* @property {number} nodeId Its id in the session.
|
|
59
|
+
* @property {number} backendNodeId Its lasting id.
|
|
60
|
+
* @property {number} nodeType Its DOM node type.
|
|
61
|
+
* @property {string} nodeName Its name.
|
|
62
|
+
* @property {string} [nodeValue] A text node's text.
|
|
63
|
+
* @property {string[]} [attributes] Its attributes, name and value in turn.
|
|
64
|
+
* @property {CdpNode[]} [children] Its children.
|
|
65
|
+
* @property {CdpNode[]} [shadowRoots] Its shadow roots, closed ones too.
|
|
66
|
+
* @property {CdpNode} [contentDocument] A frame's document.
|
|
67
|
+
*/
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every node of a page's tree in tree order, closed shadow roots and same-process frames included.
|
|
71
|
+
*
|
|
72
|
+
* @param {CDPSession} cdp - A session on the page.
|
|
73
|
+
* @returns {Promise<CdpNode[]>} The nodes; a node's shadow roots come before its children.
|
|
74
|
+
* @example
|
|
75
|
+
* const boxes = (await pierceNodes(cdp)).filter((node) => nodeAttribute(node, 'class') === 'box');
|
|
76
|
+
*/
|
|
77
|
+
export async function pierceNodes(cdp) {
|
|
78
|
+
const { root } = /** @type {{root: CdpNode}} */ (await cdp.send('DOM.getDocument', { depth: -1, pierce: true }));
|
|
79
|
+
/** @type {CdpNode[]} */
|
|
80
|
+
const nodes = [];
|
|
81
|
+
/** @param {CdpNode} node */
|
|
82
|
+
const walk = (node) => {
|
|
83
|
+
nodes.push(node);
|
|
84
|
+
for (const child of [...(node.shadowRoots ?? []), ...(node.children ?? []), ...(node.contentDocument ? [node.contentDocument] : [])]) walk(child);
|
|
85
|
+
};
|
|
86
|
+
walk(root);
|
|
87
|
+
return nodes;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* A node's attribute.
|
|
92
|
+
*
|
|
93
|
+
* @param {CdpNode} node - The node.
|
|
94
|
+
* @param {string} name - The attribute.
|
|
95
|
+
* @returns {string} Its value, or ''.
|
|
96
|
+
*/
|
|
97
|
+
export function nodeAttribute(node, name) {
|
|
98
|
+
const attributes = node.attributes ?? [];
|
|
99
|
+
const at = attributes.findIndex((value, index) => index % 2 === 0 && value === name);
|
|
100
|
+
return at >= 0 ? attributes[at + 1] : '';
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A node's text: a text node's own, an element's children's joined.
|
|
105
|
+
*
|
|
106
|
+
* @param {CdpNode} node - The node.
|
|
107
|
+
* @returns {string} Its text.
|
|
108
|
+
*/
|
|
109
|
+
export const nodeText = (node) => (node.nodeType === 3 ? node.nodeValue ?? '' : (node.children ?? []).map(nodeText).join(''));
|
package/src/testing/prompt.d.ts
CHANGED
|
@@ -8,8 +8,10 @@
|
|
|
8
8
|
* Stand in for Chrome's permission prompt in a page: each `chrome.permissions.request` is
|
|
9
9
|
* recorded on `window.asked` (what was asked, and whether a click was behind it,
|
|
10
10
|
* `navigator.userActivation.isActive`) and answered from `answers` (the next one; the last is
|
|
11
|
-
* kept), as a person clicking “Allow” or “Deny” would.
|
|
12
|
-
*
|
|
11
|
+
* kept), as a person clicking “Allow” or “Deny” would. A request with no click behind it is
|
|
12
|
+
* refused as Chrome refuses it (“This function must be called during a user gesture”) and takes
|
|
13
|
+
* no answer. Runs in the page: give it to `page.evaluate` or `page.addInitScript`, so it takes
|
|
14
|
+
* no closures.
|
|
13
15
|
*
|
|
14
16
|
* @param {boolean[]} answers - The answers, in order.
|
|
15
17
|
* @example
|
package/src/testing/prompt.js
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
* Stand in for Chrome's permission prompt in a page: each `chrome.permissions.request` is
|
|
10
10
|
* recorded on `window.asked` (what was asked, and whether a click was behind it,
|
|
11
11
|
* `navigator.userActivation.isActive`) and answered from `answers` (the next one; the last is
|
|
12
|
-
* kept), as a person clicking “Allow” or “Deny” would.
|
|
13
|
-
*
|
|
12
|
+
* kept), as a person clicking “Allow” or “Deny” would. A request with no click behind it is
|
|
13
|
+
* refused as Chrome refuses it (“This function must be called during a user gesture”) and takes
|
|
14
|
+
* no answer. Runs in the page: give it to `page.evaluate` or `page.addInitScript`, so it takes
|
|
15
|
+
* no closures.
|
|
14
16
|
*
|
|
15
17
|
* @param {boolean[]} answers - The answers, in order.
|
|
16
18
|
* @example
|
|
@@ -23,7 +25,9 @@ export function playPermissionPrompt(answers) {
|
|
|
23
25
|
const asked = [];
|
|
24
26
|
Object.assign(window, { asked });
|
|
25
27
|
chrome.permissions.request = /** @type {typeof chrome.permissions.request} */ (/** @type {unknown} */ (async (/** @type {chrome.permissions.Permissions} */ permissions) => {
|
|
26
|
-
|
|
28
|
+
const gesture = navigator.userActivation.isActive;
|
|
29
|
+
asked.push({ permissions: permissions.permissions, origins: permissions.origins, gesture });
|
|
30
|
+
if (!gesture) throw new Error('This function must be called during a user gesture');
|
|
27
31
|
return answers.length > 1 ? answers.shift() : answers[0];
|
|
28
32
|
}));
|
|
29
33
|
}
|
package/src/time/pool.d.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* @template T
|
|
15
15
|
* @param {readonly T[]} items - The items.
|
|
16
|
-
* @param {number} limit - At most this many at once
|
|
16
|
+
* @param {number} limit - At most this many at once (at least one: a limit of 0 or less, or one
|
|
17
|
+
* that isn't a number, runs them one at a time rather than not at all).
|
|
17
18
|
* @param {(item: T, slot: number, index: number) => Promise<void>} work - Deals with one item;
|
|
18
19
|
* `slot` (0 to limit − 1) is the same for everything one slot does, for per-slot resources
|
|
19
20
|
* (a browser each).
|
package/src/time/pool.js
CHANGED
|
@@ -14,7 +14,8 @@
|
|
|
14
14
|
*
|
|
15
15
|
* @template T
|
|
16
16
|
* @param {readonly T[]} items - The items.
|
|
17
|
-
* @param {number} limit - At most this many at once
|
|
17
|
+
* @param {number} limit - At most this many at once (at least one: a limit of 0 or less, or one
|
|
18
|
+
* that isn't a number, runs them one at a time rather than not at all).
|
|
18
19
|
* @param {(item: T, slot: number, index: number) => Promise<void>} work - Deals with one item;
|
|
19
20
|
* `slot` (0 to limit − 1) is the same for everything one slot does, for per-slot resources
|
|
20
21
|
* (a browser each).
|
|
@@ -24,7 +25,8 @@
|
|
|
24
25
|
*/
|
|
25
26
|
export async function eachLimited(items, limit, work) {
|
|
26
27
|
let next = 0;
|
|
27
|
-
|
|
28
|
+
const slots = Math.max(1, Math.floor(limit) || 1);
|
|
29
|
+
await Promise.all(Array.from({ length: Math.min(slots, items.length) }, async (_, slot) => {
|
|
28
30
|
while (next < items.length) {
|
|
29
31
|
const index = next;
|
|
30
32
|
next += 1;
|
package/src/toolbar/theme.d.ts
CHANGED
|
@@ -132,9 +132,12 @@ export type OffscreenApi = {
|
|
|
132
132
|
*
|
|
133
133
|
* @param {OffscreenApi} api - `chrome.offscreen` and `chrome.runtime`.
|
|
134
134
|
* @param {string} [url] - The document, from the extension's root. Default {@link OFFSCREEN_PATH}.
|
|
135
|
+
* @param {string} [justification] - What the document is for, when it does more than watch the
|
|
136
|
+
* scheme (it answers for Chrome's on-device model too, `serveModelOffscreen`): Chrome keeps it
|
|
137
|
+
* with the document, so it has to be the whole truth.
|
|
135
138
|
* @returns {Promise<boolean>} Whether it opened one (false: already open).
|
|
136
139
|
*/
|
|
137
|
-
export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string): Promise<boolean>;
|
|
140
|
+
export declare function watchFromOffscreen({ offscreen, runtime }: OffscreenApi, url?: string, justification?: string): Promise<boolean>;
|
|
138
141
|
export type ToolbarWorkerApi = {
|
|
139
142
|
/**
|
|
140
143
|
* `chrome.storage`.
|
|
@@ -179,9 +182,14 @@ export type ToolbarWorkerApi = {
|
|
|
179
182
|
* worker wakes.
|
|
180
183
|
*
|
|
181
184
|
* @param {ToolbarWorkerApi} api - `chrome`, or a fake.
|
|
185
|
+
* @param {{url?: string, justification?: string}} [document] - The offscreen document and what
|
|
186
|
+
* it's for, when it isn't the kit's defaults ({@link watchFromOffscreen}).
|
|
182
187
|
* @returns {ToolbarTheme} The theme, for tests.
|
|
183
188
|
* @example
|
|
184
189
|
* // background.js
|
|
185
190
|
* startToolbarTheme(chrome);
|
|
186
191
|
*/
|
|
187
|
-
export declare function startToolbarTheme(api: ToolbarWorkerApi
|
|
192
|
+
export declare function startToolbarTheme(api: ToolbarWorkerApi, { url, justification }?: {
|
|
193
|
+
url?: string;
|
|
194
|
+
justification?: string;
|
|
195
|
+
}): ToolbarTheme;
|
package/src/toolbar/theme.js
CHANGED
|
@@ -116,6 +116,9 @@ export function createToolbarTheme({ storage, action, extensionId }) {
|
|
|
116
116
|
/** @type {Promise<boolean> | null} */
|
|
117
117
|
let opening = null;
|
|
118
118
|
|
|
119
|
+
/** Why the offscreen document is open, as Chrome is told. */
|
|
120
|
+
const SCHEME_JUSTIFICATION = 'Reads whether Chrome is in light or dark mode, to show the matching toolbar icon.';
|
|
121
|
+
|
|
119
122
|
/**
|
|
120
123
|
* Open the offscreen document that watches the scheme (reason MATCH_MEDIA),
|
|
121
124
|
* unless it's open. It stays open: it's one small page in the extension's own
|
|
@@ -125,16 +128,19 @@ let opening = null;
|
|
|
125
128
|
*
|
|
126
129
|
* @param {OffscreenApi} api - `chrome.offscreen` and `chrome.runtime`.
|
|
127
130
|
* @param {string} [url] - The document, from the extension's root. Default {@link OFFSCREEN_PATH}.
|
|
131
|
+
* @param {string} [justification] - What the document is for, when it does more than watch the
|
|
132
|
+
* scheme (it answers for Chrome's on-device model too, `serveModelOffscreen`): Chrome keeps it
|
|
133
|
+
* with the document, so it has to be the whole truth.
|
|
128
134
|
* @returns {Promise<boolean>} Whether it opened one (false: already open).
|
|
129
135
|
*/
|
|
130
|
-
export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH) {
|
|
136
|
+
export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH, justification = SCHEME_JUSTIFICATION) {
|
|
131
137
|
opening ??= (async () => {
|
|
132
138
|
const open = await runtime.getContexts({ contextTypes: ['OFFSCREEN_DOCUMENT'] });
|
|
133
139
|
if (open.length) return false;
|
|
134
140
|
await offscreen.createDocument({
|
|
135
141
|
url,
|
|
136
142
|
reasons: [offscreen.Reason.MATCH_MEDIA],
|
|
137
|
-
justification
|
|
143
|
+
justification,
|
|
138
144
|
});
|
|
139
145
|
return true;
|
|
140
146
|
})().finally(() => { opening = null; });
|
|
@@ -159,15 +165,17 @@ export function watchFromOffscreen({ offscreen, runtime }, url = OFFSCREEN_PATH)
|
|
|
159
165
|
* worker wakes.
|
|
160
166
|
*
|
|
161
167
|
* @param {ToolbarWorkerApi} api - `chrome`, or a fake.
|
|
168
|
+
* @param {{url?: string, justification?: string}} [document] - The offscreen document and what
|
|
169
|
+
* it's for, when it isn't the kit's defaults ({@link watchFromOffscreen}).
|
|
162
170
|
* @returns {ToolbarTheme} The theme, for tests.
|
|
163
171
|
* @example
|
|
164
172
|
* // background.js
|
|
165
173
|
* startToolbarTheme(chrome);
|
|
166
174
|
*/
|
|
167
|
-
export function startToolbarTheme(api) {
|
|
175
|
+
export function startToolbarTheme(api, { url, justification } = {}) {
|
|
168
176
|
const theme = createToolbarTheme({ storage: api.storage.local, action: api.action, extensionId: api.runtime.id });
|
|
169
177
|
theme.restore();
|
|
170
|
-
watchFromOffscreen(api).catch((error) => console.error('Opening the colour scheme watcher:', error));
|
|
178
|
+
watchFromOffscreen(api, url, justification).catch((error) => console.error('Opening the colour scheme watcher:', error));
|
|
171
179
|
api.runtime.onMessage.addListener((message, sender) => {
|
|
172
180
|
theme.onMessage(message, sender);
|
|
173
181
|
return false;
|
package/src/ui/menu.d.ts
CHANGED
|
@@ -99,8 +99,10 @@ export type Box = {
|
|
|
99
99
|
*
|
|
100
100
|
* @param {{width: number, height: number}} size - The menu's size.
|
|
101
101
|
* @param {{width: number, height: number}} viewport - The window's (`innerWidth`, `innerHeight`).
|
|
102
|
-
* @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below'}} where - The
|
|
103
|
-
* box, or a point; `placement` says where it goes from an anchor (default `'over'`)
|
|
102
|
+
* @param {{anchor?: Box, x?: number, y?: number, placement?: 'over' | 'below', rtl?: boolean}} where - The
|
|
103
|
+
* anchor's box, or a point; `placement` says where it goes from an anchor (default `'over'`); `rtl`
|
|
104
|
+
* (a right-to-left page) lines its start up with the anchor's left edge and opens leftwards from a
|
|
105
|
+
* point, as Chrome mirrors its own.
|
|
104
106
|
* @returns {{left: number, top: number}} The menu's top-left, in px.
|
|
105
107
|
* @example
|
|
106
108
|
* menuPosition({width: 160, height: 120}, {width: 800, height: 600}, {anchor: button.getBoundingClientRect()});
|
|
@@ -112,11 +114,12 @@ export declare function menuPosition(size: {
|
|
|
112
114
|
}, viewport: {
|
|
113
115
|
width: number;
|
|
114
116
|
height: number;
|
|
115
|
-
}, { anchor, x, y, placement }: {
|
|
117
|
+
}, { anchor, x, y, placement, rtl }: {
|
|
116
118
|
anchor?: Box;
|
|
117
119
|
x?: number;
|
|
118
120
|
y?: number;
|
|
119
121
|
placement?: 'over' | 'below';
|
|
122
|
+
rtl?: boolean;
|
|
120
123
|
}): {
|
|
121
124
|
left: number;
|
|
122
125
|
top: number;
|
|
@@ -163,6 +166,11 @@ export type ActionMenu = {
|
|
|
163
166
|
* Close it, if open, and return the focus.
|
|
164
167
|
*/
|
|
165
168
|
close: () => void;
|
|
169
|
+
/**
|
|
170
|
+
* Take it off the page and stop listening to the window (a content
|
|
171
|
+
* script's overlay being removed).
|
|
172
|
+
*/
|
|
173
|
+
destroy: () => void;
|
|
166
174
|
};
|
|
167
175
|
/**
|
|
168
176
|
* What {@link createMenu} takes.
|
|
@@ -182,6 +190,8 @@ export type ActionMenu = {
|
|
|
182
190
|
* @property {(entries: readonly MenuEntry[], where: MenuPlace) => void} open Fill it with `entries`
|
|
183
191
|
* and open it (closing it first if it's open).
|
|
184
192
|
* @property {() => void} close Close it, if open, and return the focus.
|
|
193
|
+
* @property {() => void} destroy Take it off the page and stop listening to the window (a content
|
|
194
|
+
* script's overlay being removed).
|
|
185
195
|
*/
|
|
186
196
|
/**
|
|
187
197
|
* Make a page's action menu: one `<dialog class="menu">` that any number of
|