@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.
Files changed (68) hide show
  1. package/README.md +97 -20
  2. package/bin/extension-kit.js +2 -1
  3. package/package.json +1 -1
  4. package/src/ai/host.d.ts +183 -0
  5. package/src/ai/host.js +225 -0
  6. package/src/ai/model.d.ts +2 -1
  7. package/src/ai/model.js +10 -2
  8. package/src/build/vendor.d.ts +29 -8
  9. package/src/build/vendor.js +136 -40
  10. package/src/content/highlight.d.ts +15 -6
  11. package/src/content/highlight.js +42 -17
  12. package/src/content/overlay.d.ts +19 -4
  13. package/src/content/overlay.js +44 -6
  14. package/src/content/slices.d.ts +3 -1
  15. package/src/content/slices.js +29 -14
  16. package/src/content/watch.d.ts +4 -2
  17. package/src/content/watch.js +17 -3
  18. package/src/dom/focus.d.ts +11 -0
  19. package/src/dom/focus.js +15 -0
  20. package/src/frame/window.d.ts +1 -1
  21. package/src/frame/window.js +8 -7
  22. package/src/index.d.ts +10 -3
  23. package/src/index.js +8 -3
  24. package/src/panel/show-on-page.d.ts +82 -0
  25. package/src/panel/show-on-page.js +83 -0
  26. package/src/scripting/call.d.ts +29 -9
  27. package/src/scripting/call.js +35 -11
  28. package/src/scripting/context-menus.d.ts +45 -0
  29. package/src/scripting/context-menus.js +53 -0
  30. package/src/scripting/registration.d.ts +2 -1
  31. package/src/scripting/registration.js +2 -1
  32. package/src/scripting/tab.d.ts +17 -5
  33. package/src/scripting/tab.js +15 -7
  34. package/src/settings/saver.js +16 -6
  35. package/src/settings/storage.d.ts +33 -2
  36. package/src/settings/storage.js +73 -3
  37. package/src/settings/store.d.ts +17 -0
  38. package/src/settings/store.js +87 -20
  39. package/src/sites/sites.d.ts +6 -1
  40. package/src/sites/sites.js +20 -3
  41. package/src/styles/chrome-ui.css +56 -30
  42. package/src/testing/e2e.d.ts +35 -4
  43. package/src/testing/e2e.js +67 -5
  44. package/src/testing/fake-model.d.ts +9 -1
  45. package/src/testing/fake-model.js +44 -23
  46. package/src/testing/fake-storage.d.ts +41 -13
  47. package/src/testing/fake-storage.js +163 -51
  48. package/src/testing/index.d.ts +3 -3
  49. package/src/testing/index.js +5 -3
  50. package/src/testing/live-run.d.ts +23 -2
  51. package/src/testing/live-run.js +64 -18
  52. package/src/testing/messages.d.ts +7 -3
  53. package/src/testing/messages.js +50 -9
  54. package/src/testing/package.d.ts +56 -7
  55. package/src/testing/package.js +57 -18
  56. package/src/testing/prompt.d.ts +4 -2
  57. package/src/testing/prompt.js +7 -3
  58. package/src/time/pool.d.ts +2 -1
  59. package/src/time/pool.js +4 -2
  60. package/src/toolbar/theme.d.ts +10 -2
  61. package/src/toolbar/theme.js +12 -4
  62. package/src/ui/menu.d.ts +13 -3
  63. package/src/ui/menu.js +31 -11
  64. package/src/ui/model-row.js +5 -1
  65. package/src/ui/site-list.js +12 -3
  66. package/src/ui/text-dialog.d.ts +3 -2
  67. package/src/ui/text-dialog.js +10 -3
  68. package/src/ui/toast.js +2 -0
@@ -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';
@@ -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 { SYNC_QUOTA_BYTES, SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
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 (`'timed out'`).
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
@@ -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 (`'timed out'`).
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
- const dir = options.out ? path.resolve(options.out) : path.join(root, 'test', 'live', 'report');
113
- await rm(dir, { recursive: true, force: true });
114
- await mkdir(dir, { recursive: true });
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
- browsers[slot] ??= await launchExtension(chromium, build, { prefix: `${prefix}${slot}-`, ...launchOptions });
137
- const launched = /** @type {LaunchedExtension} */ (browsers[slot]);
138
- await within(launched.context.clearCookies(), 10_000, undefined);
139
- const page = await launched.context.newPage();
140
- const result = await within(visit(launched, page, site), pageMs, undefined);
141
- const timedOut = result === undefined;
142
- await within(page.close(), 15_000, undefined);
143
- if (timedOut) {
144
- // A page that ran out of time may have left its browser stuck: the slot gets a new one.
145
- await within(launched.close(), 15_000, undefined);
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: timedOut ? failed(site, 'timed out') : /** @type {R} */ (result) };
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 ('' for a missing key, as Chrome gives).
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;
@@ -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. Node only. From the copies in Better
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
- * `chrome.i18n.getMessage` over a locale's real messages ('' for a missing key, as Chrome gives).
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 messages = { ...readMessages(extensionDir, 'en'), ...(locale === 'en' ? {} : readMessages(extensionDir, locale)) };
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 entry = messages[key];
46
- if (!entry) return '';
47
- const subs = [substitutions].flat();
48
- return entry.message.replace(/\$([A-Za-z_]+)\$/g, (_, name) => (entry.placeholders?.[name.toLowerCase()]?.content ?? '')
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
  }
@@ -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 inside it (a broken import, a stylesheet link, a manifest entry).
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 and web-accessible resources (less patterns with `*`).
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 inside it: relative
88
- * imports in its scripts, `chrome.runtime.getURL('…')` paths, `src`/`href`
89
- * in its pages, the manifest's entries, and any `extra` paths. A `#fragment`
90
- * or `?query` is ignored: `getURL('options.html#workspaces')` names
91
- * `options.html`.
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).
@@ -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 inside it (a broken import, a stylesheet link, a manifest entry).
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 and web-accessible resources (less patterns with `*`).
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 inside it: relative
93
- * imports in its scripts, `chrome.runtime.getURL('…')` paths, `src`/`href`
94
- * in its pages, the manifest's entries, and any `extra` paths. A `#fragment`
95
- * or `?query` is ignored: `getURL('options.html#workspaces')` names
96
- * `options.html`.
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
- if (!file.startsWith(root) || !existsSync(file)) missing.push(`${from || 'root'} → ${target}`);
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
- if (file.endsWith('.js')) {
116
- const source = await readFile(path.join(root, file), 'utf8');
117
- for (const [, spec] of source.matchAll(/(?:from|import\()\s*'(\.{1,2}\/[^']+)'/g)) check(from, spec);
118
- for (const [, ref] of source.matchAll(/getURL\('([^']+)'\)/g)) check('', ref);
119
- } else if (file.endsWith('.html')) {
120
- const source = await readFile(path.join(root, file), 'utf8');
121
- for (const [, ref] of source.matchAll(/(?:src|href)="([^"]+)"/g)) check(from, ref);
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'));
@@ -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. Runs in the page: give it to
12
- * `page.evaluate` or `page.addInitScript`, so it takes no closures.
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
@@ -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. Runs in the page: give it to
13
- * `page.evaluate` or `page.addInitScript`, so it takes no closures.
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
- asked.push({ permissions: permissions.permissions, origins: permissions.origins, gesture: navigator.userActivation.isActive });
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
  }
@@ -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
- await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async (_, slot) => {
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;