@arraypress/extension-kit 1.1.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.
Files changed (48) hide show
  1. package/README.md +166 -14
  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/setup.d.ts +54 -0
  19. package/src/dom/setup.js +46 -0
  20. package/src/index.d.ts +29 -2
  21. package/src/index.js +23 -2
  22. package/src/sites/sites.d.ts +82 -0
  23. package/src/sites/sites.js +128 -0
  24. package/src/styles/chrome-ui.css +18 -7
  25. package/src/testing/browser.d.ts +52 -0
  26. package/src/testing/browser.js +79 -0
  27. package/src/testing/e2e.d.ts +18 -5
  28. package/src/testing/e2e.js +17 -5
  29. package/src/testing/index.d.ts +15 -4
  30. package/src/testing/index.js +15 -4
  31. package/src/testing/package.d.ts +62 -4
  32. package/src/testing/package.js +39 -8
  33. package/src/testing/pool.d.ts +23 -0
  34. package/src/testing/pool.js +33 -0
  35. package/src/testing/saved-groups.d.ts +33 -0
  36. package/src/testing/saved-groups.js +222 -0
  37. package/src/testing/time.d.ts +35 -0
  38. package/src/testing/time.js +46 -0
  39. package/src/testing/warnings.d.ts +57 -0
  40. package/src/testing/warnings.js +69 -0
  41. package/src/ui/menu.d.ts +257 -0
  42. package/src/ui/menu.js +283 -0
  43. package/src/ui/site-list.d.ts +199 -0
  44. package/src/ui/site-list.js +194 -0
  45. package/src/ui/text-dialog.d.ts +132 -0
  46. package/src/ui/text-dialog.js +187 -0
  47. package/src/ui/toast.d.ts +89 -0
  48. package/src/ui/toast.js +103 -0
@@ -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
+ }
@@ -136,9 +136,10 @@
136
136
  --tonal-text: rgb(4 30 73);
137
137
  --elevation-3: 0 1px 3px 0 rgb(60 64 67 / 0.3), 0 4px 8px 3px rgb(60 64 67 / 0.15);
138
138
 
139
- /* Icons for link rows (cr:open-in-new, cr:arrow-right / subpage arrow). */
139
+ /* Icons for link rows (cr:open-in-new, cr:arrow-right / subpage arrow) and ⋮ buttons (cr:more-vert). */
140
140
  --icon-open-in-new: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M19 19H5V5h7V3H5a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14c1.1 0 2-.9 2-2v-7h-2v7zM14 3v2h3.59l-9.83 9.83 1.41 1.41L19 6.41V10h2V3h-7z'/%3E%3C/svg%3E");
141
141
  --icon-arrow-right: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M10 6 8.59 7.41 13.17 12l-4.58 4.59L10 18l6-6z'/%3E%3C/svg%3E");
142
+ --icon-more-vert: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z'/%3E%3C/svg%3E");
142
143
 
143
144
  /* Sizes. */
144
145
  --radius: 8px; /* cards, fields, dialogs */
@@ -296,6 +297,9 @@ button.link:hover { background: none; text-decoration: underline; }
296
297
 
297
298
  .icon { width: 20px; height: 20px; fill: currentColor; flex: none; }
298
299
 
300
+ /* The ⋮ icon without a sprite: <span class="icon icon-more-vert" aria-hidden="true">. */
301
+ .icon-more-vert { display: inline-block; background: currentColor; mask: var(--icon-more-vert) center / contain no-repeat; }
302
+
299
303
  .sprite { position: absolute; width: 0; height: 0; overflow: hidden; }
300
304
 
301
305
  /* --------------------------------------------------------------- Fields */
@@ -617,6 +621,9 @@ label.row, .row[actionable] { cursor: pointer; }
617
621
  .list { list-style: none; margin: 0; padding: 0; }
618
622
  .list:empty { display: none; }
619
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
+
620
627
  /* About card, as chrome://settings/help (about_page.css): a 32px logo 10px from a 20px title. */
