@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.
Files changed (88) hide show
  1. package/README.md +145 -18
  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/index.d.ts +2 -1
  9. package/src/build/index.js +2 -1
  10. package/src/build/store-shots.d.ts +177 -0
  11. package/src/build/store-shots.js +173 -0
  12. package/src/build/test-build.d.ts +13 -1
  13. package/src/build/test-build.js +41 -2
  14. package/src/build/vendor.d.ts +29 -8
  15. package/src/build/vendor.js +136 -40
  16. package/src/content/elements.d.ts +50 -0
  17. package/src/content/elements.js +88 -0
  18. package/src/content/highlight.d.ts +63 -0
  19. package/src/content/highlight.js +117 -0
  20. package/src/content/overlay.d.ts +31 -6
  21. package/src/content/overlay.js +56 -8
  22. package/src/content/page-toast.d.ts +26 -0
  23. package/src/content/page-toast.js +72 -0
  24. package/src/content/slices.d.ts +3 -1
  25. package/src/content/slices.js +29 -14
  26. package/src/content/watch.d.ts +4 -2
  27. package/src/content/watch.js +17 -3
  28. package/src/dom/copy.d.ts +36 -0
  29. package/src/dom/copy.js +84 -0
  30. package/src/dom/focus.d.ts +11 -0
  31. package/src/dom/focus.js +15 -0
  32. package/src/dom/i18n.d.ts +17 -0
  33. package/src/dom/i18n.js +15 -0
  34. package/src/dom/make.d.ts +53 -0
  35. package/src/dom/make.js +71 -0
  36. package/src/frame/window.d.ts +8 -1
  37. package/src/frame/window.js +11 -8
  38. package/src/index.d.ts +31 -6
  39. package/src/index.js +24 -6
  40. package/src/panel/show-on-page.d.ts +82 -0
  41. package/src/panel/show-on-page.js +83 -0
  42. package/src/scripting/call.d.ts +29 -9
  43. package/src/scripting/call.js +35 -11
  44. package/src/scripting/context-menus.d.ts +45 -0
  45. package/src/scripting/context-menus.js +53 -0
  46. package/src/scripting/registration.d.ts +2 -1
  47. package/src/scripting/registration.js +2 -1
  48. package/src/scripting/tab.d.ts +80 -0
  49. package/src/scripting/tab.js +89 -0
  50. package/src/settings/saver.js +16 -6
  51. package/src/settings/storage.d.ts +33 -2
  52. package/src/settings/storage.js +73 -3
  53. package/src/settings/store.d.ts +17 -0
  54. package/src/settings/store.js +87 -20
  55. package/src/sites/sites.d.ts +6 -1
  56. package/src/sites/sites.js +20 -3
  57. package/src/styles/chrome-ui.css +63 -30
  58. package/src/testing/e2e.d.ts +35 -4
  59. package/src/testing/e2e.js +67 -5
  60. package/src/testing/fake-model.d.ts +9 -1
  61. package/src/testing/fake-model.js +44 -23
  62. package/src/testing/fake-storage.d.ts +41 -13
  63. package/src/testing/fake-storage.js +163 -51
  64. package/src/testing/index.d.ts +8 -2
  65. package/src/testing/index.js +8 -2
  66. package/src/testing/live-run.d.ts +151 -0
  67. package/src/testing/live-run.js +206 -0
  68. package/src/testing/messages.d.ts +7 -3
  69. package/src/testing/messages.js +50 -9
  70. package/src/testing/package.d.ts +56 -7
  71. package/src/testing/package.js +57 -18
  72. package/src/testing/pages.d.ts +114 -0
  73. package/src/testing/pages.js +109 -0
  74. package/src/testing/prompt.d.ts +4 -2
  75. package/src/testing/prompt.js +7 -3
  76. package/src/time/pool.d.ts +2 -1
  77. package/src/time/pool.js +4 -2
  78. package/src/toolbar/theme.d.ts +10 -2
  79. package/src/toolbar/theme.js +12 -4
  80. package/src/ui/menu.d.ts +13 -3
  81. package/src/ui/menu.js +31 -11
  82. package/src/ui/model-row.js +5 -1
  83. package/src/ui/notice.d.ts +58 -0
  84. package/src/ui/notice.js +43 -0
  85. package/src/ui/site-list.js +12 -3
  86. package/src/ui/text-dialog.d.ts +3 -2
  87. package/src/ui/text-dialog.js +10 -3
  88. package/src/ui/toast.js +2 -0
