@arraypress/extension-kit 1.5.0 → 1.6.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 CHANGED
@@ -193,6 +193,24 @@ withSite(['a.com', 'b.com'], 'a.com'); // ['b.com', 'a.com'], what a list
193
193
  A site is http or https with a dot in its host (or `localhost`); text without a scheme is https;
194
194
  `localhost:8080` is a host and port. Pure, so a content script can load `sites/sites.js` alone.
195
195
 
196
+ ## A content script on every site
197
+
198
+ ```js
199
+ // The content script runs everywhere only while the optional all-sites grant is held, less the
200
+ // "Never on" sites; one call brings the registration in line, whatever changed.
201
+ const sync = contentScriptSync(chrome.scripting, 'prices', async () => {
202
+ const settings = await store.load(chrome.storage);
203
+ if (!settings.everySite || !(await chrome.permissions.contains({ origins: ALL_SITES }))) return null;
204
+ return { js: ['content/loader.js'], matches: ALL_SITES, excludeMatches: settings.neverSites.flatMap(hostPatterns), runAt: 'document_idle' };
205
+ });
206
+ chrome.permissions.onAdded.addListener(() => sync());
207
+ chrome.permissions.onRemoved.addListener(() => sync()); // site access taken back on chrome://extensions
208
+ ```
209
+
210
+ Registers, updates or unregisters the script `id` to match what `want()` gives at the time (null:
211
+ none). Calls run one after another, so a quick run of changes can't register twice or end out of
212
+ order; a refused change rejects that call only.
213
+
196
214
  ## Time limits
197
215
 
198
216
  ```js
@@ -203,6 +221,13 @@ const groups = await within(chrome.tabGroups.query({}), 2000, []); // a Chrome c
203
221
  rejects; its timer cleared once the work settles (the work itself isn't stopped). In the runtime
204
222
  half, so a service worker or page can use it; the test helpers use it too.
205
223
 
224
+ ```js
225
+ await eachLimited(files, 4, async (file) => save(file)); // at most four at once
226
+ ```
227
+
228
+ `eachLimited(items, limit, work)`: `work(item, slot, index)` on every item, at most `limit` at once;
229
+ in the runtime half too (`/testing` exports it as well).
230
+
206
231
  ## Page components
207
232
 
208
233
  Chrome's own components, made in the page by script (no markup to copy), styled by `chrome-ui.css`.
@@ -366,19 +391,22 @@ except those listed, each with a reason:
366
391
  |---|---|
367
392
  | `fakeChromeStorage({local, sync, session, now})` | In-memory `chrome.storage`: JSON copies, `onChanged` for real changes only, sync's 8,192-byte item quota and 120 writes a minute. `settled()` waits for listeners. |
368
393
  | `launchExtension(chromium, dir, options)` | Load an unpacked extension into a fresh profile; `{context, worker, extensionId, url(page), profile, close()}`. |
394
+ | `launchDownloading(chromium, dir, options)`, `filesIn(dir)` | An extension in a browser that downloads for real: `{…launchExtension's, downloads}`, the profile's own Downloads folder (Playwright otherwise saves downloads under made-up names no extension sees); the files under a folder, downloads still running left out. |
369
395
  | `extensionProfile(chromium, dir, options)` | A profile to start the browser on more than once (a restart, a first start with a changed manifest): `{dir, launch(), remove()}`. |
370
396
  | `checkRunner({timeout})` | Named checks that report and go on: `check(name, fn, {timeout})`, `fail(name)` for an error found outside a check, `finish()`. A check that runs past its limit (`CHECK_MS`, 60 s) fails, and the run goes on. |
371
- | `eachLimited(items, limit, work)` | Work through a list at most `limit` at once, `work(item, slot, index)`. |
397
+ | `eachLimited(items, limit, work)` | Work through a list at most `limit` at once, `work(item, slot, index)` (also in the main entry). |
372
398
  | `realisticBrowser(options)`, `chromeUserAgent(browser)` | Launch options and a user agent for a browser that doesn't say it's automated, for live checks. |
373
399
  | `closeOnSignal(close)` | On Ctrl-C or a kill, close the run's browsers, then exit. |
374
400
  | `testBuildDir(root, name)` | As in `/build`, for the tests that load the build. |
375
- | `serve(routes, {port})` | Serve test pages on 127.0.0.1 from a table, a `Map` or a function of the path; `{origin, port, server, close()}`. |
401
+ | `serve(routes, {port, tls})` | Serve test pages on 127.0.0.1 from a table, a `Map` or a function of the path; `{origin, port, server, close()}`. With `tls` (`testCertificate()`, a throwaway one from `openssl`), over HTTPS: for a site Chrome reaches only over HTTPS, mapped with `--host-resolver-rules`, Chromium started with `--ignore-certificate-errors`. |
376
402
  | `until(fn, what, timeout?)` | Poll until truthy. |
377
403
  | `offscreenEmulator(cdp)` | Emulate light/dark inside the offscreen document. |
378
404
  | `recordIconCalls(worker)` | Record `chrome.action.setIcon` calls Chrome accepted. |
379
405
  | `installWarnings(worker, manifest)` | Chrome's own install warnings for a manifest (`chrome.management.getPermissionWarningsByManifest`, no permission needed), from a loaded extension's worker. |
380
406
  | `warningsAddedBy(worker, manifest, names)` | `{warnings, without, added}`: the warnings with and without some permissions, and those only they bring (`withoutPermissions` is the pure part). |
381
407
  | `savedTabGroups(profile)` | Chrome's saved tab groups (`{title, pinned, urls}`) read from a closed profile's `Sync Data/LevelDB`: no extension API lists them. |
408
+ | `parseLiveOptions(argv, env, {usage})`, `parseProxy(address)` | A live checker's command line (`--only`, `--category`, `--concurrency`, `--proxy` or `HTTPS_PROXY`, `--locale`, `--timezone`, `--out`, `--help`), and a proxy address for Playwright with its password hidden for the output. |
409
+ | `exitPoint(request)` | Where the run's traffic leaves for the internet (`{ip, country, where, source}`), from a keyless lookup service through the context's proxy: sites decide what to show by it. |
382
410
  | `filesUnder`, `zipEntries`, `missingReferences`, `manifestReferences` | For a packaging test: the zip is the folder, and every path the extension names resolves (a `#fragment` or `?query` ignored: `referencedPath`). |
383
411
 
384
412
  ## Stylesheet classes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@arraypress/extension-kit",
3
- "version": "1.5.0",
3
+ "version": "1.6.0",
4
4
  "description": "Shared pieces for Chrome extensions: Chrome's own page styles, a Chrome Sync settings store, the light/dark toolbar glyph, About links, and build and test helpers.",
5
5
  "type": "module",
6
6
  "main": "src/index.js",
@@ -28,7 +28,7 @@ const SOURCE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
28
28
  const KIT = '@arraypress/extension-kit';
29
29
 
30
30
  /** What a vendored copy of the kit holds, relative to `src/`: everything an extension page or worker may load. */
31
- export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'commands', 'dom', 'settings', 'sites', 'styles', 'time', 'toolbar', 'ui']);
31
+ export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'commands', 'dom', 'scripting', 'settings', 'sites', 'styles', 'time', 'toolbar', 'ui']);
32
32
 
