@arraypress/extension-kit 1.2.0 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/README.md +147 -17
  2. package/bin/extension-kit.js +46 -10
  3. package/package.json +1 -1
  4. package/src/build/index.d.ts +4 -2
  5. package/src/build/index.js +6 -2
  6. package/src/build/store-frame.d.ts +176 -0
  7. package/src/build/store-frame.js +241 -0
  8. package/src/build/test-build.d.ts +85 -0
  9. package/src/build/test-build.js +82 -0
  10. package/src/build/toolbar-icons.d.ts +36 -18
  11. package/src/build/toolbar-icons.js +91 -24
  12. package/src/build/vendor.d.ts +78 -8
  13. package/src/build/vendor.js +133 -10
  14. package/src/build/zip.d.ts +3 -2
  15. package/src/build/zip.js +6 -4
  16. package/src/dom/focus.d.ts +28 -0
  17. package/src/dom/focus.js +36 -0
  18. package/src/dom/i18n.d.ts +11 -3
  19. package/src/dom/i18n.js +17 -4
  20. package/src/dom/setup.d.ts +55 -0
  21. package/src/dom/setup.js +47 -0
  22. package/src/index.d.ts +9 -1
  23. package/src/index.js +10 -1
  24. package/src/sites/sites.d.ts +114 -0
  25. package/src/sites/sites.js +169 -0
  26. package/src/styles/chrome-ui.css +13 -6
  27. package/src/testing/browser.d.ts +52 -0
  28. package/src/testing/browser.js +79 -0
  29. package/src/testing/e2e.d.ts +18 -5
  30. package/src/testing/e2e.js +17 -5
  31. package/src/testing/index.d.ts +7 -3
  32. package/src/testing/index.js +9 -3
  33. package/src/testing/pool.d.ts +23 -0
  34. package/src/testing/pool.js +33 -0
  35. package/src/time/time.d.ts +36 -0
  36. package/src/time/time.js +47 -0
  37. package/src/ui/menu.d.ts +13 -6
  38. package/src/ui/menu.js +11 -7
  39. package/src/ui/site-list.d.ts +216 -0
  40. package/src/ui/site-list.js +234 -0
  41. package/src/ui/text-dialog.js +3 -1
  42. package/src/ui/toast.d.ts +25 -9
  43. package/src/ui/toast.js +25 -11
package/src/index.js CHANGED
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * The kit's browser half: what an extension's pages, offscreen document and
3
3
  * service worker load, Chrome's page components (the ⋮ menu, the one-field
4
- * dialog, the toast with Undo) included. Every function takes the `chrome`
4
+ * dialog, the toast with Undo, a settings card's site list) and per-site
5
+ * lists' sites included. Every function takes the `chrome`
5
6
  * APIs it uses as arguments, so tests run the real logic against fakes.
6
7
  *
7
8
  * Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
@@ -13,17 +14,24 @@
13
14
 
14
15
  export { githubAboutLinks, isPlaceholder } from './about/links.js';
15
16
  export { setShortcuts } from './commands/shortcuts.js';
17
+ export { focusedElement } from './dom/focus.js';
16
18
  export { localize, translator } from './dom/i18n.js';
17
19
  export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
20
+ export { setUpPage } from './dom/setup.js';
18
21
  export { createSettingsSaver } from './settings/saver.js';
19
22
  export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
20
23
  export { createSettingsStore } from './settings/store.js';
24
+ export {
25
+ MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite,
26
+ } from './sites/sites.js';
27
+ export { within } from './time/time.js';
21
28
  export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
22
29
  export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
23
30
  export {
24
31
  OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen,
25
32
  } from './toolbar/theme.js';
26
33
  export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
34
+ export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
27
35
  export { askText, checkText } from './ui/text-dialog.js';
28
36
  export { TOAST_MS, createToast } from './ui/toast.js';
29
37
 
@@ -34,6 +42,7 @@ export { TOAST_MS, createToast } from './ui/toast.js';
34
42
  * @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
35
43
  * @typedef {import('./ui/menu.js').MenuItem} MenuItem
36
44
  * @typedef {import('./ui/menu.js').MenuPlace} MenuPlace
45
+ * @typedef {import('./ui/site-list.js').SiteList} SiteList
37
46
  * @typedef {import('./ui/text-dialog.js').TextRequest} TextRequest
38
47
  * @typedef {import('./ui/toast.js').Toast} Toast