@@ -8,16 +8,18 @@
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 { REPORT_MARKER, liveUsage, reportFolder, runLiveCheck } from './live-run.js';
13
14
  export { eachLimited } from '../time/pool.js';
14
15
  export { filesIn, launchDownloading } from './downloads.js';
15
16
  export { fakeLanguageModel } from './fake-model.js';
16
17
  export { messageLookup, readMessages } from './messages.js';
17
18
  export { heartbeat } from './heartbeat.js';
18
19
  export { playPermissionPrompt } from './prompt.js';
20
+ export { callTabModule, nodeAttribute, nodeText, pierceNodes, tabIdOf } from './pages.js';
19
21
  export { serve, testCertificate } from './server.js';
20
- 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';
21
23
  export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
22
24
  export { savedTabGroups } from './saved-groups.js';
23
25
  export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
@@ -28,8 +30,10 @@ export type ExtensionProfile = import('./e2e.js').ExtensionProfile;
28
30
  export type LaunchedExtension = import('./e2e.js').LaunchedExtension;
29
31
  export type LaunchOptions = import('./e2e.js').LaunchOptions;
30
32
  export type Messages = import('./messages.js').Messages;
33
+ export type CdpNode = import('./pages.js').CdpNode;
31
34
  export type Exit = import('./live.js').Exit;
32
35
  export type LiveOptions = import('./live.js').LiveOptions;
36
+ export type LiveSite = import('./live-run.js').LiveSite;
33
37
  export type SavedTabGroup = import('./saved-groups.js').SavedTabGroup;
34
38
  export type Manifest = import('./warnings.js').Manifest;
35
39
  export type Reply = import('./server.js').Reply;
@@ -45,8 +49,10 @@ export type TestServer = import('./server.js').TestServer;
45
49
  * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
46
50
  * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
47
51
  * @typedef {import('./messages.js').Messages} Messages
52
+ * @typedef {import('./pages.js').CdpNode} CdpNode
48
53
  * @typedef {import('./live.js').Exit} Exit
49
54
  * @typedef {import('./live.js').LiveOptions} LiveOptions
55
+ * @typedef {import('./live-run.js').LiveSite} LiveSite
50
56
  * @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
51
57
  * @typedef {import('./warnings.js').Manifest} Manifest
52
58
  * @typedef {import('./server.js').Reply} Reply
@@ -10,17 +10,21 @@
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 { REPORT_MARKER, liveUsage, reportFolder, runLiveCheck } from './live-run.js';
16
17
  export { eachLimited } from '../time/pool.js';
17
18
  export { filesIn, launchDownloading } from './downloads.js';
18
19
  export { fakeLanguageModel } from './fake-model.js';
19
20
  export { messageLookup, readMessages } from './messages.js';
20
21
  export { heartbeat } from './heartbeat.js';
21
22
  export { playPermissionPrompt } from './prompt.js';
23
+ export { callTabModule, nodeAttribute, nodeText, pierceNodes, tabIdOf } from './pages.js';
22
24
  export { serve, testCertificate } from './server.js';
23
- 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';
24
28
  export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
25
29
  export { savedTabGroups } from './saved-groups.js';
26
30
  export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
@@ -35,8 +39,10 @@ export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings
35
39
  * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
36
40
  * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
37
41
  * @typedef {import('./messages.js').Messages} Messages
42
+ * @typedef {import('./pages.js').CdpNode} CdpNode
38
43
  * @typedef {import('./live.js').Exit} Exit
39
44
  * @typedef {import('./live.js').LiveOptions} LiveOptions
45
+ * @typedef {import('./live-run.js').LiveSite} LiveSite
40
46
  * @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
41
47
  * @typedef {import('./warnings.js').Manifest} Manifest
42
48
  * @typedef {import('./server.js').Reply} Reply
