@arraypress/extension-kit 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +145 -18
- package/bin/extension-kit.js +2 -1
- package/package.json +1 -1
- package/src/ai/host.d.ts +183 -0
- package/src/ai/host.js +225 -0
- package/src/ai/model.d.ts +2 -1
- package/src/ai/model.js +10 -2
- package/src/build/index.d.ts +2 -1
- package/src/build/index.js +2 -1
- package/src/build/store-shots.d.ts +177 -0
- package/src/build/store-shots.js +173 -0
- package/src/build/test-build.d.ts +13 -1
- package/src/build/test-build.js +41 -2
- package/src/build/vendor.d.ts +29 -8
- package/src/build/vendor.js +136 -40
- package/src/content/elements.d.ts +50 -0
- package/src/content/elements.js +88 -0
- package/src/content/highlight.d.ts +63 -0
- package/src/content/highlight.js +117 -0
- package/src/content/overlay.d.ts +31 -6
- package/src/content/overlay.js +56 -8
- package/src/content/page-toast.d.ts +26 -0
- package/src/content/page-toast.js +72 -0
- package/src/content/slices.d.ts +3 -1
- package/src/content/slices.js +29 -14
- package/src/content/watch.d.ts +4 -2
- package/src/content/watch.js +17 -3
- package/src/dom/copy.d.ts +36 -0
- package/src/dom/copy.js +84 -0
- package/src/dom/focus.d.ts +11 -0
- package/src/dom/focus.js +15 -0
- package/src/dom/i18n.d.ts +17 -0
- package/src/dom/i18n.js +15 -0
- package/src/dom/make.d.ts +53 -0
- package/src/dom/make.js +71 -0
- package/src/frame/window.d.ts +8 -1
- package/src/frame/window.js +11 -8
- package/src/index.d.ts +31 -6
- package/src/index.js +24 -6
- package/src/panel/show-on-page.d.ts +82 -0
- package/src/panel/show-on-page.js +83 -0
- package/src/scripting/call.d.ts +29 -9
- package/src/scripting/call.js +35 -11
- package/src/scripting/context-menus.d.ts +45 -0
- package/src/scripting/context-menus.js +53 -0
- package/src/scripting/registration.d.ts +2 -1
- package/src/scripting/registration.js +2 -1
- package/src/scripting/tab.d.ts +80 -0
- package/src/scripting/tab.js +89 -0
- package/src/settings/saver.js +16 -6
- package/src/settings/storage.d.ts +33 -2
- package/src/settings/storage.js +73 -3
- package/src/settings/store.d.ts +17 -0
- package/src/settings/store.js +87 -20
- package/src/sites/sites.d.ts +6 -1
- package/src/sites/sites.js +20 -3
- package/src/styles/chrome-ui.css +63 -30
- package/src/testing/e2e.d.ts +35 -4
- package/src/testing/e2e.js +67 -5
- package/src/testing/fake-model.d.ts +9 -1
- package/src/testing/fake-model.js +44 -23
- package/src/testing/fake-storage.d.ts +41 -13
- package/src/testing/fake-storage.js +163 -51
- package/src/testing/index.d.ts +8 -2
- package/src/testing/index.js +8 -2
- package/src/testing/live-run.d.ts +151 -0
- package/src/testing/live-run.js +206 -0
- package/src/testing/messages.d.ts +7 -3
- package/src/testing/messages.js +50 -9
- package/src/testing/package.d.ts +56 -7
- package/src/testing/package.js +57 -18
- package/src/testing/pages.d.ts +114 -0
- package/src/testing/pages.js +109 -0
- package/src/testing/prompt.d.ts +4 -2
- package/src/testing/prompt.js +7 -3
- package/src/time/pool.d.ts +2 -1
- package/src/time/pool.js +4 -2
- package/src/toolbar/theme.d.ts +10 -2
- package/src/toolbar/theme.js +12 -4
- package/src/ui/menu.d.ts +13 -3
- package/src/ui/menu.js +31 -11
- package/src/ui/model-row.js +5 -1
- package/src/ui/notice.d.ts +58 -0
- package/src/ui/notice.js +43 -0
- package/src/ui/site-list.js +12 -3
- package/src/ui/text-dialog.d.ts +3 -2
- package/src/ui/text-dialog.js +10 -3
- package/src/ui/toast.js +2 -0
package/src/testing/index.d.ts
CHANGED
|
@@ -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
|
package/src/testing/index.js
CHANGED
|
@@ -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 {
|
|
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
|
|
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).
|