39
48
  */
@@ -0,0 +1,114 @@
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), the match patterns for both, and a list with a site added,
7
+ * taken out or edited. Pure.
8
+ *
9
+ * A site is a web host: http or https, with a dot in its name (or
10
+ * `localhost`). Text without a scheme is read as https.
11
+ *
12
+ * @module sites/sites
13
+ */
14
+ /** The longest host name DNS allows, and so the longest site a list takes. */
15
+ export declare const MAX_HOST_LENGTH = 253;
16
+ /**
17
+ * The origin of a site: what Chrome grants access to, so `www.` and the
18
+ * scheme are kept (https unless typed otherwise); the port and path aren't.
19
+ *
20
+ * @param {unknown} input - What was typed or stored, or a page's address.
21
+ * @returns {string | null} The origin, or null when it isn't a web site.
22
+ * @example
23
+ * siteOrigin('example.com'); // 'https://example.com'
24
+ * siteOrigin('http://localhost:3000/x'); // 'http://localhost'
25
+ * siteOrigin('chrome://settings'); // null
26
+ */
27
+ export declare function siteOrigin(input: unknown): string | null;
28
+ /**
29
+ * The host a per-site list keeps: no scheme, port, path, leading `www.` or
30
+ * trailing dot, lower case. A list of hosts covers each one's subdomains
31
+ * ({@link onSite}, {@link hostPatterns}).
32
+ *
33
+ * @param {unknown} input - What was typed or stored, or a page's address.
34
+ * @returns {string | null} The host, or null when it isn't a web site.
35
+ * @example
36
+ * siteHost(' https://www.GitHub.com/a '); // 'github.com'
37
+ * siteHost('news.bbc.co.uk'); // 'news.bbc.co.uk'
38
+ * siteHost('not a site'); // null
39
+ */
40
+ export declare function siteHost(input: unknown): string | null;
41
+ /**
42
+ * Whether a host is one of the listed sites, or a subdomain of one
43
+ * ("docs.github.com" is on "github.com"; "notgithub.com" isn't).
44
+ *
45
+ * @param {string | null} host - From {@link siteHost} (null for a page that isn't a web site).
46
+ * @param {readonly string[]} sites - Hosts from {@link siteHost}.
47
+ * @returns {boolean} True when it's on one of them.
48
+ * @example
49
+ * onSite(siteHost(location.href), settings.neverSites);
50
+ */
51
+ export declare function onSite(host: string | null, sites: readonly string[]): boolean;
52
+ /**
53
+ * The match patterns for a listed host and its subdomains: to leave it out
54
+ * of a registered content script (`excludeMatches`).
55
+ *
56
+ * @param {string} host - From {@link siteHost}.
57
+ * @returns {string[]} Two match patterns.
58
+ * @example
59
+ * hostPatterns('example.com'); // ['*://example.com/*', '*://*.example.com/*']
60
+ */
61
+ export declare function hostPatterns(host: string): string[];
62
+ /**
63
+ * The host permission pattern for a site: its scheme and host, every path
64
+ * and port.
65
+ *
66
+ * @param {string} url - An origin from {@link siteOrigin}, or any web address.
67
+ * @returns {string} The pattern.
68
+ * @example
69
+ * originPattern('https://example.com'); // 'https://example.com/*'
70
+ * originPattern('https://bit.ly:8443/abc'); // 'https://bit.ly/*'
71
+ */
72
+ export declare function originPattern(url: string): string;
73
+ /**
74
+ * An origin as a settings list shows it: without `https://` (an http one
75
+ * keeps its scheme, so it reads as different).
76
+ *
77
+ * @param {string} site - An origin from {@link siteOrigin}, or a host.
78
+ * @returns {string} What to show.
79
+ * @example
80
+ * siteLabel('https://example.com'); // 'example.com'
81
+ * siteLabel('http://example.com'); // 'http://example.com'
82
+ */
83
+ export declare function siteLabel(site: string): string;
84
+ /**
85
+ * A site list with `site` added: at the end, once (a site added again moves
86
+ * there).
87
+ *
88
+ * @param {readonly string[]} sites - The list.
89
+ * @param {string} site - The site to add.
90
+ * @returns {string[]} A new list.
91
+ * @example
92
+ * withSite(['a.com', 'b.com'], 'a.com'); // ['b.com', 'a.com']
93
+ */
94
+ export declare function withSite(sites: readonly string[], site: string): string[];
95
+ /**
96
+ * A site list without `site`.
97
+ *
98
+ * @param {readonly string[]} sites - The list.
99
+ * @param {string} site - The site to take out.
100
+ * @returns {string[]} A new list.
101
+ */
102
+ export declare function withoutSite(sites: readonly string[], site: string): string[];
103
+ /**
104
+ * A site list with `site` edited to `edited`, in its place, once (an edit
105
+ * to a site already listed merges the two).
106
+ *
107
+ * @param {readonly string[]} sites - The list.
108
+ * @param {string} site - The site as it was.
109
+ * @param {string} edited - What it was edited to.
110
+ * @returns {string[]} A new list.
111
+ * @example
112
+ * withSiteReplaced(['a.com', 'b.com'], 'a.com', 'b.com'); // ['b.com']
113
+ */
114
+ export declare function withSiteReplaced(sites: readonly string[], site: string, edited: string): string[];
@@ -0,0 +1,169 @@
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), the match patterns for both, and a list with a site added,
7
+ * taken out or edited. Pure.
8
+ *
9
+ * A site is a web host: http or https, with a dot in its name (or
10
+ * `localhost`). Text without a scheme is read as https.
11
+ *
12
+ * @module sites/sites
13
+ */
14
+
15
+ /** The longest host name DNS allows, and so the longest site a list takes. */
16
+ export const MAX_HOST_LENGTH = 253;
17
+
18
+ /** A scheme at the start of what was typed; `localhost:8080` is a host and port, not one. */
19
+ const SCHEME = /^[a-z][a-z\d+.-]*:(?!\d)/i;
20
+
21
+ /**
22
+ * What was typed (or stored, or a page's address) as a web URL.
23
+ *
24
+ * @param {unknown} input - Anything.
25
+ * @returns {URL | null} The URL, or null when it isn't a web site's.
26
+ */
27
+ function webUrl(input) {
28
+ // Only text: a stray number would otherwise read as an IP address (3 → "0.0.0.3").
29
+ const text = typeof input === 'string' ? input.trim() : '';
30
+ if (!text || /\s/.test(text)) return null;
31
+ try {
32
+ const url = new URL(SCHEME.test(text) ? text : `https://${text}`);
33
+ if (url.protocol !== 'https:' && url.protocol !== 'http:') return null;
34
+ return url.hostname.includes('.') || url.hostname === 'localhost' ? url : null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ /**
41
+ * The origin of a site: what Chrome grants access to, so `www.` and the
42
+ * scheme are kept (https unless typed otherwise); the port and path aren't.
43
+ *
44
+ * @param {unknown} input - What was typed or stored, or a page's address.
45
+ * @returns {string | null} The origin, or null when it isn't a web site.
46
+ * @example
47
+ * siteOrigin('example.com'); // 'https://example.com'
48
+ * siteOrigin('http://localhost:3000/x'); // 'http://localhost'
49
+ * siteOrigin('chrome://settings'); // null
50
+ */
51
+ export function siteOrigin(input) {
52
+ const url = webUrl(input);
53
+ return url && `${url.protocol}//${url.hostname}`;
54
+ }
55
+
56
+ /**
57
+ * The host a per-site list keeps: no scheme, port, path, leading `www.` or
58
+ * trailing dot, lower case. A list of hosts covers each one's subdomains
59
+ * ({@link onSite}, {@link hostPatterns}).
60
+ *
61
+ * @param {unknown} input - What was typed or stored, or a page's address.
62
+ * @returns {string | null} The host, or null when it isn't a web site.
63
+ * @example
64
+ * siteHost(' https://www.GitHub.com/a '); // 'github.com'
65
+ * siteHost('news.bbc.co.uk'); // 'news.bbc.co.uk'
66
+ * siteHost('not a site'); // null
67
+ */
68
+ export function siteHost(input) {
69
+ const url = webUrl(input);
70
+ if (!url) return null;
71
+ const host = url.hostname.replace(/^www\./, '').replace(/\.$/, '');
72
+ return (host.includes('.') || host === 'localhost') && host.length <= MAX_HOST_LENGTH ? host : null;
73
+ }
74
+
75
+ /**
76
+ * Whether a host is one of the listed sites, or a subdomain of one
77
+ * ("docs.github.com" is on "github.com"; "notgithub.com" isn't).
78
+ *
79
+ * @param {string | null} host - From {@link siteHost} (null for a page that isn't a web site).
80
+ * @param {readonly string[]} sites - Hosts from {@link siteHost}.
81
+ * @returns {boolean} True when it's on one of them.
82
+ * @example
83
+ * onSite(siteHost(location.href), settings.neverSites);
84
+ */
85
+ export function onSite(host, sites) {
86
+ return host !== null && sites.some((site) => host === site || host.endsWith(`.${site}`));
87
+ }
88
+
89
+ /**
90
+ * The match patterns for a listed host and its subdomains: to leave it out
91
+ * of a registered content script (`excludeMatches`).
92
+ *
93
+ * @param {string} host - From {@link siteHost}.
94
+ * @returns {string[]} Two match patterns.
95
+ * @example
96
+ * hostPatterns('example.com'); // ['*://example.com/*', '*://*.example.com/*']
97
+ */
98
+ export function hostPatterns(host) {
99
+ return [`*://${host}/*`, `*://*.${host}/*`];
100
+ }
101
+
102
+ /**
103
+ * The host permission pattern for a site: its scheme and host, every path
104
+ * and port.
105
+ *
106
+ * @param {string} url - An origin from {@link siteOrigin}, or any web address.
107
+ * @returns {string} The pattern.
108
+ * @example
109
+ * originPattern('https://example.com'); // 'https://example.com/*'
110
+ * originPattern('https://bit.ly:8443/abc'); // 'https://bit.ly/*'
111
+ */
112
+ export function originPattern(url) {
113
+ const { protocol, hostname } = new URL(url);
114
+ return `${protocol}//${hostname}/*`;
115
+ }
116
+
117
+ /**
118
+ * An origin as a settings list shows it: without `https://` (an http one
119
+ * keeps its scheme, so it reads as different).
120
+ *
121
+ * @param {string} site - An origin from {@link siteOrigin}, or a host.
122
+ * @returns {string} What to show.
123
+ * @example
124
+ * siteLabel('https://example.com'); // 'example.com'
125
+ * siteLabel('http://example.com'); // 'http://example.com'
126
+ */
127
+ export function siteLabel(site) {
128
+ return site.replace(/^https:\/\//, '');
129
+ }
130
+
131
+ /**
132
+ * A site list with `site` added: at the end, once (a site added again moves
133
+ * there).
134
+ *
135
+ * @param {readonly string[]} sites - The list.
136
+ * @param {string} site - The site to add.
137
+ * @returns {string[]} A new list.
138
+ * @example
139
+ * withSite(['a.com', 'b.com'], 'a.com'); // ['b.com', 'a.com']
140
+ */
141
+ export function withSite(sites, site) {
142
+ return [...withoutSite(sites, site), site];
143
+ }
144
+
145
+ /**
146
+ * A site list without `site`.
147
+ *
148
+ * @param {readonly string[]} sites - The list.
149
+ * @param {string} site - The site to take out.
150
+ * @returns {string[]} A new list.
151
+ */
152
+ export function withoutSite(sites, site) {
153
+ return sites.filter((s) => s !== site);
154
+ }
155
+
156
+ /**
157
+ * A site list with `site` edited to `edited`, in its place, once (an edit
158
+ * to a site already listed merges the two).
159
+ *
160
+ * @param {readonly string[]} sites - The list.
161
+ * @param {string} site - The site as it was.
162
+ * @param {string} edited - What it was edited to.
163
+ * @returns {string[]} A new list.
164
+ * @example
165
+ * withSiteReplaced(['a.com', 'b.com'], 'a.com', 'b.com'); // ['b.com']
166
+ */
167
+ export function withSiteReplaced(sites, site, edited) {
168
+ return [...new Set(sites.map((s) => (s === site ? edited : s)))];
169
+ }
@@ -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
- left: 0;
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
- left: 8px;
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/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
+ }
@@ -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
- * Run one check; resolves to whether it passed.
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) => Promise<boolean>;
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 Run one check; resolves to whether it passed.
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(): 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`.
@@ -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/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 Run one check; resolves to whether it passed.
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;
@@ -1,11 +1,15 @@
1
1
  /**
2
2
  * The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
3
- * end-to-end tests and a server for their pages, Chrome's install warnings
4
- * and saved tab groups, and checks for a packaging test. Node only.
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 { checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
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';
9
13
  export { serve } from './server.js';
10
14
  export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
11
15
  export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';