@arraypress/extension-kit 1.10.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 +97 -20
- 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/vendor.d.ts +29 -8
- package/src/build/vendor.js +136 -40
- package/src/content/highlight.d.ts +15 -6
- package/src/content/highlight.js +42 -17
- package/src/content/overlay.d.ts +19 -4
- package/src/content/overlay.js +44 -6
- 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/focus.d.ts +11 -0
- package/src/dom/focus.js +15 -0
- package/src/frame/window.d.ts +1 -1
- package/src/frame/window.js +8 -7
- package/src/index.d.ts +10 -3
- package/src/index.js +8 -3
- 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 +17 -5
- package/src/scripting/tab.js +15 -7
- 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 +56 -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 +3 -3
- package/src/testing/index.js +5 -3
- package/src/testing/live-run.d.ts +23 -2
- package/src/testing/live-run.js +64 -18
- 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/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/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/index.d.ts
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export { TEST_BUILDS_ENV, testBuildDir } from '../build/test-build.js';
|
|
10
10
|
export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
|
|
11
|
-
export { CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, runChecks, until, } from './e2e.js';
|
|
11
|
+
export { CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, runChecks, stopWorker, until, } from './e2e.js';
|
|
12
12
|
export { exitPoint, parseLiveOptions, parseProxy } from './live.js';
|
|
13
|
-
export { liveUsage, runLiveCheck } from './live-run.js';
|
|
13
|
+
export { REPORT_MARKER, liveUsage, reportFolder, runLiveCheck } from './live-run.js';
|
|
14
14
|
export { eachLimited } from '../time/pool.js';
|
|
15
15
|
export { filesIn, launchDownloading } from './downloads.js';
|
|
16
16
|
export { fakeLanguageModel } from './fake-model.js';
|
|
@@ -19,7 +19,7 @@ export { heartbeat } from './heartbeat.js';
|
|
|
19
19
|
export { playPermissionPrompt } from './prompt.js';
|
|
20
20
|
export { callTabModule, nodeAttribute, nodeText, pierceNodes, tabIdOf } from './pages.js';
|
|
21
21
|
export { serve, testCertificate } from './server.js';
|
|
22
|
-
export { SYNC_QUOTA_BYTES, SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
22
|
+
export { LOCAL_QUOTA_BYTES, SYNC_MAX_ITEMS, SYNC_QUOTA_BYTES, SYNC_WRITES_PER_HOUR, SYNC_WRITES_PER_MINUTE, fakeChromeStorage, } from './fake-storage.js';
|
|
23
23
|
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
|
24
24
|
export { savedTabGroups } from './saved-groups.js';
|
|
25
25
|
export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
|
package/src/testing/index.js
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
export { TEST_BUILDS_ENV, testBuildDir } from '../build/test-build.js';
|
|
11
11
|
export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
|
|
12
12
|
export {
|
|
13
|
-
CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, runChecks, until,
|
|
13
|
+
CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, runChecks, stopWorker, until,
|
|
14
14
|
} from './e2e.js';
|
|
15
15
|
export { exitPoint, parseLiveOptions, parseProxy } from './live.js';
|
|
16
|
-
export { liveUsage, runLiveCheck } from './live-run.js';
|
|
16
|
+
export { REPORT_MARKER, liveUsage, reportFolder, runLiveCheck } from './live-run.js';
|
|
17
17
|
export { eachLimited } from '../time/pool.js';
|
|
18
18
|
export { filesIn, launchDownloading } from './downloads.js';
|
|
19
19
|
export { fakeLanguageModel } from './fake-model.js';
|
|
@@ -22,7 +22,9 @@ export { heartbeat } from './heartbeat.js';
|
|
|
22
22
|
export { playPermissionPrompt } from './prompt.js';
|
|
23
23
|
export { callTabModule, nodeAttribute, nodeText, pierceNodes, tabIdOf } from './pages.js';
|
|
24
24
|
export { serve, testCertificate } from './server.js';
|
|
25
|
-
export {
|
|
25
|
+
export {
|
|
26
|
+
LOCAL_QUOTA_BYTES, SYNC_MAX_ITEMS, SYNC_QUOTA_BYTES, SYNC_WRITES_PER_HOUR, SYNC_WRITES_PER_MINUTE, fakeChromeStorage,
|
|
27
|
+
} from './fake-storage.js';
|
|
26
28
|
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
|
27
29
|
export { savedTabGroups } from './saved-groups.js';
|
|
28
30
|
export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
|
|
@@ -80,10 +80,31 @@ export type LiveRun<S extends LiveSite, R> = {
|
|
|
80
80
|
* @returns {string} The text.
|
|
81
81
|
*/
|
|
82
82
|
export declare const liveUsage: (categories: string[], concurrency: number) => string;
|
|
83
|
+
/** The file that marks a folder as a live report the kit made, so the next run may replace it. */
|
|
84
|
+
export declare const REPORT_MARKER = ".live-report";
|
|
85
|
+
/**
|
|
86
|
+
* The folder a run's report goes in, made empty. Only a folder the kit made is ever emptied:
|
|
87
|
+
* the checker's own (`own`), or one carrying {@link REPORT_MARKER} from an earlier run. A new
|
|
88
|
+
* or empty folder is taken (and marked); a folder with anything else in it is refused, so
|
|
89
|
+
* `--out .` or `--out ~/Desktop` can't delete what's there.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} dir - The folder.
|
|
92
|
+
* @param {{own?: boolean}} [options] - `own`: the checker's default folder, replaced without asking.
|
|
93
|
+
* @returns {Promise<string>} `dir`, empty but for the marker.
|
|
94
|
+
* @throws {Error} When the folder holds files and isn't a report folder.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* await reportFolder('/tmp/report'); // made, or emptied if an earlier run made it
|
|
98
|
+
*/
|
|
99
|
+
export declare function reportFolder(dir: string, { own }?: {
|
|
100
|
+
own?: boolean;
|
|
101
|
+
}): Promise<string>;
|
|
83
102
|
/**
|
|
84
103
|
* Run a live check: read the command line, visit the chosen sites, give back every visit.
|
|
85
104
|
* Prints the usage and exits for `--help` (or an option it doesn't know), and exits when no
|
|
86
|
-
* site matches.
|
|
105
|
+
* site matches or `--out` names a folder with other files in it ({@link reportFolder}). A visit
|
|
106
|
+
* that throws, runs out of time or loses its browser is `failed` with why, and the run goes on
|
|
107
|
+
* (the slot gets a new browser).
|
|
87
108
|
*
|
|
88
109
|
* @template {LiveSite} S
|
|
89
110
|
* @template R
|
|
@@ -96,7 +117,7 @@ export declare const liveUsage: (categories: string[], concurrency: number) => s
|
|
|
96
117
|
* @param {string[]} run.categories - The categories' ids, for the usage.
|
|
97
118
|
* @param {number} run.pageMs - The most a visit may take.
|
|
98
119
|
* @param {(browser: LaunchedExtension, page: Page, site: S) => Promise<R>} run.visit - Check one site, on a fresh page.
|
|
99
|
-
* @param {(site: S, error: string) => R} run.failed - A visit that couldn't finish
|
|
120
|
+
* @param {(site: S, error: string) => R} run.failed - A visit that couldn't finish: `'timed out'`, or the error's message.
|
|
100
121
|
* @param {(result: {site: S, visit: R}) => void} [run.onResult] - Each visit as it finishes (a line of output).
|
|
101
122
|
* @param {{argv?: string[], env?: Record<string, string | undefined>, concurrency?: number, findExit?: typeof exitPoint}} [run.command] - The
|
|
102
123
|
* arguments and environment (default the process's), the most sites at once (default 4), and how the exit point
|
package/src/testing/live-run.js
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* @module testing/live-run
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { mkdir, rm } from 'node:fs/promises';
|
|
13
|
+
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
-
import { within } from '../time/time.js';
|
|
15
|
+
import { deadline, within } from '../time/time.js';
|
|
16
16
|
import { eachLimited } from '../time/pool.js';
|
|
17
17
|
import { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
|
|
18
18
|
import { launchExtension } from './e2e.js';
|
|
@@ -60,10 +60,43 @@ export const liveUsage = (categories, concurrency) => `Usage: npm run live -- [o
|
|
|
60
60
|
--out <dir> write the report there (default test/live/report)
|
|
61
61
|
--help`;
|
|
62
62
|
|
|
63
|
+
/** The file that marks a folder as a live report the kit made, so the next run may replace it. */
|
|
64
|
+
export const REPORT_MARKER = '.live-report';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The folder a run's report goes in, made empty. Only a folder the kit made is ever emptied:
|
|
68
|
+
* the checker's own (`own`), or one carrying {@link REPORT_MARKER} from an earlier run. A new
|
|
69
|
+
* or empty folder is taken (and marked); a folder with anything else in it is refused, so
|
|
70
|
+
* `--out .` or `--out ~/Desktop` can't delete what's there.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} dir - The folder.
|
|
73
|
+
* @param {{own?: boolean}} [options] - `own`: the checker's default folder, replaced without asking.
|
|
74
|
+
* @returns {Promise<string>} `dir`, empty but for the marker.
|
|
75
|
+
* @throws {Error} When the folder holds files and isn't a report folder.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* await reportFolder('/tmp/report'); // made, or emptied if an earlier run made it
|
|
79
|
+
*/
|
|
80
|
+
export async function reportFolder(dir, { own = false } = {}) {
|
|
81
|
+
const entries = await readdir(dir).catch(() => /** @type {string[]} */ ([]));
|
|
82
|
+
if (entries.length && !own && !entries.includes(REPORT_MARKER)) {
|
|
83
|
+
throw new Error(`${dir} has files in it and isn't a report folder: choose a new or empty folder for --out`);
|
|
84
|
+
}
|
|
85
|
+
if (entries.length) await rm(dir, { recursive: true, force: true });
|
|
86
|
+
await mkdir(dir, { recursive: true });
|
|
87
|
+
await writeFile(path.join(dir, REPORT_MARKER), 'A live check\'s report: the next run replaces this folder.\n');
|
|
88
|
+
return dir;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Stands for a visit that ran out of time. */
|
|
92
|
+
const LATE = Symbol('late');
|
|
93
|
+
|
|
63
94
|
/**
|
|
64
95
|
* Run a live check: read the command line, visit the chosen sites, give back every visit.
|
|
65
96
|
* Prints the usage and exits for `--help` (or an option it doesn't know), and exits when no
|
|
66
|
-
* site matches.
|
|
97
|
+
* site matches or `--out` names a folder with other files in it ({@link reportFolder}). A visit
|
|
98
|
+
* that throws, runs out of time or loses its browser is `failed` with why, and the run goes on
|
|
99
|
+
* (the slot gets a new browser).
|
|
67
100
|
*
|
|
68
101
|
* @template {LiveSite} S
|
|
69
102
|
* @template R
|
|
@@ -76,7 +109,7 @@ export const liveUsage = (categories, concurrency) => `Usage: npm run live -- [o
|
|
|
76
109
|
* @param {string[]} run.categories - The categories' ids, for the usage.
|
|
77
110
|
* @param {number} run.pageMs - The most a visit may take.
|
|
78
111
|
* @param {(browser: LaunchedExtension, page: Page, site: S) => Promise<R>} run.visit - Check one site, on a fresh page.
|
|
79
|
-
* @param {(site: S, error: string) => R} run.failed - A visit that couldn't finish
|
|
112
|
+
* @param {(site: S, error: string) => R} run.failed - A visit that couldn't finish: `'timed out'`, or the error's message.
|
|
80
113
|
* @param {(result: {site: S, visit: R}) => void} [run.onResult] - Each visit as it finishes (a line of output).
|
|
81
114
|
* @param {{argv?: string[], env?: Record<string, string | undefined>, concurrency?: number, findExit?: typeof exitPoint}} [run.command] - The
|
|
82
115
|
* arguments and environment (default the process's), the most sites at once (default 4), and how the exit point
|
|
@@ -109,9 +142,14 @@ export async function runLiveCheck({ chromium, build, prefix, root, sites, categ
|
|
|
109
142
|
console.error('No pages match.');
|
|
110
143
|
process.exit(2);
|
|
111
144
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
145
|
+
/** @type {string} */
|
|
146
|
+
let dir;
|
|
147
|
+
try {
|
|
148
|
+
dir = await reportFolder(options.out ? path.resolve(options.out) : path.join(root, 'test', 'live', 'report'), { own: !options.out });
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error(error instanceof Error ? error.message : error);
|
|
151
|
+
process.exit(2);
|
|
152
|
+
}
|
|
115
153
|
|
|
116
154
|
const browserOptions = realisticBrowser(options.proxy ? { proxy: options.proxy } : {});
|
|
117
155
|
const plain = await chromium.launch({ channel: 'chromium', ...browserOptions });
|
|
@@ -133,19 +171,27 @@ export async function runLiveCheck({ chromium, build, prefix, root, sites, categ
|
|
|
133
171
|
/** @type {{site: S, visit: R}[]} */
|
|
134
172
|
const results = [];
|
|
135
173
|
await eachLimited(chosen, options.concurrency, async (site, slot, index) => {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
174
|
+
/** @type {R} */
|
|
175
|
+
let outcome;
|
|
176
|
+
try {
|
|
177
|
+
browsers[slot] ??= await launchExtension(chromium, build, { prefix: `${prefix}${slot}-`, ...launchOptions });
|
|
178
|
+
const launched = /** @type {LaunchedExtension} */ (browsers[slot]);
|
|
179
|
+
await within(launched.context.clearCookies(), 10_000, undefined);
|
|
180
|
+
const page = await launched.context.newPage();
|
|
181
|
+
try {
|
|
182
|
+
const result = await deadline(/** @type {Promise<R | typeof LATE>} */ (visit(launched, page, site)), pageMs, (resolve) => resolve(LATE));
|
|
183
|
+
if (result === LATE) throw new Error('timed out');
|
|
184
|
+
outcome = result;
|
|
185
|
+
} finally {
|
|
186
|
+
await within(page.close(), 15_000, undefined);
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
// A visit that failed may have left its browser stuck, or lost it: the slot gets a new one.
|
|
190
|
+
await within(Promise.resolve(browsers[slot]?.close()), 15_000, undefined);
|
|
146
191
|
browsers[slot] = null;
|
|
192
|
+
outcome = failed(site, error instanceof Error ? error.message : String(error));
|
|
147
193
|
}
|
|
148
|
-
results[index] = { site, visit:
|
|
194
|
+
results[index] = { site, visit: outcome };
|
|
149
195
|
onResult(results[index]);
|
|
150
196
|
});
|
|
151
197
|
return {
|
|
@@ -21,13 +21,17 @@ export type Messages = Record<string, {
|
|
|
21
21
|
*/
|
|
22
22
|
export declare function readMessages(extensionDir: string, locale: string): Messages;
|
|
23
23
|
/**
|
|
24
|
-
* `chrome.i18n.getMessage` over a locale's real messages
|
|
24
|
+
* `chrome.i18n.getMessage` over a locale's real messages, as Chrome reads them (checked against
|
|
25
|
+
* Chromium): names found whatever their case, named placeholders and `$1`–`$9` filled, `$$` a
|
|
26
|
+
* dollar sign, '' for a missing key, `undefined` for more than nine substitutions.
|
|
25
27
|
*
|
|
26
28
|
* @param {string} extensionDir - The extension's folder.
|
|
27
29
|
* @param {string} [locale] - Default "en"; another locale is read over en.
|
|
28
|
-
* @returns {(key: string, substitutions?: string | string[]) => string} The lookup.
|
|
30
|
+
* @returns {(key: string, substitutions?: string | number | (string | number)[]) => string} The lookup.
|
|
31
|
+
* @throws {Error} When a message names a placeholder it doesn't define (Chrome refuses to load the extension).
|
|
32
|
+
*
|
|
29
33
|
* @example
|
|
30
34
|
* const t = messageLookup('extension');
|
|
31
35
|
* t('optionsMoreActions', ['example.com']); // 'More actions for example.com'
|
|
32
36
|
*/
|
|
33
|
-
export declare function messageLookup(extensionDir: string, locale?: string): (key: string, substitutions?: string | string[]) => string;
|
|
37
|
+
export declare function messageLookup(extensionDir: string, locale?: string): (key: string, substitutions?: string | number | (string | number)[]) => string;
|
package/src/testing/messages.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Chrome's message lookup for an extension's unit tests: its real `_locales`
|
|
3
3
|
* files, a locale's messages over `en`'s, named placeholders ("$SITE$" →
|
|
4
4
|
* `{site: {content: '$1'}}`) filled from substitutions as
|
|
5
|
-
* `chrome.i18n.getMessage` fills them
|
|
5
|
+
* `chrome.i18n.getMessage` fills them, and refused as Chrome refuses them (a
|
|
6
|
+
* placeholder that isn't defined). Node only. From the copies in Better
|
|
6
7
|
* Prices, Better Downloads, Better Saves and Clean Links (`test/helpers/i18n.js`).
|
|
7
8
|
*
|
|
8
9
|
* @module testing/messages
|
|
@@ -29,23 +30,63 @@ export function readMessages(extensionDir, locale) {
|
|
|
29
30
|
return JSON.parse(readFileSync(path.join(extensionDir, '_locales', locale, 'messages.json'), 'utf8'));
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** A named placeholder in a message, as Chrome reads one: `$SITE$` (letters, digits, `_`, `@`). */
|
|
34
|
+
const PLACEHOLDER = /\$([A-Za-z0-9_@]+)\$/g;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A message with its named placeholders put in, as Chrome does when it loads an extension.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} key - The message's name, for the error.
|
|
40
|
+
* @param {Messages[string]} entry - The message.
|
|
41
|
+
* @returns {string} The message, `$1`-style substitutions still to fill.
|
|
42
|
+
* @throws {Error} When it names a placeholder it doesn't define: Chrome refuses to load such an extension.
|
|
43
|
+
*/
|
|
44
|
+
function withPlaceholders(key, entry) {
|
|
45
|
+
const contents = new Map(Object.entries(entry.placeholders ?? {}).map(([name, { content }]) => [name.toLowerCase(), content]));
|
|
46
|
+
return entry.message.replace(PLACEHOLDER, (_, name) => {
|
|
47
|
+
const content = contents.get(name.toLowerCase());
|
|
48
|
+
if (content === undefined) throw new Error(`Message “${key}” uses $${name}$ but has no such placeholder: Chrome wouldn't load the extension`);
|
|
49
|
+
return content;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
/**
|
|
33
|
-
*
|
|
54
|
+
* Fill a message's substitutions as Chrome does: `$1`–`$9` from the list ('' past its end), `$$`
|
|
55
|
+
* a dollar sign; a `$` before anything else is dropped with what follows it.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} message - The message, named placeholders already put in.
|
|
58
|
+
* @param {readonly string[]} subs - The substitutions.
|
|
59
|
+
* @returns {string} The text.
|
|
60
|
+
*/
|
|
61
|
+
function substituted(message, subs) {
|
|
62
|
+
return message.replace(/\$(\$+|[\s\S])?/g, (_, next) => {
|
|
63
|
+
if (next === undefined) return '';
|
|
64
|
+
if (next.startsWith('$')) return next;
|
|
65
|
+
return /[1-9]/.test(next) ? String(subs[Number(next) - 1] ?? '') : '';
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `chrome.i18n.getMessage` over a locale's real messages, as Chrome reads them (checked against
|
|
71
|
+
* Chromium): names found whatever their case, named placeholders and `$1`–`$9` filled, `$$` a
|
|
72
|
+
* dollar sign, '' for a missing key, `undefined` for more than nine substitutions.
|
|
34
73
|
*
|
|
35
74
|
* @param {string} extensionDir - The extension's folder.
|
|
36
75
|
* @param {string} [locale] - Default "en"; another locale is read over en.
|
|
37
|
-
* @returns {(key: string, substitutions?: string | string[]) => string} The lookup.
|
|
76
|
+
* @returns {(key: string, substitutions?: string | number | (string | number)[]) => string} The lookup.
|
|
77
|
+
* @throws {Error} When a message names a placeholder it doesn't define (Chrome refuses to load the extension).
|
|
78
|
+
*
|
|
38
79
|
* @example
|
|
39
80
|
* const t = messageLookup('extension');
|
|
40
81
|
* t('optionsMoreActions', ['example.com']); // 'More actions for example.com'
|
|
41
82
|
*/
|
|
42
83
|
export function messageLookup(extensionDir, locale = 'en') {
|
|
43
|
-
const
|
|
84
|
+
const read = { ...readMessages(extensionDir, 'en'), ...(locale === 'en' ? {} : readMessages(extensionDir, locale)) };
|
|
85
|
+
const messages = new Map(Object.entries(read).map(([key, entry]) => [key.toLowerCase(), withPlaceholders(key, entry)]));
|
|
44
86
|
return (key, substitutions = []) => {
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
47
|
-
const
|
|
48
|
-
return
|
|
49
|
-
.replace(/\$(\d)/g, (/** @type {string} */ __, /** @type {string} */ n) => subs[Number(n) - 1] ?? ''));
|
|
87
|
+
const subs = [substitutions].flat().map(String);
|
|
88
|
+
if (subs.length > 9) return /** @type {string} */ (/** @type {unknown} */ (undefined));
|
|
89
|
+
const message = messages.get(key.toLowerCase());
|
|
90
|
+
return message === undefined ? '' : substituted(message, subs);
|
|
50
91
|
};
|
|
51
92
|
}
|
package/src/testing/package.d.ts
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
|
*/
|
|
@@ -33,6 +33,10 @@ export type ManifestFiles = {
|
|
|
33
33
|
options_ui?: {
|
|
34
34
|
page?: string;
|
|
35
35
|
};
|
|
36
|
+
/**
|
|
37
|
+
* The options page, the older way.
|
|
38
|
+
*/
|
|
39
|
+
options_page?: string;
|
|
36
40
|
/**
|
|
37
41
|
* The popup and toolbar icon.
|
|
38
42
|
*/
|
|
@@ -56,6 +60,39 @@ export type ManifestFiles = {
|
|
|
56
60
|
web_accessible_resources?: {
|
|
57
61
|
resources: string[];
|
|
58
62
|
}[];
|
|
63
|
+
/**
|
|
64
|
+
* Scripts and styles Chrome puts in pages.
|
|
65
|
+
*/
|
|
66
|
+
content_scripts?: {
|
|
67
|
+
js?: string[];
|
|
68
|
+
css?: string[];
|
|
69
|
+
}[];
|
|
70
|
+
/**
|
|
71
|
+
* The locale whose `_locales/<locale>/messages.json` must be there.
|
|
72
|
+
*/
|
|
73
|
+
default_locale?: string;
|
|
74
|
+
/**
|
|
75
|
+
* Rule files.
|
|
76
|
+
*/
|
|
77
|
+
declarative_net_request?: {
|
|
78
|
+
rule_resources?: {
|
|
79
|
+
path: string;
|
|
80
|
+
}[];
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Pages that replace Chrome's (the new tab page).
|
|
84
|
+
*/
|
|
85
|
+
chrome_url_overrides?: Record<string, string>;
|
|
86
|
+
/**
|
|
87
|
+
* The DevTools page.
|
|
88
|
+
*/
|
|
89
|
+
devtools_page?: string;
|
|
90
|
+
/**
|
|
91
|
+
* Sandboxed pages.
|
|
92
|
+
*/
|
|
93
|
+
sandbox?: {
|
|
94
|
+
pages?: string[];
|
|
95
|
+
};
|
|
59
96
|
};
|
|
60
97
|
/**
|
|
61
98
|
* The parts of a manifest that name files.
|
|
@@ -63,16 +100,25 @@ export type ManifestFiles = {
|
|
|
63
100
|
* @typedef {object} ManifestFiles
|
|
64
101
|
* @property {{service_worker?: string}} [background] The worker.
|
|
65
102
|
* @property {{page?: string}} [options_ui] The options page.
|
|
103
|
+
* @property {string} [options_page] The options page, the older way.
|
|
66
104
|
* @property {{default_popup?: string, default_icon?: string | Record<string, string>}} [action] The popup and toolbar icon.
|
|
67
105
|
* @property {{default_path?: string}} [side_panel] The side panel.
|
|
68
106
|
* @property {Record<string, string>} [icons] The extension's icons.
|
|
69
107
|
* @property {{resources: string[]}[]} [web_accessible_resources] Files pages may load.
|
|
108
|
+
* @property {{js?: string[], css?: string[]}[]} [content_scripts] Scripts and styles Chrome puts in pages.
|
|
109
|
+
* @property {string} [default_locale] The locale whose `_locales/<locale>/messages.json` must be there.
|
|
110
|
+
* @property {{rule_resources?: {path: string}[]}} [declarative_net_request] Rule files.
|
|
111
|
+
* @property {Record<string, string>} [chrome_url_overrides] Pages that replace Chrome's (the new tab page).
|
|
112
|
+
* @property {string} [devtools_page] The DevTools page.
|
|
113
|
+
* @property {{pages?: string[]}} [sandbox] Sandboxed pages.
|
|
70
114
|
*/
|
|
71
115
|
/**
|
|
72
116
|
* The paths a manifest names, from the extension's root.
|
|
73
117
|
*
|
|
74
118
|
* @param {ManifestFiles} manifest - The parsed `manifest.json`.
|
|
75
|
-
* @returns {string[]} Worker, pages, icons, side panel
|
|
119
|
+
* @returns {string[]} Worker, pages, icons, side panel, content scripts and their styles, the default
|
|
120
|
+
* locale's messages, rule files, overridden and DevTools pages, and web-accessible resources
|
|
121
|
+
* (less patterns with `*`).
|
|
76
122
|
*/
|
|
77
123
|
export declare function manifestReferences(manifest: ManifestFiles): string[];
|
|
78
124
|
/**
|
|
@@ -84,11 +130,14 @@ export declare function manifestReferences(manifest: ManifestFiles): string[];
|
|
|
84
130
|
*/
|
|
85
131
|
export declare function referencedPath(reference: string): string;
|
|
86
132
|
/**
|
|
87
|
-
* Every path the extension names that isn't a file
|
|
88
|
-
*
|
|
89
|
-
* in its pages,
|
|
90
|
-
*
|
|
91
|
-
* `
|
|
133
|
+
* Every path the extension names that isn't a file it ships: relative imports in its scripts
|
|
134
|
+
* (`from`, `import()` and a bare `import './x.js'`), `chrome.runtime.getURL('…')` paths,
|
|
135
|
+
* `src`/`href` in its pages, `url(…)` and `@import` in its stylesheets, the manifest's entries
|
|
136
|
+
* ({@link manifestReferences}), and any `extra` paths. A file that's there but left out of the
|
|
137
|
+
* zip (`shipsFile`: an SVG under `icons/`, a declaration, a vendor stamp) is as missing as one
|
|
138
|
+
* that isn't: the store build won't have it. A `#fragment` or `?query` is ignored:
|
|
139
|
+
* `getURL('options.html#workspaces')` names `options.html`. A path starting `/` is from the
|
|
140
|
+
* extension's root. Files that don't ship aren't read.
|
|
92
141
|
*
|
|
93
142
|
* @param {string} dir - The extension folder.
|
|
94
143
|
* @param {readonly string[]} [extra] - More paths from the root (say, a script injected by name).
|
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'));
|
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;
|