@@ -0,0 +1,151 @@
1
+ /**
2
+ * A live checker's run: the command line read (`parseLiveOptions`), the
3
+ * sites chosen, where the traffic leaves for the internet printed, then each
4
+ * site visited in a browser with the extension loaded, a few at once (one
5
+ * browser a slot, launched on first use, passing for a person's), each within
6
+ * a time limit (a browser whose page ran out of time is replaced), Ctrl-C
7
+ * closing every browser. What a visit checks, and the report, are the
8
+ * checker's own.
9
+ *
10
+ * @module testing/live-run
11
+ */
12
+ import { exitPoint } from './live.js';
13
+ import type { BrowserType, Page } from 'playwright';
14
+ import type { LaunchedExtension } from './e2e.js';
15
+ import type { Exit } from './live.js';
16
+ export type LiveSite = {
17
+ name: string;
18
+ url: string;
19
+ category: string;
20
+ };
21
+ export type LiveRun<S extends LiveSite, R> = {
22
+ /**
23
+ * The report's folder (made empty).
24
+ */
25
+ dir: string;
26
+ /**
27
+ * When it started (ISO).
28
+ */
29
+ started: string;
30
+ /**
31
+ * When it finished (ISO).
32
+ */
33
+ finished: string;
34
+ /**
35
+ * Where the traffic left.
36
+ */
37
+ exit: Exit | null;
38
+ /**
39
+ * How the browsers looked.
40
+ */
41
+ browser: {
42
+ locale: string;
43
+ timezone: string;
44
+ proxy: string;
45
+ };
46
+ /**
47
+ * Each site's visit, in the sites' order.
48
+ */
49
+ results: {
50
+ site: S;
51
+ visit: R;
52
+ }[];
53
+ };
54
+ /** @import { BrowserType, Page } from 'playwright' */
55
+ /** @import { LaunchedExtension } from './e2e.js' */
56
+ /** @import { Exit } from './live.js' */
57
+ /**
58
+ * A site a checker visits.
59
+ *
60
+ * @typedef {{name: string, url: string, category: string}} LiveSite
61
+ */
62
+ /**
63
+ * A finished run, for the checker's report.
64
+ *
65
+ * @template {LiveSite} S
66
+ * @template R
67
+ * @typedef {object} LiveRun
68
+ * @property {string} dir The report's folder (made empty).
69
+ * @property {string} started When it started (ISO).
70
+ * @property {string} finished When it finished (ISO).
71
+ * @property {Exit | null} exit Where the traffic left.
72
+ * @property {{locale: string, timezone: string, proxy: string}} browser How the browsers looked.
73
+ * @property {{site: S, visit: R}[]} results Each site's visit, in the sites' order.
74
+ */
75
+ /**
76
+ * A live checker's usage text.
77
+ *
78
+ * @param {string[]} categories - The categories' ids.
79
+ * @param {number} concurrency - The default and most sites at once.
80
+ * @returns {string} The text.
81
+ */
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>;
102
+ /**
103
+ * Run a live check: read the command line, visit the chosen sites, give back every visit.
104
+ * Prints the usage and exits for `--help` (or an option it doesn't know), and exits when no
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).
108
+ *
109
+ * @template {LiveSite} S
110
+ * @template R
111
+ * @param {object} run
112
+ * @param {BrowserType} run.chromium - Playwright's chromium.
113
+ * @param {string} run.build - The extension's live test build (every site granted).
114
+ * @param {string} run.prefix - The browsers' profile prefix.
115
+ * @param {string} run.root - The extension's repository (the report goes in `test/live/report`).
116
+ * @param {S[]} run.sites - Every site.
117
+ * @param {string[]} run.categories - The categories' ids, for the usage.
118
+ * @param {number} run.pageMs - The most a visit may take.
119
+ * @param {(browser: LaunchedExtension, page: Page, site: S) => Promise<R>} run.visit - Check one site, on a fresh page.
120
+ * @param {(site: S, error: string) => R} run.failed - A visit that couldn't finish: `'timed out'`, or the error's message.
121
+ * @param {(result: {site: S, visit: R}) => void} [run.onResult] - Each visit as it finishes (a line of output).
122
+ * @param {{argv?: string[], env?: Record<string, string | undefined>, concurrency?: number, findExit?: typeof exitPoint}} [run.command] - The
123
+ * arguments and environment (default the process's), the most sites at once (default 4), and how the exit point
124
+ * is found (default `exitPoint`, which asks a lookup service).
125
+ * @returns {Promise<LiveRun<S, R>>} The run.
126
+ * @example
127
+ * const run = await runLiveCheck({ chromium, build: BUILDS.live, prefix: 'better-colors-live-', root, sites: allSites(),
128
+ * categories: CATEGORIES.map((c) => c.id), pageMs: 120_000, visit: (browser, page, site) => visit(browser, page, site.url),
129
+ * failed: (site, error) => ({ url: site.url, error }), onResult: (result) => console.log(pageLine(result)) });
130
+ */
131
+ export declare function runLiveCheck<S extends LiveSite, R>({ chromium, build, prefix, root, sites, categories, pageMs, visit, failed, onResult, command }: {
132
+ chromium: BrowserType;
133
+ build: string;
134
+ prefix: string;
135
+ root: string;
136
+ sites: S[];
137
+ categories: string[];
138
+ pageMs: number;
139
+ visit: (browser: LaunchedExtension, page: Page, site: S) => Promise<R>;
140
+ failed: (site: S, error: string) => R;
141
+ onResult?: (result: {
142
+ site: S;
143
+ visit: R;
144
+ }) => void;
145
+ command?: {
146
+ argv?: string[];
147
+ env?: Record<string, string | undefined>;
148
+ concurrency?: number;
149
+ findExit?: typeof exitPoint;
150
+ };
151
+ }): Promise<LiveRun<S, R>>;
@@ -0,0 +1,206 @@
1
+ /**
2
+ * A live checker's run: the command line read (`parseLiveOptions`), the
3
+ * sites chosen, where the traffic leaves for the internet printed, then each
4
+ * site visited in a browser with the extension loaded, a few at once (one
5
+ * browser a slot, launched on first use, passing for a person's), each within
6
+ * a time limit (a browser whose page ran out of time is replaced), Ctrl-C
7
+ * closing every browser. What a visit checks, and the report, are the
8
+ * checker's own.
9
+ *
10
+ * @module testing/live-run
11
+ */
12
+
13
+ import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { deadline, within } from '../time/time.js';
16
+ import { eachLimited } from '../time/pool.js';
17
+ import { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
18
+ import { launchExtension } from './e2e.js';
19
+ import { exitPoint, parseLiveOptions } from './live.js';
20
+
21
+ /** @import { BrowserType, Page } from 'playwright' */
22
+ /** @import { LaunchedExtension } from './e2e.js' */
23
+ /** @import { Exit } from './live.js' */
24
+
25
+ /**
26
+ * A site a checker visits.
27
+ *
28
+ * @typedef {{name: string, url: string, category: string}} LiveSite
29
+ */
30
+
31
+ /**
32
+ * A finished run, for the checker's report.
33
+ *
34
+ * @template {LiveSite} S
35
+ * @template R
36
+ * @typedef {object} LiveRun
37
+ * @property {string} dir The report's folder (made empty).
38
+ * @property {string} started When it started (ISO).
39
+ * @property {string} finished When it finished (ISO).
40
+ * @property {Exit | null} exit Where the traffic left.
41
+ * @property {{locale: string, timezone: string, proxy: string}} browser How the browsers looked.
42
+ * @property {{site: S, visit: R}[]} results Each site's visit, in the sites' order.
43
+ */
44
+
45
+ /**
46
+ * A live checker's usage text.
47
+ *
48
+ * @param {string[]} categories - The categories' ids.
49
+ * @param {number} concurrency - The default and most sites at once.
50
+ * @returns {string} The text.
51
+ */
52
+ export const liveUsage = (categories, concurrency) => `Usage: npm run live -- [options] [word …]
53
+
54
+ --only <word> only pages whose address or name contains it (repeatable; bare words too)
55
+ --category <id> only this category (repeatable): ${categories.join(', ')}
56
+ --concurrency <n> pages checked at once (default ${concurrency}, at most ${concurrency})
57
+ --proxy <url> send everything through this proxy (default: HTTPS_PROXY)
58
+ --locale <tag> browser language (default en-GB)
59
+ --timezone <zone> browser time zone (default Europe/London)
60
+ --out <dir> write the report there (default test/live/report)
61
+ --help`;
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
+
94
+ /**
95
+ * Run a live check: read the command line, visit the chosen sites, give back every visit.
96
+ * Prints the usage and exits for `--help` (or an option it doesn't know), and exits when no
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).
100
+ *
101
+ * @template {LiveSite} S
102
+ * @template R
103
+ * @param {object} run
104
+ * @param {BrowserType} run.chromium - Playwright's chromium.
105
+ * @param {string} run.build - The extension's live test build (every site granted).
106
+ * @param {string} run.prefix - The browsers' profile prefix.
107
+ * @param {string} run.root - The extension's repository (the report goes in `test/live/report`).
108
+ * @param {S[]} run.sites - Every site.
109
+ * @param {string[]} run.categories - The categories' ids, for the usage.
110
+ * @param {number} run.pageMs - The most a visit may take.
111
+ * @param {(browser: LaunchedExtension, page: Page, site: S) => Promise<R>} run.visit - Check one site, on a fresh page.
112
+ * @param {(site: S, error: string) => R} run.failed - A visit that couldn't finish: `'timed out'`, or the error's message.
113
+ * @param {(result: {site: S, visit: R}) => void} [run.onResult] - Each visit as it finishes (a line of output).
114
+ * @param {{argv?: string[], env?: Record<string, string | undefined>, concurrency?: number, findExit?: typeof exitPoint}} [run.command] - The
115
+ * arguments and environment (default the process's), the most sites at once (default 4), and how the exit point
116
+ * is found (default `exitPoint`, which asks a lookup service).
117
+ * @returns {Promise<LiveRun<S, R>>} The run.
118
+ * @example
119
+ * const run = await runLiveCheck({ chromium, build: BUILDS.live, prefix: 'better-colors-live-', root, sites: allSites(),
120
+ * categories: CATEGORIES.map((c) => c.id), pageMs: 120_000, visit: (browser, page, site) => visit(browser, page, site.url),
121
+ * failed: (site, error) => ({ url: site.url, error }), onResult: (result) => console.log(pageLine(result)) });
122
+ */
123
+ export async function runLiveCheck({ chromium, build, prefix, root, sites, categories, pageMs, visit, failed, onResult = () => {}, command = {} }) {
124
+ const { argv = process.argv.slice(2), env = process.env, concurrency = 4, findExit = exitPoint } = command;
125
+ const usage = liveUsage(categories, concurrency);
126
+ /** @type {import('./live.js').LiveOptions} */
127
+ let options;
128
+ try {
129
+ options = parseLiveOptions(argv, env, { usage, concurrency, maxConcurrency: concurrency });
130
+ } catch (error) {
131
+ console.error(error instanceof Error ? error.message : error);
132
+ process.exit(2);
133
+ }
134
+ if (options.help) {
135
+ console.log(usage);
136
+ process.exit(0);
137
+ }
138
+ const words = options.only.map((word) => word.toLowerCase());
139
+ const chosen = sites.filter((site) => (!options.categories.length || options.categories.includes(site.category))
140
+ && (!words.length || words.some((word) => site.url.toLowerCase().includes(word) || site.name.toLowerCase().includes(word))));
141
+ if (!chosen.length) {
142
+ console.error('No pages match.');
143
+ process.exit(2);
144
+ }
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
+ }
153
+
154
+ const browserOptions = realisticBrowser(options.proxy ? { proxy: options.proxy } : {});
155
+ const plain = await chromium.launch({ channel: 'chromium', ...browserOptions });
156
+ /** @type {(LaunchedExtension | null)[]} */
157
+ const browsers = [];
158
+ const closeAll = async () => {
159
+ await Promise.allSettled([plain.close(), ...browsers.map((launched) => launched?.close())]);
160
+ };
161
+ const forget = closeOnSignal(closeAll);
162
+ try {
163
+ const userAgent = await chromeUserAgent(plain);
164
+ const probe = await plain.newContext();
165
+ const exit = await findExit(probe.request);
166
+ await probe.close();
167
+ console.log(exit ? `Exit: ${exit.country} (${exit.ip}${exit.where ? `, ${exit.where}` : ''})${options.proxyLabel ? ` through ${options.proxyLabel}` : ''}` : 'Exit: unknown');
168
+ console.log(`Pages: ${chosen.length}, ${options.concurrency} at a time.\n`);
169
+ const launchOptions = { ...browserOptions, userAgent, locale: options.locale, timezoneId: options.timezone, viewport: { width: 1280, height: 900 } };
170
+ const started = new Date().toISOString();
171
+ /** @type {{site: S, visit: R}[]} */
172
+ const results = [];
173
+ await eachLimited(chosen, options.concurrency, async (site, slot, index) => {
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);
191
+ browsers[slot] = null;
192
+ outcome = failed(site, error instanceof Error ? error.message : String(error));
193
+ }
194
+ results[index] = { site, visit: outcome };
195
+ onResult(results[index]);
196
+ });
197
+ return {
198
+ dir, started, finished: new Date().toISOString(), exit,
199
+ browser: { locale: options.locale, timezone: options.timezone, proxy: options.proxyLabel },
200
+ results: results.filter(Boolean),
201
+ };
202
+ } finally {
203
+ forget();
204
+ await closeAll();
205
+ }
206
+ }
@@ -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).