33
33
  /** The kit's stylesheet, an absolute path in the installed package. */
34
34
  export const STYLESHEET = path.join(SOURCE, 'styles', 'chrome-ui.css');
package/src/index.d.ts CHANGED
@@ -17,10 +17,12 @@ export { focusedElement } from './dom/focus.js';
17
17
  export { localize, translator } from './dom/i18n.js';
18
18
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
19
19
  export { setUpPage } from './dom/setup.js';
20
+ export { contentScriptSync } from './scripting/registration.js';
20
21
  export { createSettingsSaver } from './settings/saver.js';
21
22
  export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
22
23
  export { createSettingsStore } from './settings/store.js';
23
24
  export { MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite, } from './sites/sites.js';
25
+ export { eachLimited } from './time/pool.js';
24
26
  export { within } from './time/time.js';
25
27
  export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
26
28
  export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
@@ -29,6 +31,7 @@ export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from
29
31
  export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
30
32
  export { askFields, askText, checkText, fieldIds } from './ui/text-dialog.js';
31
33
  export { TOAST_MS, createToast } from './ui/toast.js';
34
+ export type ContentScript = import('./scripting/registration.js').ContentScript;
32
35
  export type ActionMenu = import('./ui/menu.js').ActionMenu;
33
36
  export type MenuEntry = import('./ui/menu.js').MenuEntry;
34
37
  export type MenuItem = import('./ui/menu.js').MenuItem;
