@arraypress/extension-kit 1.9.0 → 1.10.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.
@@ -0,0 +1,160 @@
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, rm } from 'node:fs/promises';
14
+ import path from 'node:path';
15
+ import { 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
+ /**
64
+ * Run a live check: read the command line, visit the chosen sites, give back every visit.
65
+ * Prints the usage and exits for `--help` (or an option it doesn't know), and exits when no
66
+ * site matches.
67
+ *
68
+ * @template {LiveSite} S
69
+ * @template R
70
+ * @param {object} run
71
+ * @param {BrowserType} run.chromium - Playwright's chromium.
72
+ * @param {string} run.build - The extension's live test build (every site granted).
73
+ * @param {string} run.prefix - The browsers' profile prefix.
74
+ * @param {string} run.root - The extension's repository (the report goes in `test/live/report`).
75
+ * @param {S[]} run.sites - Every site.
76
+ * @param {string[]} run.categories - The categories' ids, for the usage.
77
+ * @param {number} run.pageMs - The most a visit may take.
78
+ * @param {(browser: LaunchedExtension, page: Page, site: S) => Promise<R>} run.visit - Check one site, on a fresh page.
79
+ * @param {(site: S, error: string) => R} run.failed - A visit that couldn't finish (`'timed out'`).
80
+ * @param {(result: {site: S, visit: R}) => void} [run.onResult] - Each visit as it finishes (a line of output).
81
+ * @param {{argv?: string[], env?: Record<string, string | undefined>, concurrency?: number, findExit?: typeof exitPoint}} [run.command] - The
82
+ * arguments and environment (default the process's), the most sites at once (default 4), and how the exit point
83
+ * is found (default `exitPoint`, which asks a lookup service).
84
+ * @returns {Promise<LiveRun<S, R>>} The run.
85
+ * @example
86
+ * const run = await runLiveCheck({ chromium, build: BUILDS.live, prefix: 'better-colors-live-', root, sites: allSites(),
87
+ * categories: CATEGORIES.map((c) => c.id), pageMs: 120_000, visit: (browser, page, site) => visit(browser, page, site.url),
88
+ * failed: (site, error) => ({ url: site.url, error }), onResult: (result) => console.log(pageLine(result)) });
89
+ */
90
+ export async function runLiveCheck({ chromium, build, prefix, root, sites, categories, pageMs, visit, failed, onResult = () => {}, command = {} }) {
91
+ const { argv = process.argv.slice(2), env = process.env, concurrency = 4, findExit = exitPoint } = command;
92
+ const usage = liveUsage(categories, concurrency);
93
+ /** @type {import('./live.js').LiveOptions} */
94
+ let options;
95
+ try {
96
+ options = parseLiveOptions(argv, env, { usage, concurrency, maxConcurrency: concurrency });
97
+ } catch (error) {
98
+ console.error(error instanceof Error ? error.message : error);
99
+ process.exit(2);
100
+ }
101
+ if (options.help) {
102
+ console.log(usage);
103
+ process.exit(0);
104
+ }
105
+ const words = options.only.map((word) => word.toLowerCase());
106
+ const chosen = sites.filter((site) => (!options.categories.length || options.categories.includes(site.category))
107
+ && (!words.length || words.some((word) => site.url.toLowerCase().includes(word) || site.name.toLowerCase().includes(word))));
108
+ if (!chosen.length) {
109
+ console.error('No pages match.');
110
+ process.exit(2);
111
+ }
112
+ const dir = options.out ? path.resolve(options.out) : path.join(root, 'test', 'live', 'report');
113
+ await rm(dir, { recursive: true, force: true });
114
+ await mkdir(dir, { recursive: true });
115
+
116
+ const browserOptions = realisticBrowser(options.proxy ? { proxy: options.proxy } : {});
117
+ const plain = await chromium.launch({ channel: 'chromium', ...browserOptions });
118
+ /** @type {(LaunchedExtension | null)[]} */
119
+ const browsers = [];
120
+ const closeAll = async () => {
121
+ await Promise.allSettled([plain.close(), ...browsers.map((launched) => launched?.close())]);
122
+ };
123
+ const forget = closeOnSignal(closeAll);
124
+ try {
125
+ const userAgent = await chromeUserAgent(plain);
126
+ const probe = await plain.newContext();
127
+ const exit = await findExit(probe.request);
128
+ await probe.close();
129
+ console.log(exit ? `Exit: ${exit.country} (${exit.ip}${exit.where ? `, ${exit.where}` : ''})${options.proxyLabel ? ` through ${options.proxyLabel}` : ''}` : 'Exit: unknown');
130
+ console.log(`Pages: ${chosen.length}, ${options.concurrency} at a time.\n`);
131
+ const launchOptions = { ...browserOptions, userAgent, locale: options.locale, timezoneId: options.timezone, viewport: { width: 1280, height: 900 } };
132
+ const started = new Date().toISOString();
133
+ /** @type {{site: S, visit: R}[]} */
134
+ const results = [];
135
+ await eachLimited(chosen, options.concurrency, async (site, slot, index) => {
136
+ browsers[slot] ??= await launchExtension(chromium, build, { prefix: `${prefix}${slot}-`, ...launchOptions });
137
+ const launched = /** @type {LaunchedExtension} */ (browsers[slot]);
138
+ await within(launched.context.clearCookies(), 10_000, undefined);
139
+ const page = await launched.context.newPage();
140
+ const result = await within(visit(launched, page, site), pageMs, undefined);
141
+ const timedOut = result === undefined;
142
+ await within(page.close(), 15_000, undefined);
143
+ if (timedOut) {
144
+ // A page that ran out of time may have left its browser stuck: the slot gets a new one.
145
+ await within(launched.close(), 15_000, undefined);
146
+ browsers[slot] = null;
147
+ }
148
+ results[index] = { site, visit: timedOut ? failed(site, 'timed out') : /** @type {R} */ (result) };
149
+ onResult(results[index]);
150
+ });
151
+ return {
152
+ dir, started, finished: new Date().toISOString(), exit,
153
+ browser: { locale: options.locale, timezone: options.timezone, proxy: options.proxyLabel },
154
+ results: results.filter(Boolean),
155
+ };
156
+ } finally {
157
+ forget();
158
+ await closeAll();
159
+ }
160
+ }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Reaching a tested extension's pages from an e2e test: a Playwright page's
3
+ * tab id (as the extension's worker sees it), a page module called in a
4
+ * tab's frames as the extension calls it, and a page's whole tree through
5
+ * the DevTools protocol, closed shadow roots and frames included (where an
6
+ * extension's overlay lives). Tests only: an extension never needs CDP.
7
+ *
8
+ * @module testing/pages
9
+ */
10
+ import type { CDPSession, Page, Worker } from 'playwright';
11
+ /** @import { CDPSession, Page, Worker } from 'playwright' */
12
+ /**
13
+ * The tab id of a page: the newest tab at its address (any tab for a `chrome:` page).
14
+ *
15
+ * @param {Worker} worker - The extension's service worker.
16
+ * @param {Page} page - The page.
17
+ * @returns {Promise<number>} Its tab's id.
18
+ * @example
19
+ * const tabId = await tabIdOf(worker, page);
20
+ */
21
+ export declare function tabIdOf(worker: Worker, page: Page): Promise<number>;
22
+ /**
23
+ * Call an export of the extension's page module in a tab's frames, as `callInPage` does from the
24
+ * worker.
25
+ *
26
+ * @param {Worker} worker - The extension's service worker.
27
+ * @param {number} tabId - The tab.
28
+ * @param {string} module - The module's path in the extension (`content/main.js`).
29
+ * @param {string} name - The export.
30
+ * @param {unknown[]} [args] - Its arguments.
31
+ * @returns {Promise<{frameId: number, result: any}[]>} Each frame's answer.
32
+ * @example
33
+ * const [{ result }] = await callTabModule(worker, tabId, 'content/main.js', 'scan');
34
+ */
35
+ export declare function callTabModule(worker: Worker, tabId: number, module: string, name: string, args?: unknown[]): Promise<{
36
+ frameId: number;
37
+ result: any;
38
+ }[]>;
39
+ export type CdpNode = {
40
+ /**
41
+ * Its id in the session.
42
+ */
43
+ nodeId: number;
44
+ /**
45
+ * Its lasting id.
46
+ */
47
+ backendNodeId: number;
48
+ /**
49
+ * Its DOM node type.
50
+ */
51
+ nodeType: number;
52
+ /**
53
+ * Its name.
54
+ */
55
+ nodeName: string;
56
+ /**
57
+ * A text node's text.
58
+ */
59
+ nodeValue?: string;
60
+ /**
61
+ * Its attributes, name and value in turn.
62
+ */
63
+ attributes?: string[];
64
+ /**
65
+ * Its children.
66
+ */
67
+ children?: CdpNode[];
68
+ /**
69
+ * Its shadow roots, closed ones too.
70
+ */
71
+ shadowRoots?: CdpNode[];
72
+ /**
73
+ * A frame's document.
74
+ */
75
+ contentDocument?: CdpNode;
76
+ };
77
+ /**
78
+ * A node as the DevTools protocol's `DOM.getDocument` gives it (the parts read here).
79
+ *
80
+ * @typedef {object} CdpNode
81
+ * @property {number} nodeId Its id in the session.
82
+ * @property {number} backendNodeId Its lasting id.
83
+ * @property {number} nodeType Its DOM node type.
84
+ * @property {string} nodeName Its name.
85
+ * @property {string} [nodeValue] A text node's text.
86
+ * @property {string[]} [attributes] Its attributes, name and value in turn.
87
+ * @property {CdpNode[]} [children] Its children.
88
+ * @property {CdpNode[]} [shadowRoots] Its shadow roots, closed ones too.
89
+ * @property {CdpNode} [contentDocument] A frame's document.
90
+ */
91
+ /**
92
+ * Every node of a page's tree in tree order, closed shadow roots and same-process frames included.
93
+ *
94
+ * @param {CDPSession} cdp - A session on the page.
95
+ * @returns {Promise<CdpNode[]>} The nodes; a node's shadow roots come before its children.
96
+ * @example
97
+ * const boxes = (await pierceNodes(cdp)).filter((node) => nodeAttribute(node, 'class') === 'box');
98
+ */
99
+ export declare function pierceNodes(cdp: CDPSession): Promise<CdpNode[]>;
100
+ /**
101
+ * A node's attribute.
102
+ *
103
+ * @param {CdpNode} node - The node.
104
+ * @param {string} name - The attribute.
105
+ * @returns {string} Its value, or ''.
106
+ */
107
+ export declare function nodeAttribute(node: CdpNode, name: string): string;
108
+ /**
109
+ * A node's text: a text node's own, an element's children's joined.
110
+ *
111
+ * @param {CdpNode} node - The node.
112
+ * @returns {string} Its text.
113
+ */
114
+ export declare const nodeText: (node: CdpNode) => string;
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Reaching a tested extension's pages from an e2e test: a Playwright page's
3
+ * tab id (as the extension's worker sees it), a page module called in a
4
+ * tab's frames as the extension calls it, and a page's whole tree through
5
+ * the DevTools protocol, closed shadow roots and frames included (where an
6
+ * extension's overlay lives). Tests only: an extension never needs CDP.
7
+ *
8
+ * @module testing/pages
9
+ */
10
+
11
+ /** @import { CDPSession, Page, Worker } from 'playwright' */
12
+
13
+ /**
14
+ * The tab id of a page: the newest tab at its address (any tab for a `chrome:` page).
15
+ *
16
+ * @param {Worker} worker - The extension's service worker.
17
+ * @param {Page} page - The page.
18
+ * @returns {Promise<number>} Its tab's id.
19
+ * @example
20
+ * const tabId = await tabIdOf(worker, page);
21
+ */
22
+ export async function tabIdOf(worker, page) {
23
+ return worker.evaluate(async (url) => {
24
+ const query = url.startsWith('chrome:') ? {} : { url: url.replace(/#.*$/, '') };
25
+ const ids = (await chrome.tabs.query(query)).map((tab) => tab.id ?? -1);
26
+ if (!ids.length) throw new Error(`No tab for ${url}`);
27
+ return Math.max(...ids);
28
+ }, page.url());
29
+ }
30
+
31
+ /**
32
+ * Call an export of the extension's page module in a tab's frames, as `callInPage` does from the
33
+ * worker.
34
+ *
35
+ * @param {Worker} worker - The extension's service worker.
36
+ * @param {number} tabId - The tab.
37
+ * @param {string} module - The module's path in the extension (`content/main.js`).
38
+ * @param {string} name - The export.
39
+ * @param {unknown[]} [args] - Its arguments.
40
+ * @returns {Promise<{frameId: number, result: any}[]>} Each frame's answer.
41
+ * @example
42
+ * const [{ result }] = await callTabModule(worker, tabId, 'content/main.js', 'scan');
43
+ */
44
+ export function callTabModule(worker, tabId, module, name, args = []) {
45
+ return worker.evaluate(({ tabId, module, name, args }) => chrome.scripting.executeScript({
46
+ target: { tabId, allFrames: true },
47
+ injectImmediately: true,
48
+ // As the kit's importAndCall (a worker's evaluate takes no function from outside, and its CSP no eval).
49
+ func: (/** @type {string} */ module, /** @type {string} */ name, /** @type {unknown[]} */ args) => import(chrome.runtime.getURL(module)).then((loaded) => loaded[name](...args)),
50
+ args: [module, name, args],
51
+ }).then((frames) => frames.map(({ frameId, result }) => ({ frameId, result }))), { tabId, module, name, args });
52
+ }
53
+
54
+ /**
55
+ * A node as the DevTools protocol's `DOM.getDocument` gives it (the parts read here).
56
+ *
57
+ * @typedef {object} CdpNode
58
+ * @property {number} nodeId Its id in the session.
59
+ * @property {number} backendNodeId Its lasting id.
60
+ * @property {number} nodeType Its DOM node type.
61
+ * @property {string} nodeName Its name.
62
+ * @property {string} [nodeValue] A text node's text.
63
+ * @property {string[]} [attributes] Its attributes, name and value in turn.
64
+ * @property {CdpNode[]} [children] Its children.
65
+ * @property {CdpNode[]} [shadowRoots] Its shadow roots, closed ones too.
66
+ * @property {CdpNode} [contentDocument] A frame's document.
67
+ */
68
+
69
+ /**
70
+ * Every node of a page's tree in tree order, closed shadow roots and same-process frames included.
71
+ *
72
+ * @param {CDPSession} cdp - A session on the page.
73
+ * @returns {Promise<CdpNode[]>} The nodes; a node's shadow roots come before its children.
74
+ * @example
75
+ * const boxes = (await pierceNodes(cdp)).filter((node) => nodeAttribute(node, 'class') === 'box');
76
+ */
77
+ export async function pierceNodes(cdp) {
78
+ const { root } = /** @type {{root: CdpNode}} */ (await cdp.send('DOM.getDocument', { depth: -1, pierce: true }));
79
+ /** @type {CdpNode[]} */
80
+ const nodes = [];
81
+ /** @param {CdpNode} node */
82
+ const walk = (node) => {
83
+ nodes.push(node);
84
+ for (const child of [...(node.shadowRoots ?? []), ...(node.children ?? []), ...(node.contentDocument ? [node.contentDocument] : [])]) walk(child);
85
+ };
86
+ walk(root);
87
+ return nodes;
88
+ }
89
+
90
+ /**
91
+ * A node's attribute.
92
+ *
93
+ * @param {CdpNode} node - The node.
94
+ * @param {string} name - The attribute.
95
+ * @returns {string} Its value, or ''.
96
+ */
97
+ export function nodeAttribute(node, name) {
98
+ const attributes = node.attributes ?? [];
99
+ const at = attributes.findIndex((value, index) => index % 2 === 0 && value === name);
100
+ return at >= 0 ? attributes[at + 1] : '';
101
+ }
102
+
103
+ /**
104
+ * A node's text: a text node's own, an element's children's joined.
105
+ *
106
+ * @param {CdpNode} node - The node.
107
+ * @returns {string} Its text.
108
+ */
109
+ export const nodeText = (node) => (node.nodeType === 3 ? node.nodeValue ?? '' : (node.children ?? []).map(nodeText).join(''));
@@ -0,0 +1,58 @@
1
+ /**
2
+ * A notice in a side panel's list (chrome-ui.css `.sp-notice`): a card with an icon, a heading,
3
+ * a line, buttons beside the heading and actions under the line. For what a list leaves out or
4
+ * can't do: frames from other sites to allow, a web font that didn't load.
5
+ *
6
+ * @module ui/notice
7
+ */
8
+ export type Notice = {
9
+ /**
10
+ * Its sprite symbol's id.
11
+ */
12
+ icon: string;
13
+ /**
14
+ * An information's icon is grey, a warning's red. Default `warning`.
15
+ */
16
+ tone?: 'info' | 'warning';
17
+ /**
18
+ * The heading.
19
+ */
20
+ title: string;
21
+ /**
22
+ * The line under it.
23
+ */
24
+ text: string;
25
+ /**
26
+ * Icon buttons at the heading's end.
27
+ */
28
+ buttons?: HTMLElement[];
29
+ /**
30
+ * Buttons under the line, at the end (“Allow”).
31
+ */
32
+ actions?: HTMLElement[];
33
+ /**
34
+ * The page. Default `document`.
35
+ */
36
+ doc?: Document;
37
+ };
38
+ /**
39
+ * A notice's parts.
40
+ *
41
+ * @typedef {object} Notice
42
+ * @property {string} icon Its sprite symbol's id.
43
+ * @property {'info' | 'warning'} [tone] An information's icon is grey, a warning's red. Default `warning`.
44
+ * @property {string} title The heading.
45
+ * @property {string} text The line under it.
46
+ * @property {HTMLElement[]} [buttons] Icon buttons at the heading's end.
47
+ * @property {HTMLElement[]} [actions] Buttons under the line, at the end (“Allow”).
48
+ * @property {Document} [doc] The page. Default `document`.
49
+ */
50
+ /**
51
+ * A notice card.
52
+ *
53
+ * @param {Notice} notice - Its parts.
54
+ * @returns {HTMLElement} The card.
55
+ * @example
56
+ * notices.append(noticeCard({ icon: 'i-frame', tone: 'info', title: t('panelFrames'), text, actions: [allow] }));
57
+ */
58
+ export declare function noticeCard({ icon, tone, title, text, buttons, actions, doc }: Notice): HTMLElement;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * A notice in a side panel's list (chrome-ui.css `.sp-notice`): a card with an icon, a heading,
3
+ * a line, buttons beside the heading and actions under the line. For what a list leaves out or
4
+ * can't do: frames from other sites to allow, a web font that didn't load.
5
+ *
6
+ * @module ui/notice
7
+ */
8
+
9
+ import { make, spriteIcon } from '../dom/make.js';
10
+
11
+ /**
12
+ * A notice's parts.
13
+ *
14
+ * @typedef {object} Notice
15
+ * @property {string} icon Its sprite symbol's id.
16
+ * @property {'info' | 'warning'} [tone] An information's icon is grey, a warning's red. Default `warning`.
17
+ * @property {string} title The heading.
18
+ * @property {string} text The line under it.
19
+ * @property {HTMLElement[]} [buttons] Icon buttons at the heading's end.
20
+ * @property {HTMLElement[]} [actions] Buttons under the line, at the end (“Allow”).
21
+ * @property {Document} [doc] The page. Default `document`.
22
+ */
23
+
24
+ /**
25
+ * A notice card.
26
+ *
27
+ * @param {Notice} notice - Its parts.
28
+ * @returns {HTMLElement} The card.
29
+ * @example
30
+ * notices.append(noticeCard({ icon: 'i-frame', tone: 'info', title: t('panelFrames'), text, actions: [allow] }));
31
+ */
32
+ export function noticeCard({ icon, tone = 'warning', title, text, buttons = [], actions = [], doc = document }) {
33
+ const card = make('section', `sp-card sp-notice ${tone}`, '', doc);
34
+ const heading = make('div', 'sp-heading', '', doc);
35
+ heading.append(spriteIcon(icon, '', doc), make('h2', '', title, doc), ...buttons);
36
+ card.append(heading, make('p', 'sp-notice-text', text, doc));
37
+ if (actions.length) {
38
+ const row = make('div', 'sp-notice-actions', '', doc);
39
+ row.append(...actions);
40
+ card.append(row);
41
+ }
42
+ return card;
43
+ }