621
628
  .about-product { justify-content: flex-start; gap: 10px; }
622
629
  .product-title { font-size: 20px; }
@@ -776,11 +783,11 @@ label.row, .row[actionable] { cursor: pointer; }
776
783
 
777
784
  /* ------------------------------------------------------- Toast (cr-toast) */
778
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. */
779
788
  .toast {
780
789
  position: fixed;
781
- left: 0;
782
- bottom: 0;
783
- z-index: 2;
790
+ inset: auto auto 0 0;
784
791
  display: flex;
785
792
  align-items: center;
786
793
  gap: 32px;
@@ -792,10 +799,16 @@ label.row, .row[actionable] { cursor: pointer; }
792
799
  border-radius: 8px;
793
800
  background: var(--toast-bg);
794
801
  color: var(--toast-text);
802
+ width: auto;
803
+ height: auto;
804
+ overflow: visible;
805
+ border: 0;
795
806
  line-height: 20px;
796
807
  box-shadow: 0 2px 4px 0 rgb(0 0 0 / 0.28);
797
808
  }
798
809
 
810
+ .toast[popover]:not(:popover-open) { display: none; }
811
+
799
812
  .toast button {
800
813
  height: auto;
801
814
  margin-right: -8px;
@@ -1078,9 +1091,7 @@ button.floating .icon { width: 20px; height: 20px; }
1078
1091
 
1079
1092
  /* The toast in a panel: the panel's width less 8px each side, above the footer. */
1080
1093
  .side-panel .toast {
1081
- left: 8px;
1082
- right: 8px;
1083
- bottom: 56px;
1094
+ inset: auto 8px 56px 8px;
1084
1095
  min-width: 0;
1085
1096
  max-width: none;
1086
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
+ }
@@ -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.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,18 +1,27 @@
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, and checks for a packaging
4
- * 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';
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
- export { filesUnder, manifestReferences, missingReferences, zipEntries } from './package.js';
16
+ export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
17
+ export { savedTabGroups } from './saved-groups.js';
18
+ export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
12
19
  export type CheckRunner = import('./e2e.js').CheckRunner;
13
20
  export type ExtensionProfile = import('./e2e.js').ExtensionProfile;
14
21
  export type LaunchedExtension = import('./e2e.js').LaunchedExtension;
15
22
  export type LaunchOptions = import('./e2e.js').LaunchOptions;
23
+ export type SavedTabGroup = import('./saved-groups.js').SavedTabGroup;
24
+ export type Manifest = import('./warnings.js').Manifest;
16
25
  export type Reply = import('./server.js').Reply;
17
26
  export type Routes = import('./server.js').Routes;
18
27
  export type TestServer = import('./server.js').TestServer;
@@ -23,6 +32,8 @@ export type TestServer = import('./server.js').TestServer;
23
32
  * @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
24
33
  * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
25
34
  * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
35
+ * @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
36
+ * @typedef {import('./warnings.js').Manifest} Manifest
26
37
  * @typedef {import('./server.js').Reply} Reply
27
38
  * @typedef {import('./server.js').Routes} Routes
28
39
  * @typedef {import('./server.js').TestServer} TestServer
@@ -1,15 +1,24 @@
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, and checks for a packaging
4
- * 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
9
 
9
- export { checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
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
- export { filesUnder, manifestReferences, missingReferences, zipEntries } from './package.js';
19
+ export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
20
+ export { savedTabGroups } from './saved-groups.js';
21
+ export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
13
22
 
14
23
  /**
15
24
  * The helpers' types, for a test's JSDoc.
@@ -18,6 +27,8 @@ export { filesUnder, manifestReferences, missingReferences, zipEntries } from '.
18
27
  * @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
19
28
  * @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
20
29
  * @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
30
+ * @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
31
+ * @typedef {import('./warnings.js').Manifest} Manifest
21
32
  * @typedef {import('./server.js').Reply} Reply
22
33
  * @typedef {import('./server.js').Routes} Routes
23
34
  * @typedef {import('./server.js').TestServer} TestServer