@@ -41,6 +44,7 @@ export type Toast = import('./ui/toast.js').Toast;
41
44
  /**
42
45
  * The page helpers' types, for a page's JSDoc.
43
46
  *
47
+ * @typedef {import('./scripting/registration.js').ContentScript} ContentScript
44
48
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
45
49
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
46
50
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
package/src/index.js CHANGED
@@ -18,12 +18,14 @@ export { focusedElement } from './dom/focus.js';
18
18
  export { localize, translator } from './dom/i18n.js';
19
19
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
20
20
  export { setUpPage } from './dom/setup.js';
21
+ export { contentScriptSync } from './scripting/registration.js';
21
22
  export { createSettingsSaver } from './settings/saver.js';
22
23
  export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
23
24
  export { createSettingsStore } from './settings/store.js';
24
25
  export {
25
26
  MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite,
26
27
  } from './sites/sites.js';
28
+ export { eachLimited } from './time/pool.js';
27
29
  export { within } from './time/time.js';
28
30
  export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
29
31
  export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
@@ -38,6 +40,7 @@ export { TOAST_MS, createToast } from './ui/toast.js';
38
40
  /**
39
41
  * The page helpers' types, for a page's JSDoc.
40
42
  *
43
+ * @typedef {import('./scripting/registration.js').ContentScript} ContentScript
41
44
  * @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
42
45
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
43
46
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
@@ -0,0 +1,36 @@
1
+ /**
2
+ * One registered content script kept in step with what may run: registered,
3
+ * updated or unregistered to match, each change after the last, so a quick
4
+ * run of setting and permission changes can't register and unregister out
5
+ * of order. For an extension whose content script runs on every site only
6
+ * while an optional all-sites grant is held, less a "Never on" list.
7
+ *
8
+ * @module scripting/registration
9
+ */
10
+ export type ContentScript = Omit<chrome.scripting.RegisteredContentScript, 'id' | 'js'> & {
11
+ js: string[];
12
+ };
13
+ export type Scripting = Pick<typeof chrome.scripting, 'getRegisteredContentScripts' | 'registerContentScripts' | 'updateContentScripts' | 'unregisterContentScripts'>;
14
+ /** @typedef {Omit<chrome.scripting.RegisteredContentScript, 'id' | 'js'> & {js: string[]}} ContentScript The script, but its id: its `js`, `matches` and the rest. */
15
+ /**
16
+ * The parts of `chrome.scripting` a registration uses.
17
+ *
18
+ * @typedef {Pick<typeof chrome.scripting, 'getRegisteredContentScripts' | 'registerContentScripts' | 'updateContentScripts' | 'unregisterContentScripts'>} Scripting
19
+ */
20
+ /**
21
+ * Keep the content script `id` registered as `want()` says: the script, or
22
+ * null for none. Each call asks `want()` afresh once the change before it is
23
+ * done, so the last call always wins.
24
+ *
25
+ * @param {Scripting} scripting - `chrome.scripting` (or a fake in tests).
26
+ * @param {string} id - The registered script's id.
27
+ * @param {() => Promise<ContentScript | null>} want - What should be registered now (settings and
28
+ * grants read at the time).
29
+ * @returns {() => Promise<void>} Makes the registration match; rejects if Chrome refuses.
30
+ * @example
31
+ * const sync = contentScriptSync(chrome.scripting, 'prices', async () => (await chrome.permissions.contains({ origins: ALL_SITES })
32
+ * ? { js: ['content/loader.js'], matches: ALL_SITES, excludeMatches: neverSites.flatMap(hostPatterns), runAt: 'document_idle' }
33
+ * : null));
34
+ * chrome.permissions.onAdded.addListener(() => sync());
35
+ */
36
+ export declare function contentScriptSync(scripting: Scripting, id: string, want: () => Promise<ContentScript | null>): () => Promise<void>;
@@ -0,0 +1,54 @@
1
+ /**
2
+ * One registered content script kept in step with what may run: registered,
3
+ * updated or unregistered to match, each change after the last, so a quick
4
+ * run of setting and permission changes can't register and unregister out
5
+ * of order. For an extension whose content script runs on every site only
6
+ * while an optional all-sites grant is held, less a "Never on" list.
7
+ *
8
+ * @module scripting/registration
9
+ */
10
+
11
+ /** @typedef {Omit<chrome.scripting.RegisteredContentScript, 'id' | 'js'> & {js: string[]}} ContentScript The script, but its id: its `js`, `matches` and the rest. */
12
+
13
+ /**
14
+ * The parts of `chrome.scripting` a registration uses.
15
+ *
16
+ * @typedef {Pick<typeof chrome.scripting, 'getRegisteredContentScripts' | 'registerContentScripts' | 'updateContentScripts' | 'unregisterContentScripts'>} Scripting
17
+ */
18
+
19
+ /**
20
+ * Keep the content script `id` registered as `want()` says: the script, or
21
+ * null for none. Each call asks `want()` afresh once the change before it is
22
+ * done, so the last call always wins.
23
+ *
24
+ * @param {Scripting} scripting - `chrome.scripting` (or a fake in tests).
25
+ * @param {string} id - The registered script's id.
26
+ * @param {() => Promise<ContentScript | null>} want - What should be registered now (settings and
27
+ * grants read at the time).
28
+ * @returns {() => Promise<void>} Makes the registration match; rejects if Chrome refuses.
29
+ * @example
30
+ * const sync = contentScriptSync(chrome.scripting, 'prices', async () => (await chrome.permissions.contains({ origins: ALL_SITES })
31
+ * ? { js: ['content/loader.js'], matches: ALL_SITES, excludeMatches: neverSites.flatMap(hostPatterns), runAt: 'document_idle' }
32
+ * : null));
33
+ * chrome.permissions.onAdded.addListener(() => sync());
34
+ */
35
+ export function contentScriptSync(scripting, id, want) {
36
+ /** The last change, so the next waits for it. */
37
+ let queue = Promise.resolve();
38
+ const apply = async () => {
39
+ const script = await want();
40
+ const registered = (await scripting.getRegisteredContentScripts({ ids: [id] })).length > 0;
41
+ if (!script) {
42
+ if (registered) await scripting.unregisterContentScripts({ ids: [id] });
43
+ return;
44
+ }
45
+ const full = { ...script, id };
46
+ if (registered) await scripting.updateContentScripts([full]);
47
+ else await scripting.registerContentScripts([full]);
48
+ };
49
+ return () => {
50
+ const run = queue.then(apply, apply);
51
+ queue = run.catch(() => {});
52
+ return run;
53
+ };
54
+ }
@@ -0,0 +1,41 @@
1
+ /**
2
+ * A browser that downloads for real, for an extension that names, files or
3
+ * makes downloads. Playwright normally takes downloads over (CDP's
4
+ * `Browser.setDownloadBehavior` set to `allowAndName`), saving them under
5
+ * made-up names where no extension sees them: `downloads.onDeterminingFilename`
6
+ * never fires, and `chrome.downloads.download`'s names are lost. So the
7
+ * profile gets its own Downloads folder (Chrome's `download.default_directory`
8
+ * preference, written before the first start) and the behaviour goes back to
9
+ * Chrome's default: files land on disk as on a person's computer.
10
+ *
11
+ * @module testing/downloads
12
+ */
13
+ import type { BrowserType } from 'playwright';
14
+ import type { LaunchOptions, LaunchedExtension } from './e2e.js';
15
+ /** @import { BrowserType } from 'playwright' */
16
+ /** @import { LaunchOptions, LaunchedExtension } from './e2e.js' */
17
+ /**
18
+ * Every file under a folder, as paths from it (Chrome's `.crdownload` files
19
+ * of downloads still running left out), sorted.
20
+ *
21
+ * @param {string} dir - The folder.
22
+ * @returns {Promise<string[]>} The files.
23
+ * @example
24
+ * await filesIn(downloads); // ['Trips/Lake.jpg', 'Trips/Lake 2.jpg']
25
+ */
26
+ export declare function filesIn(dir: string): Promise<string[]>;
27
+ /**
28
+ * Load the unpacked extension at `dir` into a fresh profile whose downloads
29
+ * go to a folder of their own, really.
30
+ *
31
+ * @param {BrowserType} chromium - Playwright's `chromium`.
32
+ * @param {string} dir - The unpacked extension.
33
+ * @param {LaunchOptions} [options] - As `launchExtension`'s.
34
+ * @returns {Promise<LaunchedExtension & {downloads: string}>} The running extension, and its
35
+ * Downloads folder; `close()` deletes both.
36
+ * @example
37
+ * const { context, downloads, close } = await launchDownloading(chromium, 'extension');
38
+ */
39
+ export declare function launchDownloading(chromium: BrowserType, dir: string, options?: LaunchOptions): Promise<LaunchedExtension & {
40
+ downloads: string;
41
+ }>;
@@ -0,0 +1,70 @@
1
+ /**
2
+ * A browser that downloads for real, for an extension that names, files or
3
+ * makes downloads. Playwright normally takes downloads over (CDP's
4
+ * `Browser.setDownloadBehavior` set to `allowAndName`), saving them under
5
+ * made-up names where no extension sees them: `downloads.onDeterminingFilename`
6
+ * never fires, and `chrome.downloads.download`'s names are lost. So the
7
+ * profile gets its own Downloads folder (Chrome's `download.default_directory`
8
+ * preference, written before the first start) and the behaviour goes back to
9
+ * Chrome's default: files land on disk as on a person's computer.
10
+ *
11
+ * @module testing/downloads
12
+ */
13
+
14
+ import { mkdir, readdir, writeFile } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+ import { extensionProfile } from './e2e.js';
17
+
18
+ /** @import { BrowserType } from 'playwright' */
19
+ /** @import { LaunchOptions, LaunchedExtension } from './e2e.js' */
20
+
21
+ /**
22
+ * Every file under a folder, as paths from it (Chrome's `.crdownload` files
23
+ * of downloads still running left out), sorted.
24
+ *
25
+ * @param {string} dir - The folder.
26
+ * @returns {Promise<string[]>} The files.
27
+ * @example
28
+ * await filesIn(downloads); // ['Trips/Lake.jpg', 'Trips/Lake 2.jpg']
29
+ */
30
+ export async function filesIn(dir) {
31
+ const entries = await readdir(dir, { recursive: true, withFileTypes: true });
32
+ return entries.filter((entry) => entry.isFile() && !entry.name.endsWith('.crdownload'))
33
+ .map((entry) => path.relative(dir, path.join(entry.parentPath, entry.name))).sort();
34
+ }
35
+
36
+ /**
37
+ * Load the unpacked extension at `dir` into a fresh profile whose downloads
38
+ * go to a folder of their own, really.
39
+ *
40
+ * @param {BrowserType} chromium - Playwright's `chromium`.
41
+ * @param {string} dir - The unpacked extension.
42
+ * @param {LaunchOptions} [options] - As `launchExtension`'s.
43
+ * @returns {Promise<LaunchedExtension & {downloads: string}>} The running extension, and its
44
+ * Downloads folder; `close()` deletes both.
45
+ * @example
46
+ * const { context, downloads, close } = await launchDownloading(chromium, 'extension');
47
+ */
48
+ export async function launchDownloading(chromium, dir, options = {}) {
49
+ const profile = await extensionProfile(chromium, dir, options);
50
+ const downloads = path.join(path.dirname(profile.dir), 'Downloads');
51
+ await mkdir(path.join(profile.dir, 'Default'), { recursive: true });
52
+ await mkdir(downloads, { recursive: true });
53
+ await writeFile(path.join(profile.dir, 'Default', 'Preferences'), JSON.stringify({
54
+ download: { default_directory: downloads, prompt_for_download: false, directory_upgrade: true },
55
+ }));
56
+ const launched = await profile.launch();
57
+ const page = await launched.context.newPage();
58
+ const cdp = await launched.context.newCDPSession(page);
59
+ await cdp.send('Browser.setDownloadBehavior', { behavior: 'default' });
60
+ await cdp.detach();
61
+ await page.close();
62
+ return {
63
+ ...launched,
64
+ downloads,
65
+ async close() {
66
+ await launched.close();
67
+ await profile.remove();
68
+ },
69
+ };
70
+ }
@@ -9,8 +9,10 @@
9
9
  export { TEST_BUILDS_ENV, testBuildDir } from '../build/test-build.js';
