@arraypress/extension-kit 1.0.0 → 1.2.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 +78 -6
- package/package.json +1 -1
- package/src/build/vendor.js +1 -1
- package/src/index.d.ts +22 -2
- package/src/index.js +17 -2
- package/src/styles/chrome-ui.css +5 -1
- package/src/testing/e2e.d.ts +64 -7
- package/src/testing/e2e.js +75 -20
- package/src/testing/index.d.ts +29 -3
- package/src/testing/index.js +21 -3
- package/src/testing/package.d.ts +62 -4
- package/src/testing/package.js +39 -8
- package/src/testing/saved-groups.d.ts +33 -0
- package/src/testing/saved-groups.js +222 -0
- package/src/testing/server.d.ts +47 -0
- package/src/testing/server.js +90 -0
- package/src/testing/warnings.d.ts +57 -0
- package/src/testing/warnings.js +69 -0
- package/src/ui/menu.d.ts +250 -0
- package/src/ui/menu.js +279 -0
- package/src/ui/text-dialog.d.ts +132 -0
- package/src/ui/text-dialog.js +185 -0
- package/src/ui/toast.d.ts +73 -0
- package/src/ui/toast.js +89 -0
package/README.md
CHANGED
|
@@ -10,6 +10,8 @@ The pieces ArrayPress's Chrome extensions share, so each is written once:
|
|
|
10
10
|
service worker shows the matching icon.
|
|
11
11
|
- **About links**: README, Issues and PRIVACY.md in the extension's GitHub repository, plus
|
|
12
12
|
package-time warnings for placeholder links.
|
|
13
|
+
- **Chrome's page components**: the ⋮ action menu and its row button, the one-field dialog
|
|
14
|
+
("Rename folder"), and the toast with Undo, built on the stylesheet's classes.
|
|
13
15
|
- **Build and test helpers** (Node): toolbar icon rendering, zipping for the Chrome Web Store, a
|
|
14
16
|
fake `chrome.storage`, Playwright helpers, packaging checks, and a copied-code guard.
|
|
15
17
|
|
|
@@ -132,6 +134,68 @@ At the end of `npm run package`, `printReleaseChecks(ABOUT_LINKS, REPOSITORY)` w
|
|
|
132
134
|
link is a placeholder (`isPlaceholder`: example.com/.org/.net) and reminds you the repository must
|
|
133
135
|
be public before the store listing goes live.
|
|
134
136
|
|
|
137
|
+
## Page components
|
|
138
|
+
|
|
139
|
+
Chrome's own components, made in the page by script (no markup to copy), styled by `chrome-ui.css`.
|
|
140
|
+
Each takes `doc` (default `document`); the wording defaults to Chrome's English and takes a
|
|
141
|
+
translation.
|
|
142
|
+
|
|
143
|
+
### ⋮ menu (cr-action-menu)
|
|
144
|
+
|
|
145
|
+
```js
|
|
146
|
+
import { createMenu, moreActionsButton } from '../vendor/extension-kit/index.js';
|
|
147
|
+
|
|
148
|
+
const menu = createMenu(); // one <dialog class="menu" role="menu"> for the page
|
|
149
|
+
row.append(moreActionsButton(workspace.name, {
|
|
150
|
+
onClick: (anchor) => menu.open([
|
|
151
|
+
{ label: 'Rename', run: () => rename(workspace) },
|
|
152
|
+
'separator',
|
|
153
|
+
{ label: 'Delete', run: () => remove(workspace), disabled: locked },
|
|
154
|
+
], { anchor }),
|
|
155
|
+
}));
|
|
156
|
+
// A right-click: menu.open(items, { x: event.clientX, y: event.clientY, focusTo: row });
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
It opens modally over its button, as Chrome's does (its top at the button's top, its end at the
|
|
160
|
+
button's end), moved to stay 8px inside the window. A click outside, Esc, Tab or the window losing
|
|
161
|
+
focus closes it; ↑/↓ (wrapping, past disabled items), Home and End move; focus goes back to the
|
|
162
|
+
button (or `focusTo`); an item runs once the menu has closed. `createMenu({id, placement: 'below'})`
|
|
163
|
+
opens it below the button instead. `menuPosition` and `isInside` are the pure geometry.
|
|
164
|
+
|
|
165
|
+
`moreActionsButton(name, options)` is the 32px ⋮ icon button, labelled "More actions for “name”"
|
|
166
|
+
(`label` for a translation), with `aria-haspopup="menu"`; its icon is the stylesheet's
|
|
167
|
+
`.icon-more-vert` (`--icon-more-vert`, cr:more-vert), so the page needs no sprite. `className`,
|
|
168
|
+
`tooltip` (a `title`) and `focusable: false` (out of the tab order, for a row that takes the focus)
|
|
169
|
+
cover the rest.
|
|
170
|
+
|
|
171
|
+
### One-field dialog (cr-dialog)
|
|
172
|
+
|
|
173
|
+
```js
|
|
174
|
+
const name = await askText({ title: 'Rename workspace', label: 'Name', value: workspace.name, maxLength: 200 });
|
|
175
|
+
const site = await askText({
|
|
176
|
+
title: 'Add site', label: 'Site', action: 'Add',
|
|
177
|
+
validate: (text) => (siteFromInput(text) ? null : 'Enter a site, like example.com'),
|
|
178
|
+
});
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Resolves to the text, trimmed, or `null` when cancelled. The action button (default "Save") stays
|
|
182
|
+
disabled while the text is blank or `validate` gives a reason, which shows under the field
|
|
183
|
+
(`checkText` is that rule, pure). Enter presses the action button; Esc and Cancel cancel; the
|
|
184
|
+
focus goes back to what had it, or to `returnTo` (an element, or a function asked at the end).
|
|
185
|
+
One `#text-dialog` per page, made on first use.
|
|
186
|
+
|
|
187
|
+
### Toast with Undo (cr-toast)
|
|
188
|
+
|
|
189
|
+
```js
|
|
190
|
+
const toast = createToast(); // at load, so screen readers know the live region
|
|
191
|
+
toast.show(`Deleted “${workspace.name}”`, () => restore(workspace));
|
|
192
|
+
toast.show('Copied'); // no Undo
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
`#toast` (`role="status"`), bottom left. A new message replaces the last and restarts the wait:
|
|
196
|
+
10 seconds, as chrome://bookmarks's (`TOAST_MS`), or `duration` / `undoDuration`. Undo hides it
|
|
197
|
+
and runs once. `undoLabel` translates the button.
|
|
198
|
+
|
|
135
199
|
## Build helpers (`@arraypress/extension-kit/build`)
|
|
136
200
|
|
|
137
201
|
| Export | What it does |
|
|
@@ -157,11 +221,17 @@ except those listed, each with a reason:
|
|
|
157
221
|
| Export | What it does |
|
|
158
222
|
|---|---|
|
|
159
223
|
| `fakeChromeStorage({local, sync, session, now})` | In-memory `chrome.storage`: JSON copies, `onChanged` for real changes only, sync's 8,192-byte item quota and 120 writes a minute. `settled()` waits for listeners. |
|
|
160
|
-
| `launchExtension(chromium, dir, options)` | Load an unpacked extension into a fresh profile; `{context, worker, extensionId, url(page), close()}`. |
|
|
224
|
+
| `launchExtension(chromium, dir, options)` | Load an unpacked extension into a fresh profile; `{context, worker, extensionId, url(page), profile, close()}`. |
|
|
225
|
+
| `extensionProfile(chromium, dir, options)` | A profile to start the browser on more than once (a restart, a first start with a changed manifest): `{dir, launch(), remove()}`. |
|
|
226
|
+
| `checkRunner()` | Named checks that report and go on: `check(name, fn)`, `fail(name)` for an error found outside a check, `finish()`. |
|
|
227
|
+
| `serve(routes, {port})` | Serve test pages on 127.0.0.1 from a table, a `Map` or a function of the path; `{origin, port, server, close()}`. |
|
|
161
228
|
| `until(fn, what, timeout?)` | Poll until truthy. |
|
|
162
229
|
| `offscreenEmulator(cdp)` | Emulate light/dark inside the offscreen document. |
|
|
163
230
|
| `recordIconCalls(worker)` | Record `chrome.action.setIcon` calls Chrome accepted. |
|
|
164
|
-
| `
|
|
231
|
+
| `installWarnings(worker, manifest)` | Chrome's own install warnings for a manifest (`chrome.management.getPermissionWarningsByManifest`, no permission needed), from a loaded extension's worker. |
|
|
232
|
+
| `warningsAddedBy(worker, manifest, names)` | `{warnings, without, added}`: the warnings with and without some permissions, and those only they bring (`withoutPermissions` is the pure part). |
|
|
233
|
+
| `savedTabGroups(profile)` | Chrome's saved tab groups (`{title, pinned, urls}`) read from a closed profile's `Sync Data/LevelDB`: no extension API lists them. |
|
|
234
|
+
| `filesUnder`, `zipEntries`, `missingReferences`, `manifestReferences` | For a packaging test: the zip is the folder, and every path the extension names resolves (a `#fragment` or `?query` ignored: `referencedPath`). |
|
|
165
235
|
|
|
166
236
|
## Stylesheet classes
|
|
167
237
|
|
|
@@ -186,9 +256,10 @@ except those listed, each with a reason:
|
|
|
186
256
|
| Buttons (cr-button) | plain `<button>`; `.action` filled; `.link` as a link; `.icon-button` 32px round |
|
|
187
257
|
| Settings menu | `.nav` > `ul.nav-list` > `a.nav-item[aria-current="page"]`, `.nav-separator` |
|
|
188
258
|
| Manager layout | `.manager`/`.layout`, `.sidebar` with `.tree` > `.tree-item`, `.main` with `.list-card` |
|
|
189
|
-
| Menu (cr-action-menu) | `<dialog class="menu">` of `button[role="menuitem"]`, `hr` |
|
|
190
|
-
|
|
|
191
|
-
|
|
|
259
|
+
| Menu (cr-action-menu) | `<dialog class="menu">` of `button[role="menuitem"]`, `hr` (or `createMenu`) |
|
|
260
|
+
| ⋮ icon | `<span class="icon icon-more-vert" aria-hidden="true">` (or `moreActionsButton`) |
|
|
261
|
+
| Dialog (cr-dialog) | `<dialog class="dialog"><form method="dialog">` `.dialog-title`, `.dialog-body`, `.dialog-buttons` (one field: `askText`) |
|
|
262
|
+
| Toast (cr-toast) | `<div class="toast">text <button>Undo</button></div>` (or `createToast`) |
|
|
192
263
|
| Drawer (cr-drawer) | `<dialog class="drawer">` with `.drawer-head` and a `.nav` |
|
|
193
264
|
| Side panel page | `<body class="side-panel">`: the panel's colours, a column 100vh high |
|
|
194
265
|
| Panel tabs (cr-tabs) | `<div class="tabs" role="tablist">` of `button.tab[role="tab"][aria-selected]` |
|
|
@@ -206,7 +277,8 @@ chrome://bookmarks in Chromium 156 / Chrome 157; the sources are listed at the t
|
|
|
206
277
|
|
|
207
278
|
## Development
|
|
208
279
|
|
|
209
|
-
`npm test` runs ESLint, a strict TypeScript check of the JSDoc (`checkJs`), the tests
|
|
280
|
+
`npm test` runs ESLint, a strict TypeScript check of the JSDoc (`checkJs`), the tests (the menu,
|
|
281
|
+
the dialog and the Chrome readers in Playwright's Chromium, the rest in Node and jsdom), and the
|
|
210
282
|
copied-code guard. The `.d.ts` files are generated from the JSDoc: `npm run types` writes them, and
|
|
211
283
|
the type check fails while one is stale (it regenerates them into `.types/` and compares).
|
|
212
284
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arraypress/extension-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.2.0",
|
|
4
4
|
"description": "Shared pieces for Chrome extensions: Chrome's own page styles, a Chrome Sync settings store, the light/dark toolbar glyph, About links, and build and test helpers.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
package/src/build/vendor.js
CHANGED
|
@@ -19,7 +19,7 @@ import path from 'node:path';
|
|
|
19
19
|
const SOURCE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
20
20
|
|
|
21
21
|
/** What a vendored copy holds, relative to `src/`: everything an extension page or worker may load. */
|
|
22
|
-
export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'commands', 'dom', 'settings', 'styles', 'toolbar']);
|
|
22
|
+
export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'commands', 'dom', 'settings', 'styles', 'toolbar', 'ui']);
|
|
23
23
|
|
|
24
24
|
/** The kit's stylesheet, an absolute path in the installed package. */
|
|
25
25
|
export const STYLESHEET = path.join(SOURCE, 'styles', 'chrome-ui.css');
|
package/src/index.d.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The kit's browser half: what an extension's pages, offscreen document and
|
|
3
|
-
* service worker load
|
|
4
|
-
*
|
|
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`
|
|
5
|
+
* APIs it uses as arguments, so tests run the real logic against fakes.
|
|
5
6
|
*
|
|
6
7
|
* Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
|
|
7
8
|
* in `@arraypress/extension-kit/testing`, and the stylesheet is
|
|
@@ -19,3 +20,22 @@ export { createSettingsStore } from './settings/store.js';
|
|
|
19
20
|
export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
|
|
20
21
|
export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
|
|
21
22
|
export { OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen, } from './toolbar/theme.js';
|
|
23
|
+
export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
|
|
24
|
+
export { askText, checkText } from './ui/text-dialog.js';
|
|
25
|
+
export { TOAST_MS, createToast } from './ui/toast.js';
|
|
26
|
+
export type ActionMenu = import('./ui/menu.js').ActionMenu;
|
|
27
|
+
export type MenuEntry = import('./ui/menu.js').MenuEntry;
|
|
28
|
+
export type MenuItem = import('./ui/menu.js').MenuItem;
|
|
29
|
+
export type MenuPlace = import('./ui/menu.js').MenuPlace;
|
|
30
|
+
export type TextRequest = import('./ui/text-dialog.js').TextRequest;
|
|
31
|
+
export type Toast = import('./ui/toast.js').Toast;
|
|
32
|
+
/**
|
|
33
|
+
* The page helpers' types, for a page's JSDoc.
|
|
34
|
+
*
|
|
35
|
+
* @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
|
|
36
|
+
* @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
|
|
37
|
+
* @typedef {import('./ui/menu.js').MenuItem} MenuItem
|
|
38
|
+
* @typedef {import('./ui/menu.js').MenuPlace} MenuPlace
|
|
39
|
+
* @typedef {import('./ui/text-dialog.js').TextRequest} TextRequest
|
|
40
|
+
* @typedef {import('./ui/toast.js').Toast} Toast
|
|
41
|
+
*/
|
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
|
-
* service worker load
|
|
4
|
-
*
|
|
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`
|
|
5
|
+
* APIs it uses as arguments, so tests run the real logic against fakes.
|
|
5
6
|
*
|
|
6
7
|
* Build-time helpers are in `@arraypress/extension-kit/build`, test helpers
|
|
7
8
|
* in `@arraypress/extension-kit/testing`, and the stylesheet is
|
|
@@ -22,3 +23,17 @@ export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, scheme
|
|
|
22
23
|
export {
|
|
23
24
|
OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen,
|
|
24
25
|
} from './toolbar/theme.js';
|
|
26
|
+
export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
|
|
27
|
+
export { askText, checkText } from './ui/text-dialog.js';
|
|
28
|
+
export { TOAST_MS, createToast } from './ui/toast.js';
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The page helpers' types, for a page's JSDoc.
|
|
32
|
+
*
|
|
33
|
+
* @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
|
|
34
|
+
* @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
|
|
35
|
+
* @typedef {import('./ui/menu.js').MenuItem} MenuItem
|
|
36
|
+
* @typedef {import('./ui/menu.js').MenuPlace} MenuPlace
|
|
37
|
+
* @typedef {import('./ui/text-dialog.js').TextRequest} TextRequest
|
|
38
|
+
* @typedef {import('./ui/toast.js').Toast} Toast
|
|
39
|
+
*/
|
package/src/styles/chrome-ui.css
CHANGED
|
@@ -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 */
|
package/src/testing/e2e.d.ts
CHANGED
|
@@ -26,10 +26,28 @@ export type LaunchedExtension = {
|
|
|
26
26
|
*/
|
|
27
27
|
url: (page?: string) => string;
|
|
28
28
|
/**
|
|
29
|
-
*
|
|
29
|
+
* The profile's folder (Chrome writes `DevToolsActivePort` there, for one).
|
|
30
|
+
*/
|
|
31
|
+
profile: string;
|
|
32
|
+
/**
|
|
33
|
+
* Close the browser; from {@link launchExtension}, delete its profile too.
|
|
30
34
|
*/
|
|
31
35
|
close: () => Promise<void>;
|
|
32
36
|
};
|
|
37
|
+
export type ExtensionProfile = {
|
|
38
|
+
/**
|
|
39
|
+
* The profile's folder.
|
|
40
|
+
*/
|
|
41
|
+
dir: string;
|
|
42
|
+
/**
|
|
43
|
+
* Start the browser on it; its `close()` keeps the profile.
|
|
44
|
+
*/
|
|
45
|
+
launch: () => Promise<LaunchedExtension>;
|
|
46
|
+
/**
|
|
47
|
+
* Delete the profile (close every launch first).
|
|
48
|
+
*/
|
|
49
|
+
remove: () => Promise<void>;
|
|
50
|
+
};
|
|
33
51
|
export type LaunchOptions = NonNullable<Parameters<BrowserType['launchPersistentContext']>[1]> & {
|
|
34
52
|
prefix?: string;
|
|
35
53
|
};
|
|
@@ -42,7 +60,17 @@ export type LaunchOptions = NonNullable<Parameters<BrowserType['launchPersistent
|
|
|
42
60
|
* @property {Worker} worker Its service worker.
|
|
43
61
|
* @property {string} extensionId Its id, the host of its pages.
|
|
44
62
|
* @property {(page?: string) => string} url `chrome-extension://<id>/<page>`.
|
|
45
|
-
* @property {
|
|
63
|
+
* @property {string} profile The profile's folder (Chrome writes `DevToolsActivePort` there, for one).
|
|
64
|
+
* @property {() => Promise<void>} close Close the browser; from {@link launchExtension}, delete its profile too.
|
|
65
|
+
*/
|
|
66
|
+
/**
|
|
67
|
+
* A profile that outlives one browser start, for checks that something
|
|
68
|
+
* survives a restart (or that need a first start with a changed manifest).
|
|
69
|
+
*
|
|
70
|
+
* @typedef {object} ExtensionProfile
|
|
71
|
+
* @property {string} dir The profile's folder.
|
|
72
|
+
* @property {() => Promise<LaunchedExtension>} launch Start the browser on it; its `close()` keeps the profile.
|
|
73
|
+
* @property {() => Promise<void>} remove Delete the profile (close every launch first).
|
|
46
74
|
*/
|
|
47
75
|
/**
|
|
48
76
|
* What `launchExtension` takes: Playwright's persistent-context options, a
|
|
@@ -57,10 +85,31 @@ export type LaunchOptions = NonNullable<Parameters<BrowserType['launchPersistent
|
|
|
57
85
|
* @param {() => T | Promise<T>} fn - The check.
|
|
58
86
|
* @param {string} what - What's awaited, for the timeout's message.
|
|
59
87
|
* @param {number} [timeout] - Give up after this many ms. Default 5000.
|
|
60
|
-
* @returns {Promise<
|
|
88
|
+
* @returns {Promise<Exclude<T, false | 0 | 0n | '' | null | undefined>>} The first truthy value
|
|
89
|
+
* (typed without the falsy ones, so `until(() => ready && text, …)` is a string).
|
|
61
90
|
* @throws {Error} "Timed out waiting for <what>".
|
|
62
91
|
*/
|
|
63
|
-
export declare function until<T>(fn: () => T | Promise<T>, what: string, timeout?: number): Promise<
|
|
92
|
+
export declare function until<T>(fn: () => T | Promise<T>, what: string, timeout?: number): Promise<Exclude<T, false | 0 | 0n | '' | null | undefined>>;
|
|
93
|
+
/**
|
|
94
|
+
* A fresh profile for the unpacked extension at `dir`, to start the browser
|
|
95
|
+
* on as often as a test needs: each start loads the extension and waits for
|
|
96
|
+
* its service worker.
|
|
97
|
+
*
|
|
98
|
+
* @param {BrowserType} chromium - Playwright's `chromium`.
|
|
99
|
+
* @param {string} dir - The unpacked extension.
|
|
100
|
+
* @param {LaunchOptions} [options] - Playwright's context options (`locale`, `viewport`, …), plus
|
|
101
|
+
* `prefix` (the temporary profile's name prefix) and `args` (more Chromium switches).
|
|
102
|
+
* @returns {Promise<ExtensionProfile>} The profile.
|
|
103
|
+
* @example
|
|
104
|
+
* const profile = await extensionProfile(chromium, 'extension');
|
|
105
|
+
* const first = await profile.launch();
|
|
106
|
+
* await first.worker.evaluate(() => chrome.storage.local.set({seen: true}));
|
|
107
|
+
* await first.close();
|
|
108
|
+
* const { worker, close } = await profile.launch(); // the same storage, read as on any day
|
|
109
|
+
* await close();
|
|
110
|
+
* await profile.remove();
|
|
111
|
+
*/
|
|
112
|
+
export declare function extensionProfile(chromium: BrowserType, dir: string, { prefix, args, ...options }?: LaunchOptions): Promise<ExtensionProfile>;
|
|
64
113
|
/**
|
|
65
114
|
* Load the unpacked extension at `dir` into a fresh profile and wait for its
|
|
66
115
|
* service worker.
|
|
@@ -69,13 +118,13 @@ export declare function until<T>(fn: () => T | Promise<T>, what: string, timeout
|
|
|
69
118
|
* @param {string} dir - The unpacked extension.
|
|
70
119
|
* @param {LaunchOptions} [options] - Playwright's context options (`locale`, `viewport`, …), plus
|
|
71
120
|
* `prefix` (the temporary profile's name prefix) and `args` (more Chromium switches).
|
|
72
|
-
* @returns {Promise<LaunchedExtension>} The running extension.
|
|
121
|
+
* @returns {Promise<LaunchedExtension>} The running extension; `close()` deletes the profile too.
|
|
73
122
|
* @example
|
|
74
123
|
* const { context, worker, url, close } = await launchExtension(chromium, 'extension');
|
|
75
124
|
* const page = await context.newPage();
|
|
76
125
|
* await page.goto(url('options/options.html'));
|
|
77
126
|
*/
|
|
78
|
-
export declare function launchExtension(chromium: BrowserType, dir: string,
|
|
127
|
+
export declare function launchExtension(chromium: BrowserType, dir: string, options?: LaunchOptions): Promise<LaunchedExtension>;
|
|
79
128
|
/**
|
|
80
129
|
* Emulate colour schemes in the offscreen document: one CDP session attached
|
|
81
130
|
* to it, reused for every change.
|
|
@@ -99,6 +148,11 @@ export type CheckRunner = {
|
|
|
99
148
|
* Run one check; resolves to whether it passed.
|
|
100
149
|
*/
|
|
101
150
|
check: (name: string, fn: () => unknown) => Promise<boolean>;
|
|
151
|
+
/**
|
|
152
|
+
* Count a failure found outside any check (a page's
|
|
153
|
+
* uncaught error, say): printed now, listed and counted at the end.
|
|
154
|
+
*/
|
|
155
|
+
fail: (name: string) => void;
|
|
102
156
|
/**
|
|
103
157
|
* Print the tally, and exit with 1 if anything failed.
|
|
104
158
|
*/
|
|
@@ -110,6 +164,8 @@ export type CheckRunner = {
|
|
|
110
164
|
*
|
|
111
165
|
* @typedef {object} CheckRunner
|
|
112
166
|
* @property {(name: string, fn: () => unknown) => Promise<boolean>} check Run one check; resolves to whether it passed.
|
|
167
|
+
* @property {(name: string) => void} fail Count a failure found outside any check (a page's
|
|
168
|
+
* uncaught error, say): printed now, listed and counted at the end.
|
|
113
169
|
* @property {() => void} finish Print the tally, and exit with 1 if anything failed.
|
|
114
170
|
*/
|
|
115
171
|
/**
|
|
@@ -118,7 +174,8 @@ export type CheckRunner = {
|
|
|
118
174
|
*
|
|
119
175
|
* @returns {CheckRunner} The runner.
|
|
120
176
|
* @example
|
|
121
|
-
* const { check, finish } = checkRunner();
|
|
177
|
+
* const { check, fail, finish } = checkRunner();
|
|
178
|
+
* page.on('pageerror', (error) => fail(`page error: ${error.message}`));
|
|
122
179
|
* await check('the menu opens', async () => { … });
|
|
123
180
|
* finish();
|
|
124
181
|
*/
|
package/src/testing/e2e.js
CHANGED
|
@@ -23,7 +23,18 @@ import path from 'node:path';
|
|
|
23
23
|
* @property {Worker} worker Its service worker.
|
|
24
24
|
* @property {string} extensionId Its id, the host of its pages.
|
|
25
25
|
* @property {(page?: string) => string} url `chrome-extension://<id>/<page>`.
|
|
26
|
-
* @property {
|
|
26
|
+
* @property {string} profile The profile's folder (Chrome writes `DevToolsActivePort` there, for one).
|
|
27
|
+
* @property {() => Promise<void>} close Close the browser; from {@link launchExtension}, delete its profile too.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A profile that outlives one browser start, for checks that something
|
|
32
|
+
* survives a restart (or that need a first start with a changed manifest).
|
|
33
|
+
*
|
|
34
|
+
* @typedef {object} ExtensionProfile
|
|
35
|
+
* @property {string} dir The profile's folder.
|
|
36
|
+
* @property {() => Promise<LaunchedExtension>} launch Start the browser on it; its `close()` keeps the profile.
|
|
37
|
+
* @property {() => Promise<void>} remove Delete the profile (close every launch first).
|
|
27
38
|
*/
|
|
28
39
|
|
|
29
40
|
/**
|
|
@@ -40,19 +51,65 @@ import path from 'node:path';
|
|
|
40
51
|
* @param {() => T | Promise<T>} fn - The check.
|
|
41
52
|
* @param {string} what - What's awaited, for the timeout's message.
|
|
42
53
|
* @param {number} [timeout] - Give up after this many ms. Default 5000.
|
|
43
|
-
* @returns {Promise<
|
|
54
|
+
* @returns {Promise<Exclude<T, false | 0 | 0n | '' | null | undefined>>} The first truthy value
|
|
55
|
+
* (typed without the falsy ones, so `until(() => ready && text, …)` is a string).
|
|
44
56
|
* @throws {Error} "Timed out waiting for <what>".
|
|
45
57
|
*/
|
|
46
58
|
export async function until(fn, what, timeout = 5000) {
|
|
47
59
|
const end = Date.now() + timeout;
|
|
48
60
|
for (;;) {
|
|
49
61
|
const value = await fn();
|
|
50
|
-
if (value) return /** @type {
|
|
62
|
+
if (value) return /** @type {Exclude<T, false | 0 | 0n | '' | null | undefined>} */ (value);
|
|
51
63
|
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
|
|
52
64
|
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
53
65
|
}
|
|
54
66
|
}
|
|
55
67
|
|
|
68
|
+
/**
|
|
69
|
+
* A fresh profile for the unpacked extension at `dir`, to start the browser
|
|
70
|
+
* on as often as a test needs: each start loads the extension and waits for
|
|
71
|
+
* its service worker.
|
|
72
|
+
*
|
|
73
|
+
* @param {BrowserType} chromium - Playwright's `chromium`.
|
|
74
|
+
* @param {string} dir - The unpacked extension.
|
|
75
|
+
* @param {LaunchOptions} [options] - Playwright's context options (`locale`, `viewport`, …), plus
|
|
76
|
+
* `prefix` (the temporary profile's name prefix) and `args` (more Chromium switches).
|
|
77
|
+
* @returns {Promise<ExtensionProfile>} The profile.
|
|
78
|
+
* @example
|
|
79
|
+
* const profile = await extensionProfile(chromium, 'extension');
|
|
80
|
+
* const first = await profile.launch();
|
|
81
|
+
* await first.worker.evaluate(() => chrome.storage.local.set({seen: true}));
|
|
82
|
+
* await first.close();
|
|
83
|
+
* const { worker, close } = await profile.launch(); // the same storage, read as on any day
|
|
84
|
+
* await close();
|
|
85
|
+
* await profile.remove();
|
|
86
|
+
*/
|
|
87
|
+
export async function extensionProfile(chromium, dir, { prefix = 'extension-', args = [], ...options } = {}) {
|
|
88
|
+
const tmp = await mkdtemp(path.join(tmpdir(), prefix));
|
|
89
|
+
const profile = path.join(tmp, 'profile');
|
|
90
|
+
return {
|
|
91
|
+
dir: profile,
|
|
92
|
+
async launch() {
|
|
93
|
+
const context = await chromium.launchPersistentContext(profile, {
|
|
94
|
+
channel: 'chromium',
|
|
95
|
+
...options,
|
|
96
|
+
args: [`--disable-extensions-except=${dir}`, `--load-extension=${dir}`, ...args],
|
|
97
|
+
});
|
|
98
|
+
const worker = context.serviceWorkers()[0] ?? await context.waitForEvent('serviceworker');
|
|
99
|
+
const extensionId = new URL(worker.url()).host;
|
|
100
|
+
return {
|
|
101
|
+
context,
|
|
102
|
+
worker,
|
|
103
|
+
extensionId,
|
|
104
|
+
profile,
|
|
105
|
+
url: (page = '') => `chrome-extension://${extensionId}/${page}`,
|
|
106
|
+
close: () => context.close(),
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
remove: () => rm(tmp, { recursive: true, force: true }),
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
56
113
|
/**
|
|
57
114
|
* Load the unpacked extension at `dir` into a fresh profile and wait for its
|
|
58
115
|
* service worker.
|
|
@@ -61,29 +118,20 @@ export async function until(fn, what, timeout = 5000) {
|
|
|
61
118
|
* @param {string} dir - The unpacked extension.
|
|
62
119
|
* @param {LaunchOptions} [options] - Playwright's context options (`locale`, `viewport`, …), plus
|
|
63
120
|
* `prefix` (the temporary profile's name prefix) and `args` (more Chromium switches).
|
|
64
|
-
* @returns {Promise<LaunchedExtension>} The running extension.
|
|
121
|
+
* @returns {Promise<LaunchedExtension>} The running extension; `close()` deletes the profile too.
|
|
65
122
|
* @example
|
|
66
123
|
* const { context, worker, url, close } = await launchExtension(chromium, 'extension');
|
|
67
124
|
* const page = await context.newPage();
|
|
68
125
|
* await page.goto(url('options/options.html'));
|
|
69
126
|
*/
|
|
70
|
-
export async function launchExtension(chromium, dir,
|
|
71
|
-
const
|
|
72
|
-
const
|
|
73
|
-
channel: 'chromium',
|
|
74
|
-
...options,
|
|
75
|
-
args: [`--disable-extensions-except=${dir}`, `--load-extension=${dir}`, ...args],
|
|
76
|
-
});
|
|
77
|
-
const worker = context.serviceWorkers()[0] ?? await context.waitForEvent('serviceworker');
|
|
78
|
-
const extensionId = new URL(worker.url()).host;
|
|
127
|
+
export async function launchExtension(chromium, dir, options = {}) {
|
|
128
|
+
const profile = await extensionProfile(chromium, dir, options);
|
|
129
|
+
const launched = await profile.launch();
|
|
79
130
|
return {
|
|
80
|
-
|
|
81
|
-
worker,
|
|
82
|
-
extensionId,
|
|
83
|
-
url: (page = '') => `chrome-extension://${extensionId}/${page}`,
|
|
131
|
+
...launched,
|
|
84
132
|
async close() {
|
|
85
|
-
await
|
|
86
|
-
await
|
|
133
|
+
await launched.close();
|
|
134
|
+
await profile.remove();
|
|
87
135
|
},
|
|
88
136
|
};
|
|
89
137
|
}
|
|
@@ -139,6 +187,8 @@ export async function recordIconCalls(worker) {
|
|
|
139
187
|
*
|
|
140
188
|
* @typedef {object} CheckRunner
|
|
141
189
|
* @property {(name: string, fn: () => unknown) => Promise<boolean>} check Run one check; resolves to whether it passed.
|
|
190
|
+
* @property {(name: string) => void} fail Count a failure found outside any check (a page's
|
|
191
|
+
* uncaught error, say): printed now, listed and counted at the end.
|
|
142
192
|
* @property {() => void} finish Print the tally, and exit with 1 if anything failed.
|
|
143
193
|
*/
|
|
144
194
|
|
|
@@ -148,7 +198,8 @@ export async function recordIconCalls(worker) {
|
|
|
148
198
|
*
|
|
149
199
|
* @returns {CheckRunner} The runner.
|
|
150
200
|
* @example
|
|
151
|
-
* const { check, finish } = checkRunner();
|
|
201
|
+
* const { check, fail, finish } = checkRunner();
|
|
202
|
+
* page.on('pageerror', (error) => fail(`page error: ${error.message}`));
|
|
152
203
|
* await check('the menu opens', async () => { … });
|
|
153
204
|
* finish();
|
|
154
205
|
*/
|
|
@@ -169,6 +220,10 @@ export function checkRunner() {
|
|
|
169
220
|
return false;
|
|
170
221
|
}
|
|
171
222
|
},
|
|
223
|
+
fail(name) {
|
|
224
|
+
failures.push(name);
|
|
225
|
+
console.log(` ✗ ${name}`);
|
|
226
|
+
},
|
|
172
227
|
finish() {
|
|
173
228
|
console.log(`\n${passed} passed, ${failures.length} failed`);
|
|
174
229
|
if (!failures.length) return;
|
package/src/testing/index.d.ts
CHANGED
|
@@ -1,9 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
|
|
3
|
-
* end-to-end tests
|
|
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.
|
|
4
5
|
*
|
|
5
6
|
* @module testing
|
|
6
7
|
*/
|
|
7
|
-
export { checkRunner, checkToolbarTheme, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
|
|
8
|
+
export { checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
|
|
9
|
+
export { serve } from './server.js';
|
|
8
10
|
export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
9
|
-
export { filesUnder, manifestReferences, missingReferences, zipEntries } from './package.js';
|
|
11
|
+
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
|
12
|
+
export { savedTabGroups } from './saved-groups.js';
|
|
13
|
+
export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
|
|
14
|
+
export type CheckRunner = import('./e2e.js').CheckRunner;
|
|
15
|
+
export type ExtensionProfile = import('./e2e.js').ExtensionProfile;
|
|
16
|
+
export type LaunchedExtension = import('./e2e.js').LaunchedExtension;
|
|
17
|
+
export type LaunchOptions = import('./e2e.js').LaunchOptions;
|
|
18
|
+
export type SavedTabGroup = import('./saved-groups.js').SavedTabGroup;
|
|
19
|
+
export type Manifest = import('./warnings.js').Manifest;
|
|
20
|
+
export type Reply = import('./server.js').Reply;
|
|
21
|
+
export type Routes = import('./server.js').Routes;
|
|
22
|
+
export type TestServer = import('./server.js').TestServer;
|
|
23
|
+
/**
|
|
24
|
+
* The helpers' types, for a test's JSDoc.
|
|
25
|
+
*
|
|
26
|
+
* @typedef {import('./e2e.js').CheckRunner} CheckRunner
|
|
27
|
+
* @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
|
|
28
|
+
* @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
|
|
29
|
+
* @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
|
|
30
|
+
* @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
|
|
31
|
+
* @typedef {import('./warnings.js').Manifest} Manifest
|
|
32
|
+
* @typedef {import('./server.js').Reply} Reply
|
|
33
|
+
* @typedef {import('./server.js').Routes} Routes
|
|
34
|
+
* @typedef {import('./server.js').TestServer} TestServer
|
|
35
|
+
*/
|
package/src/testing/index.js
CHANGED
|
@@ -1,10 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The kit's test helpers: a fake `chrome.storage`, Playwright helpers for
|
|
3
|
-
* end-to-end tests
|
|
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.
|
|
4
5
|
*
|
|
5
6
|
* @module testing
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
|
-
export { checkRunner, checkToolbarTheme, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
|
|
9
|
+
export { checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until } from './e2e.js';
|
|
10
|
+
export { serve } from './server.js';
|
|
9
11
|
export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
10
|
-
export { filesUnder, manifestReferences, missingReferences, zipEntries } from './package.js';
|
|
12
|
+
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
|
13
|
+
export { savedTabGroups } from './saved-groups.js';
|
|
14
|
+
export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The helpers' types, for a test's JSDoc.
|
|
18
|
+
*
|
|
19
|
+
* @typedef {import('./e2e.js').CheckRunner} CheckRunner
|
|
20
|
+
* @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
|
|
21
|
+
* @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
|
|
22
|
+
* @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
|
|
23
|
+
* @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
|
|
24
|
+
* @typedef {import('./warnings.js').Manifest} Manifest
|
|
25
|
+
* @typedef {import('./server.js').Reply} Reply
|
|
26
|
+
* @typedef {import('./server.js').Routes} Routes
|
|
27
|
+
* @typedef {import('./server.js').TestServer} TestServer
|
|
28
|
+
*/
|