@arraypress/extension-kit 1.2.0 → 1.3.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 +100 -17
- package/bin/extension-kit.js +46 -10
- package/package.json +1 -1
- package/src/build/index.d.ts +4 -2
- package/src/build/index.js +6 -2
- package/src/build/store-frame.d.ts +176 -0
- package/src/build/store-frame.js +241 -0
- package/src/build/test-build.d.ts +85 -0
- package/src/build/test-build.js +82 -0
- package/src/build/toolbar-icons.d.ts +36 -18
- package/src/build/toolbar-icons.js +91 -24
- package/src/build/vendor.d.ts +78 -8
- package/src/build/vendor.js +133 -10
- package/src/build/zip.d.ts +3 -2
- package/src/build/zip.js +6 -4
- package/src/dom/focus.d.ts +28 -0
- package/src/dom/focus.js +36 -0
- package/src/dom/setup.d.ts +54 -0
- package/src/dom/setup.js +46 -0
- package/src/index.d.ts +8 -1
- package/src/index.js +7 -1
- package/src/sites/sites.d.ts +82 -0
- package/src/sites/sites.js +128 -0
- package/src/styles/chrome-ui.css +13 -6
- package/src/testing/browser.d.ts +52 -0
- package/src/testing/browser.js +79 -0
- package/src/testing/e2e.d.ts +18 -5
- package/src/testing/e2e.js +17 -5
- package/src/testing/index.d.ts +8 -3
- package/src/testing/index.js +10 -3
- package/src/testing/pool.d.ts +23 -0
- package/src/testing/pool.js +33 -0
- package/src/testing/time.d.ts +35 -0
- package/src/testing/time.js +46 -0
- package/src/ui/menu.d.ts +13 -6
- package/src/ui/menu.js +11 -7
- package/src/ui/site-list.d.ts +199 -0
- package/src/ui/site-list.js +194 -0
- package/src/ui/text-dialog.js +3 -1
- package/src/ui/toast.d.ts +25 -9
- package/src/ui/toast.js +25 -11
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sites, as people type them into a settings list ("example.com",
|
|
3
|
+
* "https://www.Example.com/shop") and as pages report them (`location.href`):
|
|
4
|
+
* the origin an extension asks Chrome for access to, the host a per-site
|
|
5
|
+
* on/off list keeps, whether a page is on a listed site (subdomains
|
|
6
|
+
* included), and the match patterns for both. Pure.
|
|
7
|
+
*
|
|
8
|
+
* A site is a web host: http or https, with a dot in its name (or
|
|
9
|
+
* `localhost`). Text without a scheme is read as https.
|
|
10
|
+
*
|
|
11
|
+
* @module sites/sites
|
|
12
|
+
*/
|
|
13
|
+
/** The longest host name DNS allows, and so the longest site a list takes. */
|
|
14
|
+
export declare const MAX_HOST_LENGTH = 253;
|
|
15
|
+
/**
|
|
16
|
+
* The origin of a site: what Chrome grants access to, so `www.` and the
|
|
17
|
+
* scheme are kept (https unless typed otherwise); the port and path aren't.
|
|
18
|
+
*
|
|
19
|
+
* @param {unknown} input - What was typed or stored, or a page's address.
|
|
20
|
+
* @returns {string | null} The origin, or null when it isn't a web site.
|
|
21
|
+
* @example
|
|
22
|
+
* siteOrigin('example.com'); // 'https://example.com'
|
|
23
|
+
* siteOrigin('http://localhost:3000/x'); // 'http://localhost'
|
|
24
|
+
* siteOrigin('chrome://settings'); // null
|
|
25
|
+
*/
|
|
26
|
+
export declare function siteOrigin(input: unknown): string | null;
|
|
27
|
+
/**
|
|
28
|
+
* The host a per-site list keeps: no scheme, port, path, leading `www.` or
|
|
29
|
+
* trailing dot, lower case. A list of hosts covers each one's subdomains
|
|
30
|
+
* ({@link onSite}, {@link hostPatterns}).
|
|
31
|
+
*
|
|
32
|
+
* @param {unknown} input - What was typed or stored, or a page's address.
|
|
33
|
+
* @returns {string | null} The host, or null when it isn't a web site.
|
|
34
|
+
* @example
|
|
35
|
+
* siteHost(' https://www.GitHub.com/a '); // 'github.com'
|
|
36
|
+
* siteHost('news.bbc.co.uk'); // 'news.bbc.co.uk'
|
|
37
|
+
* siteHost('not a site'); // null
|
|
38
|
+
*/
|
|
39
|
+
export declare function siteHost(input: unknown): string | null;
|
|
40
|
+
/**
|
|
41
|
+
* Whether a host is one of the listed sites, or a subdomain of one
|
|
42
|
+
* ("docs.github.com" is on "github.com"; "notgithub.com" isn't).
|
|
43
|
+
*
|
|
44
|
+
* @param {string | null} host - From {@link siteHost} (null for a page that isn't a web site).
|
|
45
|
+
* @param {readonly string[]} sites - Hosts from {@link siteHost}.
|
|
46
|
+
* @returns {boolean} True when it's on one of them.
|
|
47
|
+
* @example
|
|
48
|
+
* onSite(siteHost(location.href), settings.neverSites);
|
|
49
|
+
*/
|
|
50
|
+
export declare function onSite(host: string | null, sites: readonly string[]): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* The match patterns for a listed host and its subdomains: to leave it out
|
|
53
|
+
* of a registered content script (`excludeMatches`).
|
|
54
|
+
*
|
|
55
|
+
* @param {string} host - From {@link siteHost}.
|
|
56
|
+
* @returns {string[]} Two match patterns.
|
|
57
|
+
* @example
|
|
58
|
+
* hostPatterns('example.com'); // ['*://example.com/*', '*://*.example.com/*']
|
|
59
|
+
*/
|
|
60
|
+
export declare function hostPatterns(host: string): string[];
|
|
61
|
+
/**
|
|
62
|
+
* The host permission pattern for a site: its scheme and host, every path
|
|
63
|
+
* and port.
|
|
64
|
+
*
|
|
65
|
+
* @param {string} url - An origin from {@link siteOrigin}, or any web address.
|
|
66
|
+
* @returns {string} The pattern.
|
|
67
|
+
* @example
|
|
68
|
+
* originPattern('https://example.com'); // 'https://example.com/*'
|
|
69
|
+
* originPattern('https://bit.ly:8443/abc'); // 'https://bit.ly/*'
|
|
70
|
+
*/
|
|
71
|
+
export declare function originPattern(url: string): string;
|
|
72
|
+
/**
|
|
73
|
+
* An origin as a settings list shows it: without `https://` (an http one
|
|
74
|
+
* keeps its scheme, so it reads as different).
|
|
75
|
+
*
|
|
76
|
+
* @param {string} site - An origin from {@link siteOrigin}, or a host.
|
|
77
|
+
* @returns {string} What to show.
|
|
78
|
+
* @example
|
|
79
|
+
* siteLabel('https://example.com'); // 'example.com'
|
|
80
|
+
* siteLabel('http://example.com'); // 'http://example.com'
|
|
81
|
+
*/
|
|
82
|
+
export declare function siteLabel(site: string): string;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sites, as people type them into a settings list ("example.com",
|
|
3
|
+
* "https://www.Example.com/shop") and as pages report them (`location.href`):
|
|
4
|
+
* the origin an extension asks Chrome for access to, the host a per-site
|
|
5
|
+
* on/off list keeps, whether a page is on a listed site (subdomains
|
|
6
|
+
* included), and the match patterns for both. Pure.
|
|
7
|
+
*
|
|
8
|
+
* A site is a web host: http or https, with a dot in its name (or
|
|
9
|
+
* `localhost`). Text without a scheme is read as https.
|
|
10
|
+
*
|
|
11
|
+
* @module sites/sites
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** The longest host name DNS allows, and so the longest site a list takes. */
|
|
15
|
+
export const MAX_HOST_LENGTH = 253;
|
|
16
|
+
|
|
17
|
+
/** A scheme at the start of what was typed; `localhost:8080` is a host and port, not one. */
|
|
18
|
+
const SCHEME = /^[a-z][a-z\d+.-]*:(?!\d)/i;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* What was typed (or stored, or a page's address) as a web URL.
|
|
22
|
+
*
|
|
23
|
+
* @param {unknown} input - Anything.
|
|
24
|
+
* @returns {URL | null} The URL, or null when it isn't a web site's.
|
|
25
|
+
*/
|
|
26
|
+
function webUrl(input) {
|
|
27
|
+
// Only text: a stray number would otherwise read as an IP address (3 → "0.0.0.3").
|
|
28
|
+
const text = typeof input === 'string' ? input.trim() : '';
|
|
29
|
+
if (!text || /\s/.test(text)) return null;
|
|
30
|
+
try {
|
|
31
|
+
const url = new URL(SCHEME.test(text) ? text : `https://${text}`);
|
|
32
|
+
if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
|
|
33
|
+
return url.hostname.includes('.') || url.hostname === 'localhost' ? url : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The origin of a site: what Chrome grants access to, so `www.` and the
|
|
41
|
+
* scheme are kept (https unless typed otherwise); the port and path aren't.
|
|
42
|
+
*
|
|
43
|
+
* @param {unknown} input - What was typed or stored, or a page's address.
|
|
44
|
+
* @returns {string | null} The origin, or null when it isn't a web site.
|
|
45
|
+
* @example
|
|
46
|
+
* siteOrigin('example.com'); // 'https://example.com'
|
|
47
|
+
* siteOrigin('http://localhost:3000/x'); // 'http://localhost'
|
|
48
|
+
* siteOrigin('chrome://settings'); // null
|
|
49
|
+
*/
|
|
50
|
+
export function siteOrigin(input) {
|
|
51
|
+
const url = webUrl(input);
|
|
52
|
+
return url && `${url.protocol}//${url.hostname}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The host a per-site list keeps: no scheme, port, path, leading `www.` or
|
|
57
|
+
* trailing dot, lower case. A list of hosts covers each one's subdomains
|
|
58
|
+
* ({@link onSite}, {@link hostPatterns}).
|
|
59
|
+
*
|
|
60
|
+
* @param {unknown} input - What was typed or stored, or a page's address.
|
|
61
|
+
* @returns {string | null} The host, or null when it isn't a web site.
|
|
62
|
+
* @example
|
|
63
|
+
* siteHost(' https://www.GitHub.com/a '); // 'github.com'
|
|
64
|
+
* siteHost('news.bbc.co.uk'); // 'news.bbc.co.uk'
|
|
65
|
+
* siteHost('not a site'); // null
|
|
66
|
+
*/
|
|
67
|
+
export function siteHost(input) {
|
|
68
|
+
const url = webUrl(input);
|
|
69
|
+
if (!url) return null;
|
|
70
|
+
const host = url.hostname.replace(/^www\./, '').replace(/\.$/, '');
|
|
71
|
+
return (host.includes('.') || host === 'localhost') && host.length <= MAX_HOST_LENGTH ? host : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Whether a host is one of the listed sites, or a subdomain of one
|
|
76
|
+
* ("docs.github.com" is on "github.com"; "notgithub.com" isn't).
|
|
77
|
+
*
|
|
78
|
+
* @param {string | null} host - From {@link siteHost} (null for a page that isn't a web site).
|
|
79
|
+
* @param {readonly string[]} sites - Hosts from {@link siteHost}.
|
|
80
|
+
* @returns {boolean} True when it's on one of them.
|
|
81
|
+
* @example
|
|
82
|
+
* onSite(siteHost(location.href), settings.neverSites);
|
|
83
|
+
*/
|
|
84
|
+
export function onSite(host, sites) {
|
|
85
|
+
return host !== null && sites.some((site) => host === site || host.endsWith(`.${site}`));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The match patterns for a listed host and its subdomains: to leave it out
|
|
90
|
+
* of a registered content script (`excludeMatches`).
|
|
91
|
+
*
|
|
92
|
+
* @param {string} host - From {@link siteHost}.
|
|
93
|
+
* @returns {string[]} Two match patterns.
|
|
94
|
+
* @example
|
|
95
|
+
* hostPatterns('example.com'); // ['*://example.com/*', '*://*.example.com/*']
|
|
96
|
+
*/
|
|
97
|
+
export function hostPatterns(host) {
|
|
98
|
+
return [`*://${host}/*`, `*://*.${host}/*`];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* The host permission pattern for a site: its scheme and host, every path
|
|
103
|
+
* and port.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} url - An origin from {@link siteOrigin}, or any web address.
|
|
106
|
+
* @returns {string} The pattern.
|
|
107
|
+
* @example
|
|
108
|
+
* originPattern('https://example.com'); // 'https://example.com/*'
|
|
109
|
+
* originPattern('https://bit.ly:8443/abc'); // 'https://bit.ly/*'
|
|
110
|
+
*/
|
|
111
|
+
export function originPattern(url) {
|
|
112
|
+
const { protocol, hostname } = new URL(url);
|
|
113
|
+
return `${protocol}//${hostname}/*`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* An origin as a settings list shows it: without `https://` (an http one
|
|
118
|
+
* keeps its scheme, so it reads as different).
|
|
119
|
+
*
|
|
120
|
+
* @param {string} site - An origin from {@link siteOrigin}, or a host.
|
|
121
|
+
* @returns {string} What to show.
|
|
122
|
+
* @example
|
|
123
|
+
* siteLabel('https://example.com'); // 'example.com'
|
|
124
|
+
* siteLabel('http://example.com'); // 'http://example.com'
|
|
125
|
+
*/
|
|
126
|
+
export function siteLabel(site) {
|
|
127
|
+
return site.replace(/^https:\/\//, '');
|
|
128
|
+
}
|
package/src/styles/chrome-ui.css
CHANGED
|
@@ -621,6 +621,9 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
621
621
|
.list { list-style: none; margin: 0; padding: 0; }
|
|
622
622
|
.list:empty { display: none; }
|
|
623
623
|
|
|
624
|
+
/* A row that adds to the list above it (createSiteList): a field the row's width, then "Add". */
|
|
625
|
+
.row.add input { flex: 1; min-width: 0; }
|
|
626
|
+
|
|
624
627
|
/* About card, as chrome://settings/help (about_page.css): a 32px logo 10px from a 20px title. */
|
|
625
628
|
.about-product { justify-content: flex-start; gap: 10px; }
|
|
626
629
|
.product-title { font-size: 20px; }
|
|
@@ -780,11 +783,11 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
780
783
|
|
|
781
784
|
/* ------------------------------------------------------- Toast (cr-toast) */
|
|
782
785
|
|
|
786
|
+
/* A manual popover (createToast), in the top layer, or a page's own element shown and hidden with
|
|
787
|
+
`hidden`: the popover defaults undone either way. */
|
|
783
788
|
.toast {
|
|
784
789
|
position: fixed;
|
|
785
|
-
|
|
786
|
-
bottom: 0;
|
|
787
|
-
z-index: 2;
|
|
790
|
+
inset: auto auto 0 0;
|
|
788
791
|
display: flex;
|
|
789
792
|
align-items: center;
|
|
790
793
|
gap: 32px;
|
|
@@ -796,10 +799,16 @@ label.row, .row[actionable] { cursor: pointer; }
|
|
|
796
799
|
border-radius: 8px;
|
|
797
800
|
background: var(--toast-bg);
|
|
798
801
|
color: var(--toast-text);
|
|
802
|
+
width: auto;
|
|
803
|
+
height: auto;
|
|
804
|
+
overflow: visible;
|
|
805
|
+
border: 0;
|
|
799
806
|
line-height: 20px;
|
|
800
807
|
box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.28);
|
|
801
808
|
}
|
|
802
809
|
|
|
810
|
+
.toast[popover]:not(:popover-open) { display: none; }
|
|
811
|
+
|
|
803
812
|
.toast button {
|
|
804
813
|
height: auto;
|
|
805
814
|
margin-right: -8px;
|
|
@@ -1082,9 +1091,7 @@ button.floating .icon { width: 20px; height: 20px; }
|
|
|
1082
1091
|
|
|
1083
1092
|
/* The toast in a panel: the panel's width less 8px each side, above the footer. */
|
|
1084
1093
|
.side-panel .toast {
|
|
1085
|
-
|
|
1086
|
-
right: 8px;
|
|
1087
|
-
bottom: 56px;
|
|
1094
|
+
inset: auto 8px 56px 8px;
|
|
1088
1095
|
min-width: 0;
|
|
1089
1096
|
max-width: none;
|
|
1090
1097
|
margin: 0;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A browser that passes for a person's: for live checks against real sites,
|
|
3
|
+
* many of which turn away a browser that says it's automated (a
|
|
4
|
+
* "HeadlessChrome" user agent, `navigator.webdriver`), or show it something
|
|
5
|
+
* else. And closing every browser a run started when it's stopped (Ctrl-C,
|
|
6
|
+
* a kill), so nothing is left running.
|
|
7
|
+
*
|
|
8
|
+
* @module testing/browser
|
|
9
|
+
*/
|
|
10
|
+
import type { Browser } from 'playwright';
|
|
11
|
+
/**
|
|
12
|
+
* Launch options (for `chromium.launch`, `launchExtension` or
|
|
13
|
+
* `extensionProfile`) for a browser that doesn't announce it's automated:
|
|
14
|
+
* Blink's automation tells off, and without Playwright's
|
|
15
|
+
* `--enable-automation` (the "controlled by automated test software" bar).
|
|
16
|
+
* Pair it with {@link chromeUserAgent} for the user agent.
|
|
17
|
+
*
|
|
18
|
+
* @template {{args?: string[], ignoreDefaultArgs?: boolean | string[]} & Record<string, unknown>} O
|
|
19
|
+
* @param {O} [options] - Other launch options (`proxy`, `userAgent`, `locale`, …), kept.
|
|
20
|
+
* @returns {Omit<O, 'args' | 'ignoreDefaultArgs'> & {args: string[], ignoreDefaultArgs: string[]}} The
|
|
21
|
+
* options, with the switches added.
|
|
22
|
+
* @example
|
|
23
|
+
* const plain = await chromium.launch(realisticBrowser({ proxy }));
|
|
24
|
+
* const launched = await launchExtension(chromium, build, realisticBrowser({ userAgent: await chromeUserAgent(plain) }));
|
|
25
|
+
*/
|
|
26
|
+
export declare function realisticBrowser<O extends {
|
|
27
|
+
args?: string[];
|
|
28
|
+
ignoreDefaultArgs?: boolean | string[];
|
|
29
|
+
} & Record<string, unknown>>(options?: O): Omit<O, 'args' | 'ignoreDefaultArgs'> & {
|
|
30
|
+
args: string[];
|
|
31
|
+
ignoreDefaultArgs: string[];
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* The browser's user agent as ordinary Chrome's: "HeadlessChrome" (what
|
|
35
|
+
* headless mode sends) made "Chrome", the version kept.
|
|
36
|
+
*
|
|
37
|
+
* @param {Browser} browser - A launched Chromium.
|
|
38
|
+
* @returns {Promise<string>} The user agent to give its contexts (`userAgent`).
|
|
39
|
+
*/
|
|
40
|
+
export declare function chromeUserAgent(browser: Browser): Promise<string>;
|
|
41
|
+
/**
|
|
42
|
+
* On Ctrl-C or a kill, run `close` (given `ms` at most) and exit, so the
|
|
43
|
+
* browsers a run started don't outlive it.
|
|
44
|
+
*
|
|
45
|
+
* @param {() => Promise<unknown>} close - Closes everything (`Promise.allSettled` of each close, say).
|
|
46
|
+
* @param {number} [ms] - How long `close` may take. Default 10 s.
|
|
47
|
+
* @returns {() => void} Takes the handlers off again (call it once the run has closed everything itself).
|
|
48
|
+
* @example
|
|
49
|
+
* const stop = closeOnSignal(() => Promise.allSettled([plain.close(), launched.close()]));
|
|
50
|
+
* try { … } finally { stop(); await plain.close(); await launched.close(); }
|
|
51
|
+
*/
|
|
52
|
+
export declare function closeOnSignal(close: () => Promise<unknown>, ms?: number): () => void;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A browser that passes for a person's: for live checks against real sites,
|
|
3
|
+
* many of which turn away a browser that says it's automated (a
|
|
4
|
+
* "HeadlessChrome" user agent, `navigator.webdriver`), or show it something
|
|
5
|
+
* else. And closing every browser a run started when it's stopped (Ctrl-C,
|
|
6
|
+
* a kill), so nothing is left running.
|
|
7
|
+
*
|
|
8
|
+
* @module testing/browser
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { within } from './time.js';
|
|
12
|
+
|
|
13
|
+
/** @import { Browser } from 'playwright' */
|
|
14
|
+
|
|
15
|
+
/** The switch that hides `navigator.webdriver` and Blink's other automation tells. */
|
|
16
|
+
const NOT_AUTOMATED = '--disable-blink-features=AutomationControlled';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Launch options (for `chromium.launch`, `launchExtension` or
|
|
20
|
+
* `extensionProfile`) for a browser that doesn't announce it's automated:
|
|
21
|
+
* Blink's automation tells off, and without Playwright's
|
|
22
|
+
* `--enable-automation` (the "controlled by automated test software" bar).
|
|
23
|
+
* Pair it with {@link chromeUserAgent} for the user agent.
|
|
24
|
+
*
|
|
25
|
+
* @template {{args?: string[], ignoreDefaultArgs?: boolean | string[]} & Record<string, unknown>} O
|
|
26
|
+
* @param {O} [options] - Other launch options (`proxy`, `userAgent`, `locale`, …), kept.
|
|
27
|
+
* @returns {Omit<O, 'args' | 'ignoreDefaultArgs'> & {args: string[], ignoreDefaultArgs: string[]}} The
|
|
28
|
+
* options, with the switches added.
|
|
29
|
+
* @example
|
|
30
|
+
* const plain = await chromium.launch(realisticBrowser({ proxy }));
|
|
31
|
+
* const launched = await launchExtension(chromium, build, realisticBrowser({ userAgent: await chromeUserAgent(plain) }));
|
|
32
|
+
*/
|
|
33
|
+
export function realisticBrowser(options) {
|
|
34
|
+
const { args = [], ignoreDefaultArgs, ...rest } = options ?? /** @type {O} */ ({});
|
|
35
|
+
const ignored = Array.isArray(ignoreDefaultArgs) ? ignoreDefaultArgs : [];
|
|
36
|
+
return { ...rest, args: [...args, NOT_AUTOMATED], ignoreDefaultArgs: [...ignored, '--enable-automation'] };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* The browser's user agent as ordinary Chrome's: "HeadlessChrome" (what
|
|
41
|
+
* headless mode sends) made "Chrome", the version kept.
|
|
42
|
+
*
|
|
43
|
+
* @param {Browser} browser - A launched Chromium.
|
|
44
|
+
* @returns {Promise<string>} The user agent to give its contexts (`userAgent`).
|
|
45
|
+
*/
|
|
46
|
+
export async function chromeUserAgent(browser) {
|
|
47
|
+
const context = await browser.newContext();
|
|
48
|
+
try {
|
|
49
|
+
const page = await context.newPage();
|
|
50
|
+
return (await page.evaluate(() => navigator.userAgent)).replace('HeadlessChrome', 'Chrome');
|
|
51
|
+
} finally {
|
|
52
|
+
await context.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The signals a run is stopped with, and their exit codes (128 + the signal's number). */
|
|
57
|
+
const SIGNALS = /** @type {const} */ ([['SIGINT', 130], ['SIGTERM', 143]]);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* On Ctrl-C or a kill, run `close` (given `ms` at most) and exit, so the
|
|
61
|
+
* browsers a run started don't outlive it.
|
|
62
|
+
*
|
|
63
|
+
* @param {() => Promise<unknown>} close - Closes everything (`Promise.allSettled` of each close, say).
|
|
64
|
+
* @param {number} [ms] - How long `close` may take. Default 10 s.
|
|
65
|
+
* @returns {() => void} Takes the handlers off again (call it once the run has closed everything itself).
|
|
66
|
+
* @example
|
|
67
|
+
* const stop = closeOnSignal(() => Promise.allSettled([plain.close(), launched.close()]));
|
|
68
|
+
* try { … } finally { stop(); await plain.close(); await launched.close(); }
|
|
69
|
+
*/
|
|
70
|
+
export function closeOnSignal(close, ms = 10_000) {
|
|
71
|
+
/** @type {[string, () => void][]} */
|
|
72
|
+
const handlers = SIGNALS.map(([signal, code]) => [signal, () => {
|
|
73
|
+
within(Promise.resolve().then(close), ms, undefined).finally(() => process.exit(code));
|
|
74
|
+
}]);
|
|
75
|
+
for (const [signal, handler] of handlers) process.once(signal, handler);
|
|
76
|
+
return () => {
|
|
77
|
+
for (const [signal, handler] of handlers) process.off(signal, handler);
|
|
78
|
+
};
|
|
79
|
+
}
|
package/src/testing/e2e.d.ts
CHANGED
|
@@ -143,11 +143,16 @@ export declare function offscreenEmulator(cdp: CDPSession, suffix?: string): Pro
|
|
|
143
143
|
* @returns {Promise<() => Promise<unknown[]>>} Reads the recorded `path`s, oldest first.
|
|
144
144
|
*/
|
|
145
145
|
export declare function recordIconCalls(worker: Worker): Promise<() => Promise<unknown[]>>;
|
|
146
|
+
/** How long one check may take before it's failed, in ms: long enough for a slow real-browser check, short of a hang. */
|
|
147
|
+
export declare const CHECK_MS = 60000;
|
|
146
148
|
export type CheckRunner = {
|
|
147
149
|
/**
|
|
148
|
-
*
|
|
150
|
+
* Run one check, failed if it takes longer than `timeout` (default the runner's); resolves to
|
|
151
|
+
* whether it passed.
|
|
149
152
|
*/
|
|
150
|
-
check: (name: string, fn: () => unknown
|
|
153
|
+
check: (name: string, fn: () => unknown, options?: {
|
|
154
|
+
timeout?: number;
|
|
155
|
+
}) => Promise<boolean>;
|
|
151
156
|
/**
|
|
152
157
|
* Count a failure found outside any check (a page's
|
|
153
158
|
* uncaught error, say): printed now, listed and counted at the end.
|
|
@@ -163,23 +168,31 @@ export type CheckRunner = {
|
|
|
163
168
|
* script goes on, so one run shows everything that's wrong.
|
|
164
169
|
*
|
|
165
170
|
* @typedef {object} CheckRunner
|
|
166
|
-
* @property {(name: string, fn: () => unknown) => Promise<boolean>} check
|
|
171
|
+
* @property {(name: string, fn: () => unknown, options?: {timeout?: number}) => Promise<boolean>} check
|
|
172
|
+
* Run one check, failed if it takes longer than `timeout` (default the runner's); resolves to
|
|
173
|
+
* whether it passed.
|
|
167
174
|
* @property {(name: string) => void} fail Count a failure found outside any check (a page's
|
|
168
175
|
* uncaught error, say): printed now, listed and counted at the end.
|
|
169
176
|
* @property {() => void} finish Print the tally, and exit with 1 if anything failed.
|
|
170
177
|
*/
|
|
171
178
|
/**
|
|
172
179
|
* Make a {@link CheckRunner}: "✓ name" for a pass, "✗ name" and the error's
|
|
173
|
-
* message, indented, for a failure.
|
|
180
|
+
* message, indented, for a failure. A check that hangs (a page that never
|
|
181
|
+
* answers) fails once its time is up, and the script goes on; what it was
|
|
182
|
+
* doing isn't stopped, so `finish()` exits rather than wait for it.
|
|
174
183
|
*
|
|
184
|
+
* @param {{timeout?: number}} [options] - `timeout`: each check's limit in ms. Default {@link CHECK_MS}.
|
|
175
185
|
* @returns {CheckRunner} The runner.
|
|
176
186
|
* @example
|
|
177
187
|
* const { check, fail, finish } = checkRunner();
|
|
178
188
|
* page.on('pageerror', (error) => fail(`page error: ${error.message}`));
|
|
179
189
|
* await check('the menu opens', async () => { … });
|
|
190
|
+
* await check('a live site answers', async () => { … }, { timeout: 120_000 });
|
|
180
191
|
* finish();
|
|
181
192
|
*/
|
|
182
|
-
export declare function checkRunner(
|
|
193
|
+
export declare function checkRunner({ timeout: runnerTimeout }?: {
|
|
194
|
+
timeout?: number;
|
|
195
|
+
}): CheckRunner;
|
|
183
196
|
export type SchemePage = {
|
|
184
197
|
/**
|
|
185
198
|
* From the extension's root, e.g. `options/options.html`.
|
package/src/testing/e2e.js
CHANGED
|
@@ -12,6 +12,7 @@ import assert from 'node:assert/strict';
|
|
|
12
12
|
import { mkdtemp, rm } from 'node:fs/promises';
|
|
13
13
|
import { tmpdir } from 'node:os';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
+
import { deadline } from './time.js';
|
|
15
16
|
|
|
16
17
|
/** @import { BrowserContext, BrowserType, CDPSession, Page, Worker } from 'playwright' */
|
|
17
18
|
|
|
@@ -181,12 +182,17 @@ export async function recordIconCalls(worker) {
|
|
|
181
182
|
return () => worker.evaluate(() => /** @type {{iconCalls: unknown[]}} */ (/** @type {unknown} */ (globalThis)).iconCalls);
|
|
182
183
|
}
|
|
183
184
|
|
|
185
|
+
/** How long one check may take before it's failed, in ms: long enough for a slow real-browser check, short of a hang. */
|
|
186
|
+
export const CHECK_MS = 60_000;
|
|
187
|
+
|
|
184
188
|
/**
|
|
185
189
|
* A runner for a script of named checks: each failure is reported and the
|
|
186
190
|
* script goes on, so one run shows everything that's wrong.
|
|
187
191
|
*
|
|
188
192
|
* @typedef {object} CheckRunner
|
|
189
|
-
* @property {(name: string, fn: () => unknown) => Promise<boolean>} check
|
|
193
|
+
* @property {(name: string, fn: () => unknown, options?: {timeout?: number}) => Promise<boolean>} check
|
|
194
|
+
* Run one check, failed if it takes longer than `timeout` (default the runner's); resolves to
|
|
195
|
+
* whether it passed.
|
|
190
196
|
* @property {(name: string) => void} fail Count a failure found outside any check (a page's
|
|
191
197
|
* uncaught error, say): printed now, listed and counted at the end.
|
|
192
198
|
* @property {() => void} finish Print the tally, and exit with 1 if anything failed.
|
|
@@ -194,23 +200,29 @@ export async function recordIconCalls(worker) {
|
|
|
194
200
|
|
|
195
201
|
/**
|
|
196
202
|
* Make a {@link CheckRunner}: "✓ name" for a pass, "✗ name" and the error's
|
|
197
|
-
* message, indented, for a failure.
|
|
203
|
+
* message, indented, for a failure. A check that hangs (a page that never
|
|
204
|
+
* answers) fails once its time is up, and the script goes on; what it was
|
|
205
|
+
* doing isn't stopped, so `finish()` exits rather than wait for it.
|
|
198
206
|
*
|
|
207
|
+
* @param {{timeout?: number}} [options] - `timeout`: each check's limit in ms. Default {@link CHECK_MS}.
|
|
199
208
|
* @returns {CheckRunner} The runner.
|
|
200
209
|
* @example
|
|
201
210
|
* const { check, fail, finish } = checkRunner();
|
|
202
211
|
* page.on('pageerror', (error) => fail(`page error: ${error.message}`));
|
|
203
212
|
* await check('the menu opens', async () => { … });
|
|
213
|
+
* await check('a live site answers', async () => { … }, { timeout: 120_000 });
|
|
204
214
|
* finish();
|
|
205
215
|
*/
|
|
206
|
-
export function checkRunner() {
|
|
216
|
+
export function checkRunner({ timeout: runnerTimeout = CHECK_MS } = {}) {
|
|
207
217
|
/** @type {string[]} */
|
|
208
218
|
const failures = [];
|
|
209
219
|
let passed = 0;
|
|
210
220
|
return {
|
|
211
|
-
async check(name, fn) {
|
|
221
|
+
async check(name, fn, { timeout = runnerTimeout } = {}) {
|
|
212
222
|
try {
|
|
213
|
-
await fn()
|
|
223
|
+
await deadline(Promise.resolve().then(fn), timeout, (_, reject) => {
|
|
224
|
+
reject(new Error(`Timed out after ${timeout / 1000} s`));
|
|
225
|
+
});
|
|
214
226
|
passed++;
|
|
215
227
|
console.log(` ✓ ${name}`);
|
|
216
228
|
return true;
|
package/src/testing/index.d.ts
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
|
|
3
|
-
* end-to-end tests
|
|
4
|
-
* and
|
|
3
|
+
* end-to-end tests (time limits, a pool, a browser that passes for a
|
|
4
|
+
* person's) and a server for their pages, Chrome's install warnings and
|
|
5
|
+
* saved tab groups, and checks for a packaging test. Node only.
|
|
5
6
|
*
|
|
6
7
|
* @module testing
|
|
7
8
|
*/
|
|
8
|
-
export {
|
|
9
|
+
export { TEST_BUILDS_ENV, testBuildDir } from '../build/test-build.js';
|
|
10
|
+
export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
|
|
11
|
+
export { CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until, } from './e2e.js';
|
|
12
|
+
export { eachLimited } from './pool.js';
|
|
13
|
+
export { within } from './time.js';
|
|
9
14
|
export { serve } from './server.js';
|
|
10
15
|
export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
11
16
|
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
package/src/testing/index.js
CHANGED
|
@@ -1,12 +1,19 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
|
|
3
|
-
* end-to-end tests
|
|
4
|
-
* and
|
|
3
|
+
* end-to-end tests (time limits, a pool, a browser that passes for a
|
|
4
|
+
* person's) and a server for their pages, Chrome's install warnings and
|
|
5
|
+
* saved tab groups, and checks for a packaging test. Node only.
|
|
5
6
|
*
|
|
6
7
|
* @module testing
|
|
7
8
|
*/
|
|
8
9
|
|
|
9
|
-
export {
|
|
10
|
+
export { TEST_BUILDS_ENV, testBuildDir } from '../build/test-build.js';
|
|
11
|
+
export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
|
|
12
|
+
export {
|
|
13
|
+
CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until,
|
|
14
|
+
} from './e2e.js';
|
|
15
|
+
export { eachLimited } from './pool.js';
|
|
16
|
+
export { within } from './time.js';
|
|
10
17
|
export { serve } from './server.js';
|
|
11
18
|
export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
12
19
|
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
|
@@ -0,0 +1,23 @@
|
|
|
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.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/pool
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Run `work` on every item, at most `limit` at once, each slot taking the
|
|
9
|
+
* next item as it finishes one. Errors are `work`'s to handle: one that
|
|
10
|
+
* throws stops its slot, and the returned promise rejects once the others
|
|
11
|
+
* are done.
|
|
12
|
+
*
|
|
13
|
+
* @template T
|
|
14
|
+
* @param {readonly T[]} items - The items.
|
|
15
|
+
* @param {number} limit - At most this many at once.
|
|
16
|
+
* @param {(item: T, slot: number, index: number) => Promise<void>} work - Deals with one item;
|
|
17
|
+
* `slot` (0 to limit − 1) is the same for everything one slot does, for per-slot resources
|
|
18
|
+
* (a browser each).
|
|
19
|
+
* @returns {Promise<void>} Once every item is done.
|
|
20
|
+
* @example
|
|
21
|
+
* await eachLimited(sites, 3, async (site, slot) => check(site, browsers[slot]));
|
|
22
|
+
*/
|
|
23
|
+
export declare function eachLimited<T>(items: readonly T[], limit: number, work: (item: T, slot: number, index: number) => Promise<void>): Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
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.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/pool
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Run `work` on every item, at most `limit` at once, each slot taking the
|
|
10
|
+
* next item as it finishes one. Errors are `work`'s to handle: one that
|
|
11
|
+
* throws stops its slot, and the returned promise rejects once the others
|
|
12
|
+
* are done.
|
|
13
|
+
*
|
|
14
|
+
* @template T
|
|
15
|
+
* @param {readonly T[]} items - The items.
|
|
16
|
+
* @param {number} limit - At most this many at once.
|
|
17
|
+
* @param {(item: T, slot: number, index: number) => Promise<void>} work - Deals with one item;
|
|
18
|
+
* `slot` (0 to limit − 1) is the same for everything one slot does, for per-slot resources
|
|
19
|
+
* (a browser each).
|
|
20
|
+
* @returns {Promise<void>} Once every item is done.
|
|
21
|
+
* @example
|
|
22
|
+
* await eachLimited(sites, 3, async (site, slot) => check(site, browsers[slot]));
|
|
23
|
+
*/
|
|
24
|
+
export async function eachLimited(items, limit, work) {
|
|
25
|
+
let next = 0;
|
|
26
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async (_, slot) => {
|
|
27
|
+
while (next < items.length) {
|
|
28
|
+
const index = next;
|
|
29
|
+
next += 1;
|
|
30
|
+
await work(items[index], slot, index);
|
|
31
|
+
}
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time limits for tests that drive a real browser, where a page that stops
|
|
3
|
+
* responding would otherwise hold a run up forever.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/time
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Settle `promise` within `ms`, or settle the way `late` says. The timer is
|
|
9
|
+
* cleared as soon as either happens, so a finished run isn't kept waiting
|
|
10
|
+
* for it. The work itself isn't stopped.
|
|
11
|
+
*
|
|
12
|
+
* @template T
|
|
13
|
+
* @param {Promise<T>} promise - The work.
|
|
14
|
+
* @param {number} ms - The limit.
|
|
15
|
+
* @param {(resolve: (value: T) => void, reject: (error: Error) => void) => void} late - Runs when the
|
|
16
|
+
* limit passes first.
|
|
17
|
+
* @returns {Promise<T>} The work's outcome, or `late`'s.
|
|
18
|
+
*/
|
|
19
|
+
export declare function deadline<T>(promise: Promise<T>, ms: number, late: (resolve: (value: T) => void, reject: (error: Error) => void) => void): Promise<T>;
|
|
20
|
+
/**
|
|
21
|
+
* A promise's value within a time limit, or `fallback` when it's late or
|
|
22
|
+
* fails: for a step a live run should give up on and go past (a renderer
|
|
23
|
+
* that stopped responding, a close that never returns). The work itself
|
|
24
|
+
* isn't stopped; the timer is cleared once it settles.
|
|
25
|
+
*
|
|
26
|
+
* @template T
|
|
27
|
+
* @param {Promise<T>} promise - The work.
|
|
28
|
+
* @param {number} ms - The limit.
|
|
29
|
+
* @param {T} fallback - What to give if it's late or fails.
|
|
30
|
+
* @returns {Promise<T>} Its value, or the fallback. Never rejects.
|
|
31
|
+
* @example
|
|
32
|
+
* const list = await within(handled(worker, page), 8000, []);
|
|
33
|
+
* await within(page.close(), 15000, undefined);
|
|
34
|
+
*/
|
|
35
|
+
export declare function within<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T>;
|