10
10
  export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
11
11
  export { CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until, } from './e2e.js';
12
- export { eachLimited } from './pool.js';
13
- export { serve } from './server.js';
12
+ export { exitPoint, parseLiveOptions, parseProxy } from './live.js';
13
+ export { eachLimited } from '../time/pool.js';
14
+ export { filesIn, launchDownloading } from './downloads.js';
15
+ export { serve, testCertificate } from './server.js';
14
16
  export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
15
17
  export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
16
18
  export { savedTabGroups } from './saved-groups.js';
@@ -19,6 +21,8 @@ export type CheckRunner = import('./e2e.js').CheckRunner;
19
21
  export type ExtensionProfile = import('./e2e.js').ExtensionProfile;
20
22
  export type LaunchedExtension = import('./e2e.js').LaunchedExtension;
21
23
  export type LaunchOptions = import('./e2e.js').LaunchOptions;
24
+ export type Exit = import('./live.js').Exit;
25
+ export type LiveOptions = import('./live.js').LiveOptions;
22
26
  export type SavedTabGroup = import('./saved-groups.js').SavedTabGroup;
23
27
  export type Manifest = import('./warnings.js').Manifest;
24
28
  export type Reply = import('./server.js').Reply;
@@ -31,6 +35,8 @@ export type TestServer = import('./server.js').TestServer;
31
35
  * @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
32
36
  * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
33
37
  * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
38
+ * @typedef {import('./live.js').Exit} Exit
39
+ * @typedef {import('./live.js').LiveOptions} LiveOptions
34
40
  * @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
35
41
  * @typedef {import('./warnings.js').Manifest} Manifest
36
42
  * @typedef {import('./server.js').Reply} Reply
@@ -12,8 +12,10 @@ export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
12
12
  export {
13
13
  CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until,
14
14
  } from './e2e.js';
15
- export { eachLimited } from './pool.js';
16
- export { serve } from './server.js';
15
+ export { exitPoint, parseLiveOptions, parseProxy } from './live.js';
16
+ export { eachLimited } from '../time/pool.js';
17
+ export { filesIn, launchDownloading } from './downloads.js';
18
+ export { serve, testCertificate } from './server.js';
17
19
  export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
18
20
  export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
19
21
  export { savedTabGroups } from './saved-groups.js';
@@ -26,6 +28,8 @@ export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings
26
28
  * @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
27
29
  * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
28
30
  * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
31
+ * @typedef {import('./live.js').Exit} Exit
32
+ * @typedef {import('./live.js').LiveOptions} LiveOptions
29
33
  * @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
30
34
  * @typedef {import('./warnings.js').Manifest} Manifest
31
35
  * @typedef {import('./server.js').Reply} Reply
@@ -0,0 +1,146 @@
1
+ /**
2
+ * What a live checker (a run over real sites, never in CI) shares: its
3
+ * command line (which sites, how many at once, a proxy, how the browser
4
+ * looks) and where its traffic leaves for the internet, which decides what
5
+ * many sites show (cookie banners, prices, currency).
6
+ *
7
+ * @module testing/live
8
+ */
9
+ import type { APIRequestContext } from 'playwright';
10
+ export type Proxy = {
11
+ /**
12
+ * e.g. "http://uk.example.net:8080" or "socks5://127.0.0.1:1080".
13
+ */
14
+ server: string;
15
+ /**
16
+ * From the address, if it has one.
17
+ */
18
+ username?: string;
19
+ /**
20
+ * From the address, if it has one.
21
+ */
22
+ password?: string;
23
+ };
24
+ export type LiveOptions = {
25
+ /**
26
+ * Only sites whose address or name contains one of these (any case).
27
+ */
28
+ only: string[];
29
+ /**
30
+ * Only these categories (by id).
31
+ */
32
+ categories: string[];
33
+ /**
34
+ * Sites checked at once.
35
+ */
36
+ concurrency: number;
37
+ /**
38
+ * The proxy every request goes through.
39
+ */
40
+ proxy: Proxy | null;
41
+ /**
42
+ * The proxy's address without its password, for the output.
43
+ */
44
+ proxyLabel: string;
45
+ /**
46
+ * The browser's language (navigator.language, Accept-Language).
47
+ */
48
+ locale: string;
49
+ /**
50
+ * The browser's time zone.
51
+ */
52
+ timezone: string;
53
+ /**
54
+ * Where the report goes ('' for the checker's default).
55
+ */
56
+ out: string;
57
+ /**
58
+ * Show the usage and stop.
59
+ */
60
+ help: boolean;
61
+ };
62
+ /** @import { APIRequestContext } from 'playwright' */
63
+ /**
64
+ * A proxy, as Playwright takes it.
65
+ *
66
+ * @typedef {object} Proxy
67
+ * @property {string} server e.g. "http://uk.example.net:8080" or "socks5://127.0.0.1:1080".
68
+ * @property {string} [username] From the address, if it has one.
69
+ * @property {string} [password] From the address, if it has one.
70
+ */
71
+ /**
72
+ * A live checker's options.
73
+ *
74
+ * @typedef {object} LiveOptions
75
+ * @property {string[]} only Only sites whose address or name contains one of these (any case).
76
+ * @property {string[]} categories Only these categories (by id).
77
+ * @property {number} concurrency Sites checked at once.
78
+ * @property {Proxy | null} proxy The proxy every request goes through.
79
+ * @property {string} proxyLabel The proxy's address without its password, for the output.
80
+ * @property {string} locale The browser's language (navigator.language, Accept-Language).
81
+ * @property {string} timezone The browser's time zone.
82
+ * @property {string} out Where the report goes ('' for the checker's default).
83
+ * @property {boolean} help Show the usage and stop.
84
+ */
85
+ /**
86
+ * Read a proxy address.
87
+ *
88
+ * @param {string} address - e.g. "http://user:pass@host:3128".
89
+ * @returns {{ proxy: Proxy, label: string }} For Playwright, and for the output (no password).
90
+ * @throws {Error} When it isn't an address.
91
+ * @example
92
+ * parseProxy('http://me:secret@uk.example:8080').label; // 'http://me:•••@uk.example:8080'
93
+ */
94
+ export declare function parseProxy(address: string): {
95
+ proxy: Proxy;
96
+ label: string;
97
+ };
98
+ /**
99
+ * Read a live checker's command line: `--only <word>` (repeatable, or bare
100
+ * words), `--category <id>` (repeatable), `--concurrency <n>`, `--proxy <url>`
101
+ * (default `HTTPS_PROXY`), `--locale <tag>`, `--timezone <zone>`, `--out <dir>`,
102
+ * `--help`. A value may follow an `=` ("--only=amazon"); commas list several.
103
+ *
104
+ * @param {readonly string[]} argv - The arguments after the script.
105
+ * @param {Readonly<Record<string, string | undefined>>} env - The environment (HTTPS_PROXY, https_proxy).
106
+ * @param {{ usage: string, concurrency?: number, maxConcurrency?: number, locale?: string, timezone?: string }} defaults - The
107
+ * usage text (shown with an unknown option), and the defaults: 3 at once (at most 8), en-GB, Europe/London.
108
+ * @returns {LiveOptions} The options.
109
+ * @throws {Error} On an unknown option or a bad value.
110
+ * @example
111
+ * parseLiveOptions(['--category', 'shops', 'amazon'], {}, { usage }).only; // ['amazon']
112
+ */
113
+ export declare function parseLiveOptions(argv: readonly string[], env: Readonly<Record<string, string | undefined>>, { usage, concurrency, maxConcurrency, locale, timezone }: {
114
+ usage: string;
115
+ concurrency?: number;
116
+ maxConcurrency?: number;
117
+ locale?: string;
118
+ timezone?: string;
119
+ }): LiveOptions;
120
+ export type Exit = {
121
+ /**
122
+ * The public address.
123
+ */
124
+ ip: string;
125
+ /**
126
+ * Its ISO country code ("GB", "TH").
127
+ */
128
+ country: string;
129
+ /**
130
+ * Readable: city and organisation, where the service says.
131
+ */
132
+ where: string;
133
+ /**
134
+ * The service that answered.
135
+ */
136
+ source: string;
137
+ };
138
+ /**
139
+ * Where the browser's traffic leaves for the internet: its public IP's
140
+ * country, asked once through the same proxy the browser uses. Sites go by
141
+ * that address, not the browser's language or time zone.
142
+ *
143
+ * @param {APIRequestContext} request - A browser context's `request` (it uses the context's proxy).
144
+ * @returns {Promise<Exit | null>} null when no service answered.
145
+ */
146
+ export declare function exitPoint(request: APIRequestContext): Promise<Exit | null>;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * What a live checker (a run over real sites, never in CI) shares: its
3
+ * command line (which sites, how many at once, a proxy, how the browser
4
+ * looks) and where its traffic leaves for the internet, which decides what
5
+ * many sites show (cookie banners, prices, currency).
6
+ *
7
+ * @module testing/live
8
+ */
9
+
10
+ /** @import { APIRequestContext } from 'playwright' */
11
+
12
+ /**
13
+ * A proxy, as Playwright takes it.
14
+ *
15
+ * @typedef {object} Proxy
16
+ * @property {string} server e.g. "http://uk.example.net:8080" or "socks5://127.0.0.1:1080".
17
+ * @property {string} [username] From the address, if it has one.
18
+ * @property {string} [password] From the address, if it has one.
19
+ */
20
+
21
+ /**
22
+ * A live checker's options.
23
+ *
24
+ * @typedef {object} LiveOptions
25
+ * @property {string[]} only Only sites whose address or name contains one of these (any case).
26
+ * @property {string[]} categories Only these categories (by id).
27
+ * @property {number} concurrency Sites checked at once.
28
+ * @property {Proxy | null} proxy The proxy every request goes through.
29
+ * @property {string} proxyLabel The proxy's address without its password, for the output.
30
+ * @property {string} locale The browser's language (navigator.language, Accept-Language).
31
+ * @property {string} timezone The browser's time zone.
32
+ * @property {string} out Where the report goes ('' for the checker's default).
33
+ * @property {boolean} help Show the usage and stop.
34
+ */
35
+
36
+ /**
37
+ * Read a proxy address.
38
+ *
39
+ * @param {string} address - e.g. "http://user:pass@host:3128".
40
+ * @returns {{ proxy: Proxy, label: string }} For Playwright, and for the output (no password).
41
+ * @throws {Error} When it isn't an address.
42
+ * @example
43
+ * parseProxy('http://me:secret@uk.example:8080').label; // 'http://me:•••@uk.example:8080'
44
+ */
45
+ export function parseProxy(address) {
46
+ const url = new URL(address.includes('://') ? address : `http://${address}`);
47
+ if (!url.hostname) throw new Error(`Not a proxy address: ${address}`);
48
+ const server = `${url.protocol}//${url.host}`;
49
+ const username = decodeURIComponent(url.username);
50
+ const password = decodeURIComponent(url.password);
51
+ return {
52
+ proxy: { server, ...(username && { username }), ...(password && { password }) },
53
+ label: `${url.protocol}//${username ? `${username}${password ? ':•••' : ''}@` : ''}${url.host}`,
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Read a live checker's command line: `--only <word>` (repeatable, or bare
59
+ * words), `--category <id>` (repeatable), `--concurrency <n>`, `--proxy <url>`
60
+ * (default `HTTPS_PROXY`), `--locale <tag>`, `--timezone <zone>`, `--out <dir>`,
61
+ * `--help`. A value may follow an `=` ("--only=amazon"); commas list several.
62
+ *
63
+ * @param {readonly string[]} argv - The arguments after the script.
64
+ * @param {Readonly<Record<string, string | undefined>>} env - The environment (HTTPS_PROXY, https_proxy).
65
+ * @param {{ usage: string, concurrency?: number, maxConcurrency?: number, locale?: string, timezone?: string }} defaults - The
66
+ * usage text (shown with an unknown option), and the defaults: 3 at once (at most 8), en-GB, Europe/London.
67
+ * @returns {LiveOptions} The options.
68
+ * @throws {Error} On an unknown option or a bad value.
69
+ * @example
70
+ * parseLiveOptions(['--category', 'shops', 'amazon'], {}, { usage }).only; // ['amazon']
71
+ */
72
+ export function parseLiveOptions(argv, env, { usage, concurrency = 3, maxConcurrency = 8, locale = 'en-GB', timezone = 'Europe/London' }) {
73
+ /** @type {LiveOptions} */
74
+ const options = { only: [], categories: [], concurrency, proxy: null, proxyLabel: '', locale, timezone, out: '', help: false };
75
+ let proxy = env.HTTPS_PROXY || env.https_proxy || '';
76
+ for (let at = 0; at < argv.length; at += 1) {
77
+ const arg = argv[at];
78
+ const equals = arg.startsWith('--') ? arg.indexOf('=') : -1;
79
+ const flag = equals > 0 ? arg.slice(0, equals) : arg;
80
+ const inline = equals > 0 ? arg.slice(equals + 1) : undefined;
81
+ const value = () => {
82
+ const next = inline ?? argv[++at];
83
+ if (next === undefined || next === '') throw new Error(`${flag} needs a value`);
84
+ return next;
85
+ };
86
+ switch (flag) {
87
+ case '--only': options.only.push(...value().split(',')); break;
88
+ case '--category': options.categories.push(...value().split(',')); break;
89
+ case '--concurrency': {
90
+ const count = Number(value());
91
+ if (!Number.isInteger(count) || count < 1 || count > maxConcurrency) throw new Error(`--concurrency must be 1 to ${maxConcurrency}`);
92
+ options.concurrency = count;
93
+ break;
94
+ }
95
+ case '--proxy': proxy = value(); break;
96
+ case '--locale': options.locale = value(); break;
97
+ case '--timezone': options.timezone = value(); break;
98
+ case '--out': options.out = value(); break;
99
+ case '--help': case '-h': options.help = true; break;
100
+ default:
101
+ if (flag.startsWith('-')) throw new Error(`Unknown option ${flag}\n\n${usage}`);
102
+ options.only.push(flag);
103
+ }
104
+ }
105
+ if (proxy) ({ proxy: options.proxy, label: options.proxyLabel } = parseProxy(proxy));
106
+ return options;
107
+ }
108
+
109
+ /**
110
+ * The exit point.
111
+ *
112
+ * @typedef {object} Exit
113
+ * @property {string} ip The public address.
114
+ * @property {string} country Its ISO country code ("GB", "TH").
115
+ * @property {string} where Readable: city and organisation, where the service says.
116
+ * @property {string} source The service that answered.
117
+ */
118
+
119
+ /**
120
+ * Lookup services that need no key, tried in order, each with how to read its answer.
121
+ *
122
+ * @type {readonly { url: string, read: (data: Record<string, unknown>) => { ip: unknown, country: unknown, where: unknown[] } }[]}
123
+ */
124
+ const SERVICES = [
125
+ { url: 'https://ipinfo.io/json', read: (data) => ({ ip: data.ip, country: data.country, where: [data.city, data.org] }) },
126
+ { url: 'https://api.country.is/', read: (data) => ({ ip: data.ip, country: data.country, where: [] }) },
127
+ { url: 'https://ipapi.co/json/', read: (data) => ({ ip: data.ip, country: data.country_code, where: [data.city, data.org] }) },
128
+ ];
129
+
130
+ /**
131
+ * Where the browser's traffic leaves for the internet: its public IP's
132
+ * country, asked once through the same proxy the browser uses. Sites go by
133
+ * that address, not the browser's language or time zone.
134
+ *
135
+ * @param {APIRequestContext} request - A browser context's `request` (it uses the context's proxy).
136
+ * @returns {Promise<Exit | null>} null when no service answered.
137
+ */
138
+ export async function exitPoint(request) {
139
+ for (const { url, read } of SERVICES) {
140
+ try {
141
+ const response = await request.get(url, { timeout: 10000, headers: { accept: 'application/json' } });
142
+ if (!response.ok()) continue;
143
+ const { ip, country, where } = read(await response.json());
144
+ if (typeof country !== 'string' || !country) continue;
145
+ return { ip: String(ip ?? ''), country: country.toUpperCase(), where: where.filter((part) => typeof part === 'string' && part).join(', '), source: new URL(url).host };
146
+ } catch {
147
+ // The next service.
148
+ }
149
+ }
150
+ return null;
151
+ }
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * A local web server for end-to-end test pages: a site for the content
3
3
  * script to run on, answering each path from a table (or a function), on
4
- * 127.0.0.1 so a test build's host permission can cover it.
4
+ * 127.0.0.1 so a test build's host permission can cover it; over HTTPS too,
5
+ * with a throwaway certificate.
5
6
  *
6
7
  * @module testing/server
7
8
  */
@@ -31,17 +32,36 @@ export type TestServer = {
31
32
  */
32
33
  close: () => Promise<void>;
33
34
  };
35
+ /**
36
+ * A self-signed certificate for 127.0.0.1 and localhost, made by `openssl`, valid for a day: for
37
+ * {@link serve}'s `tls`, with Chromium started with `--ignore-certificate-errors` (a site whose
38
+ * name Chrome only reaches over HTTPS, as every HSTS-preloaded one, mapped to the test server with
39
+ * `--host-resolver-rules`).
40
+ *
41
+ * @returns {{key: string, cert: string}} The key and certificate, PEM.
42
+ * @example
43
+ * const site = await serve(routes, { tls: testCertificate() });
44
+ */
45
+ export declare function testCertificate(): {
46
+ key: string;
47
+ cert: string;
48
+ };
34
49
  /**
35
50
  * Serve `routes` on 127.0.0.1.
36
51
  *
37
52
  * @param {Routes} routes - Path → page, or a function of the path.
38
- * @param {{port?: number}} [options] - `port`: a fixed port (default: any free one).
53
+ * @param {{port?: number, tls?: {key: string, cert: string}}} [options] - `port`: a fixed port (default: any
54
+ * free one); `tls`: serve HTTPS with this key and certificate ({@link testCertificate}).
39
55
  * @returns {Promise<TestServer>} The listening server.
40
56
  * @example
41
57
  * const site = await serve({'/': '<!doctype html><textarea></textarea>'});
42
58
  * await page.goto(`${site.origin}/`);
43
59
  * await site.close();
44
60
  */
45
- export declare function serve(routes: Routes, { port }?: {
61
+ export declare function serve(routes: Routes, { port, tls }?: {
46
62
  port?: number;
63
+ tls?: {
64
+ key: string;
65
+ cert: string;
66
+ };
47
67
  }): Promise<TestServer>;
@@ -1,12 +1,18 @@
1
1
  /**
2
2
  * A local web server for end-to-end test pages: a site for the content
3
3
  * script to run on, answering each path from a table (or a function), on
4
- * 127.0.0.1 so a test build's host permission can cover it.
4
+ * 127.0.0.1 so a test build's host permission can cover it; over HTTPS too,
5
+ * with a throwaway certificate.
5
6
  *
6
7
  * @module testing/server
7
8
  */
8
9
 
10
+ import { execFileSync } from 'node:child_process';
11
+ import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
9
12
  import { createServer } from 'node:http';
13
+ import { createServer as createSecureServer } from 'node:https';
14
+ import { tmpdir } from 'node:os';
15
+ import path from 'node:path';
10
16
 
11
17
  /** @import { IncomingMessage, Server } from 'node:http' */
12
18
 
@@ -52,19 +58,42 @@ async function replyFor(routes, pathname, request) {
52
58
  return Object.hasOwn(table, pathname) ? table[pathname] : undefined;
53
59
  }
54
60
 
61
+ /**
62
+ * A self-signed certificate for 127.0.0.1 and localhost, made by `openssl`, valid for a day: for
63
+ * {@link serve}'s `tls`, with Chromium started with `--ignore-certificate-errors` (a site whose
64
+ * name Chrome only reaches over HTTPS, as every HSTS-preloaded one, mapped to the test server with
65
+ * `--host-resolver-rules`).
66
+ *
67
+ * @returns {{key: string, cert: string}} The key and certificate, PEM.
68
+ * @example
69
+ * const site = await serve(routes, { tls: testCertificate() });
70
+ */
71
+ export function testCertificate() {
72
+ const dir = mkdtempSync(path.join(tmpdir(), 'kit-tls-'));
73
+ try {
74
+ execFileSync('openssl', ['req', '-x509', '-newkey', 'rsa:2048', '-nodes', '-days', '1', '-subj', '/CN=localhost',
75
+ '-keyout', path.join(dir, 'key.pem'), '-out', path.join(dir, 'cert.pem')], { stdio: 'ignore' });
76
+ return { key: readFileSync(path.join(dir, 'key.pem'), 'utf8'), cert: readFileSync(path.join(dir, 'cert.pem'), 'utf8') };
77
+ } finally {
78
+ rmSync(dir, { recursive: true, force: true });
79
+ }
80
+ }
81
+
55
82
  /**
56
83
  * Serve `routes` on 127.0.0.1.
57
84
  *
58
85
  * @param {Routes} routes - Path → page, or a function of the path.
59
- * @param {{port?: number}} [options] - `port`: a fixed port (default: any free one).
86
+ * @param {{port?: number, tls?: {key: string, cert: string}}} [options] - `port`: a fixed port (default: any
87
+ * free one); `tls`: serve HTTPS with this key and certificate ({@link testCertificate}).
60
88
  * @returns {Promise<TestServer>} The listening server.
61
89
  * @example
62
90
  * const site = await serve({'/': '<!doctype html><textarea></textarea>'});
63
91
  * await page.goto(`${site.origin}/`);
64
92
  * await site.close();
65
93
  */
66
- export async function serve(routes, { port = 0 } = {}) {
67
- const server = createServer(async (request, response) => {
94
+ export async function serve(routes, { port = 0, tls } = {}) {
95
+ /** @type {import('node:http').RequestListener} */
96
+ const handle = async (request, response) => {
68
97
  const { pathname } = new URL(request.url ?? '/', 'http://127.0.0.1');
69
98
  const reply = await replyFor(routes, pathname, request);
70
99
  if (reply === undefined) {
@@ -75,13 +104,14 @@ export async function serve(routes, { port = 0 } = {}) {
75
104
  const { body, type = HTML, status = 200, headers = {} } = typeof reply === 'string' || reply instanceof Uint8Array ? { body: reply } : reply;
76
105
  response.writeHead(status, { 'Content-Type': type, ...headers });
77
106
  response.end(body);
78
- });
107
+ };
108
+ const server = tls ? createSecureServer(tls, handle) : createServer(handle);
79
109
  await new Promise((resolve) => server.listen(port, '127.0.0.1', () => resolve(undefined)));
80
110
  const address = /** @type {import('node:net').AddressInfo} */ (server.address());
81
111
  return {
82
112
  server,
83
113
  port: address.port,
84
- origin: `http://127.0.0.1:${address.port}`,
114
+ origin: `${tls ? 'https' : 'http'}://127.0.0.1:${address.port}`,
85
115
  close() {
86
116
  server.closeAllConnections();
87
117
  return new Promise((resolve) => server.close(() => resolve()));
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Working through a list a few at a time: a live check of many sites, each
3
- * in one of a few browsers.
2
+ * Working through a list a few at a time: files fetched by an extension page,
3
+ * or a live check of many sites, each in one of a few browsers. Runs
4
+ * anywhere (worker, page, Node).
4
5
  *
5
- * @module testing/pool
6
+ * @module time/pool
6
7
  */
7
8
  /**
8
9
  * Run `work` on every item, at most `limit` at once, each slot taking the
@@ -1,8 +1,9 @@
1
1
  /**
2
- * Working through a list a few at a time: a live check of many sites, each
3
- * in one of a few browsers.
2
+ * Working through a list a few at a time: files fetched by an extension page,
3
+ * or a live check of many sites, each in one of a few browsers. Runs
4
+ * anywhere (worker, page, Node).
4
5
  *
5
- * @module testing/pool
6
+ * @module time/pool
6
7
  */
7
8
 
8
9
  /**