@arraypress/extension-kit 1.4.0 → 1.6.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 +54 -6
- package/package.json +1 -1
- package/src/build/vendor.js +1 -1
- package/src/index.d.ts +9 -1
- package/src/index.js +6 -1
- package/src/scripting/registration.d.ts +36 -0
- package/src/scripting/registration.js +54 -0
- package/src/testing/downloads.d.ts +41 -0
- package/src/testing/downloads.js +70 -0
- package/src/testing/index.d.ts +8 -2
- package/src/testing/index.js +6 -2
- package/src/testing/live.d.ts +146 -0
- package/src/testing/live.js +151 -0
- package/src/testing/server.d.ts +23 -3
- package/src/testing/server.js +36 -6
- package/src/{testing → time}/pool.d.ts +4 -3
- package/src/{testing → time}/pool.js +4 -3
- package/src/ui/text-dialog.d.ts +107 -37
- package/src/ui/text-dialog.js +167 -72
package/README.md
CHANGED
|
@@ -10,8 +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
|
|
14
|
-
("Rename folder"), the toast with Undo and a settings card's site list, built on the
|
|
13
|
+
- **Chrome's page components**: the ⋮ action menu and its row button, the text dialog
|
|
14
|
+
("Rename folder", or several fields as "Add site search"), the toast with Undo and a settings card's site list, built on the
|
|
15
15
|
stylesheet's classes; they work in a content script's shadow root too.
|
|
16
16
|
- **Sites**: what people type into a per-site list, and whether a page is on one.
|
|
17
17
|
- **Build and test helpers** (Node): icon rendering, vendoring npm packages (with a staleness
|
|
@@ -193,6 +193,24 @@ withSite(['a.com', 'b.com'], 'a.com'); // ['b.com', 'a.com'], what a list
|
|
|
193
193
|
A site is http or https with a dot in its host (or `localhost`); text without a scheme is https;
|
|
194
194
|
`localhost:8080` is a host and port. Pure, so a content script can load `sites/sites.js` alone.
|
|
195
195
|
|
|
196
|
+
## A content script on every site
|
|
197
|
+
|
|
198
|
+
```js
|
|
199
|
+
// The content script runs everywhere only while the optional all-sites grant is held, less the
|
|
200
|
+
// "Never on" sites; one call brings the registration in line, whatever changed.
|
|
201
|
+
const sync = contentScriptSync(chrome.scripting, 'prices', async () => {
|
|
202
|
+
const settings = await store.load(chrome.storage);
|
|
203
|
+
if (!settings.everySite || !(await chrome.permissions.contains({ origins: ALL_SITES }))) return null;
|
|
204
|
+
return { js: ['content/loader.js'], matches: ALL_SITES, excludeMatches: settings.neverSites.flatMap(hostPatterns), runAt: 'document_idle' };
|
|
205
|
+
});
|
|
206
|
+
chrome.permissions.onAdded.addListener(() => sync());
|
|
207
|
+
chrome.permissions.onRemoved.addListener(() => sync()); // site access taken back on chrome://extensions
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
Registers, updates or unregisters the script `id` to match what `want()` gives at the time (null:
|
|
211
|
+
none). Calls run one after another, so a quick run of changes can't register twice or end out of
|
|
212
|
+
order; a refused change rejects that call only.
|
|
213
|
+
|
|
196
214
|
## Time limits
|
|
197
215
|
|
|
198
216
|
```js
|
|
@@ -203,6 +221,13 @@ const groups = await within(chrome.tabGroups.query({}), 2000, []); // a Chrome c
|
|
|
203
221
|
rejects; its timer cleared once the work settles (the work itself isn't stopped). In the runtime
|
|
204
222
|
half, so a service worker or page can use it; the test helpers use it too.
|
|
205
223
|
|
|
224
|
+
```js
|
|
225
|
+
await eachLimited(files, 4, async (file) => save(file)); // at most four at once
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`eachLimited(items, limit, work)`: `work(item, slot, index)` on every item, at most `limit` at once;
|
|
229
|
+
in the runtime half too (`/testing` exports it as well).
|
|
230
|
+
|
|
206
231
|
## Page components
|
|
207
232
|
|
|
208
233
|
Chrome's own components, made in the page by script (no markup to copy), styled by `chrome-ui.css`.
|
|
@@ -238,7 +263,7 @@ opens it below the button instead. `menuPosition` and `isInside` are the pure ge
|
|
|
238
263
|
`tooltip` (a `title`) and `focusable: false` (out of the tab order, for a row that takes the focus)
|
|
239
264
|
cover the rest.
|
|
240
265
|
|
|
241
|
-
###
|
|
266
|
+
### Text dialog (cr-dialog)
|
|
242
267
|
|
|
243
268
|
```js
|
|
244
269
|
const name = await askText({ title: 'Rename workspace', label: 'Name', value: workspace.name, maxLength: 200 });
|
|
@@ -254,6 +279,26 @@ disabled while the text is blank or `validate` gives a reason, which shows under
|
|
|
254
279
|
focus goes back to what had it, or to `returnTo` (an element, or a function asked at the end).
|
|
255
280
|
One `#text-dialog` per page, made on first use.
|
|
256
281
|
|
|
282
|
+
### Dialog with several fields
|
|
283
|
+
|
|
284
|
+
```js
|
|
285
|
+
const rule = await askFields({
|
|
286
|
+
title: 'Add folder rule', action: 'Add',
|
|
287
|
+
fields: [
|
|
288
|
+
{ name: 'from', label: 'Downloads from', placeholder: 'example.com or .pdf', validate: checkFrom },
|
|
289
|
+
{ name: 'folder', label: 'Folder', value: 'Receipts' },
|
|
290
|
+
],
|
|
291
|
+
});
|
|
292
|
+
if (rule) addRule(rule.from, rule.folder);
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
The same dialog as chrome://settings/searchEngines asks for a site search: a field each, in
|
|
296
|
+
order. Resolves to each field's text by its `name`, trimmed, or `null` when cancelled. The action
|
|
297
|
+
button stays disabled until every field is filled in and valid, each field's reason under it; the
|
|
298
|
+
focus starts in the first empty field. `askText` is this with one field. A one-field dialog's ids
|
|
299
|
+
are `text-dialog-input` and `text-dialog-error`; with several, `text-dialog-<name>` and
|
|
300
|
+
`text-dialog-<name>-error` (`fieldIds`), for a test to find them.
|
|
301
|
+
|
|
257
302
|
### Toast with Undo (cr-toast)
|
|
258
303
|
|
|
259
304
|
```js
|
|
@@ -346,19 +391,22 @@ except those listed, each with a reason:
|
|
|
346
391
|
|---|---|
|
|
347
392
|
| `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. |
|
|
348
393
|
| `launchExtension(chromium, dir, options)` | Load an unpacked extension into a fresh profile; `{context, worker, extensionId, url(page), profile, close()}`. |
|
|
394
|
+
| `launchDownloading(chromium, dir, options)`, `filesIn(dir)` | An extension in a browser that downloads for real: `{…launchExtension's, downloads}`, the profile's own Downloads folder (Playwright otherwise saves downloads under made-up names no extension sees); the files under a folder, downloads still running left out. |
|
|
349
395
|
| `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()}`. |
|
|
350
396
|
| `checkRunner({timeout})` | Named checks that report and go on: `check(name, fn, {timeout})`, `fail(name)` for an error found outside a check, `finish()`. A check that runs past its limit (`CHECK_MS`, 60 s) fails, and the run goes on. |
|
|
351
|
-
| `eachLimited(items, limit, work)` | Work through a list at most `limit` at once, `work(item, slot, index)
|
|
397
|
+
| `eachLimited(items, limit, work)` | Work through a list at most `limit` at once, `work(item, slot, index)` (also in the main entry). |
|
|
352
398
|
| `realisticBrowser(options)`, `chromeUserAgent(browser)` | Launch options and a user agent for a browser that doesn't say it's automated, for live checks. |
|
|
353
399
|
| `closeOnSignal(close)` | On Ctrl-C or a kill, close the run's browsers, then exit. |
|
|
354
400
|
| `testBuildDir(root, name)` | As in `/build`, for the tests that load the build. |
|
|
355
|
-
| `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()}`. |
|
|
401
|
+
| `serve(routes, {port, tls})` | Serve test pages on 127.0.0.1 from a table, a `Map` or a function of the path; `{origin, port, server, close()}`. With `tls` (`testCertificate()`, a throwaway one from `openssl`), over HTTPS: for a site Chrome reaches only over HTTPS, mapped with `--host-resolver-rules`, Chromium started with `--ignore-certificate-errors`. |
|
|
356
402
|
| `until(fn, what, timeout?)` | Poll until truthy. |
|
|
357
403
|
| `offscreenEmulator(cdp)` | Emulate light/dark inside the offscreen document. |
|
|
358
404
|
| `recordIconCalls(worker)` | Record `chrome.action.setIcon` calls Chrome accepted. |
|
|
359
405
|
| `installWarnings(worker, manifest)` | Chrome's own install warnings for a manifest (`chrome.management.getPermissionWarningsByManifest`, no permission needed), from a loaded extension's worker. |
|
|
360
406
|
| `warningsAddedBy(worker, manifest, names)` | `{warnings, without, added}`: the warnings with and without some permissions, and those only they bring (`withoutPermissions` is the pure part). |
|
|
361
407
|
| `savedTabGroups(profile)` | Chrome's saved tab groups (`{title, pinned, urls}`) read from a closed profile's `Sync Data/LevelDB`: no extension API lists them. |
|
|
408
|
+
| `parseLiveOptions(argv, env, {usage})`, `parseProxy(address)` | A live checker's command line (`--only`, `--category`, `--concurrency`, `--proxy` or `HTTPS_PROXY`, `--locale`, `--timezone`, `--out`, `--help`), and a proxy address for Playwright with its password hidden for the output. |
|
|
409
|
+
| `exitPoint(request)` | Where the run's traffic leaves for the internet (`{ip, country, where, source}`), from a keyless lookup service through the context's proxy: sites decide what to show by it. |
|
|
362
410
|
| `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`). |
|
|
363
411
|
|
|
364
412
|
## Stylesheet classes
|
|
@@ -387,7 +435,7 @@ except those listed, each with a reason:
|
|
|
387
435
|
| Manager layout | `.manager`/`.layout`, `.sidebar` with `.tree` > `.tree-item`, `.main` with `.list-card` |
|
|
388
436
|
| Menu (cr-action-menu) | `<dialog class="menu">` of `button[role="menuitem"]`, `hr` (or `createMenu`) |
|
|
389
437
|
| ⋮ icon | `<span class="icon icon-more-vert" aria-hidden="true">` (or `moreActionsButton`) |
|
|
390
|
-
| Dialog (cr-dialog) | `<dialog class="dialog"><form method="dialog">` `.dialog-title`, `.dialog-body`, `.dialog-buttons` (
|
|
438
|
+
| Dialog (cr-dialog) | `<dialog class="dialog"><form method="dialog">` `.dialog-title`, `.dialog-body`, `.dialog-buttons` (`askText`, `askFields`) |
|
|
391
439
|
| Toast (cr-toast) | `<div class="toast" popover="manual">text <button>Undo</button></div>` (or `createToast`); with `hidden` instead of `popover`, it shows over the page but not the top layer |
|
|
392
440
|
| Drawer (cr-drawer) | `<dialog class="drawer">` with `.drawer-head` and a `.nav` |
|
|
393
441
|
| Side panel page | `<body class="side-panel">`: the panel's colours, a column 100vh high |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@arraypress/extension-kit",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.6.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
|
@@ -28,7 +28,7 @@ const SOURCE = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
|
28
28
|
const KIT = '@arraypress/extension-kit';
|
|
29
29
|
|
|
30
30
|
/** What a vendored copy of the kit holds, relative to `src/`: everything an extension page or worker may load. */
|
|
31
|
-
export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'commands', 'dom', 'settings', 'sites', 'styles', 'time', 'toolbar', 'ui']);
|
|
31
|
+
export const RUNTIME_ENTRIES = Object.freeze(['index.js', 'index.d.ts', 'about', 'commands', 'dom', 'scripting', 'settings', 'sites', 'styles', 'time', 'toolbar', 'ui']);
|
|
32
32
|
|
|
33
33
|
/** The kit's stylesheet, an absolute path in the installed package. */
|
|
34
34
|
export const STYLESHEET = path.join(SOURCE, 'styles', 'chrome-ui.css');
|
package/src/index.d.ts
CHANGED
|
@@ -17,33 +17,41 @@ export { focusedElement } from './dom/focus.js';
|
|
|
17
17
|
export { localize, translator } from './dom/i18n.js';
|
|
18
18
|
export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
|
|
19
19
|
export { setUpPage } from './dom/setup.js';
|
|
20
|
+
export { contentScriptSync } from './scripting/registration.js';
|
|
20
21
|
export { createSettingsSaver } from './settings/saver.js';
|
|
21
22
|
export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
|
|
22
23
|
export { createSettingsStore } from './settings/store.js';
|
|
23
24
|
export { MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite, } from './sites/sites.js';
|
|
25
|
+
export { eachLimited } from './time/pool.js';
|
|
24
26
|
export { within } from './time/time.js';
|
|
25
27
|
export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
|
|
26
28
|
export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
|
|
27
29
|
export { OFFSCREEN_PATH, TOOLBAR_ICON_DIR, createToolbarTheme, startToolbarTheme, toolbarIcon, watchFromOffscreen, } from './toolbar/theme.js';
|
|
28
30
|
export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
|
|
29
31
|
export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
|
|
30
|
-
export { askText, checkText } from './ui/text-dialog.js';
|
|
32
|
+
export { askFields, askText, checkText, fieldIds } from './ui/text-dialog.js';
|
|
31
33
|
export { TOAST_MS, createToast } from './ui/toast.js';
|
|
34
|
+
export type ContentScript = import('./scripting/registration.js').ContentScript;
|
|
32
35
|
export type ActionMenu = import('./ui/menu.js').ActionMenu;
|
|
33
36
|
export type MenuEntry = import('./ui/menu.js').MenuEntry;
|
|
34
37
|
export type MenuItem = import('./ui/menu.js').MenuItem;
|
|
35
38
|
export type MenuPlace = import('./ui/menu.js').MenuPlace;
|
|
36
39
|
export type SiteList = import('./ui/site-list.js').SiteList;
|
|
40
|
+
export type FieldRequest = import('./ui/text-dialog.js').FieldRequest;
|
|
41
|
+
export type FieldsRequest = import('./ui/text-dialog.js').FieldsRequest;
|
|
37
42
|
export type TextRequest = import('./ui/text-dialog.js').TextRequest;
|
|
38
43
|
export type Toast = import('./ui/toast.js').Toast;
|
|
39
44
|
/**
|
|
40
45
|
* The page helpers' types, for a page's JSDoc.
|
|
41
46
|
*
|
|
47
|
+
* @typedef {import('./scripting/registration.js').ContentScript} ContentScript
|
|
42
48
|
* @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
|
|
43
49
|
* @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
|
|
44
50
|
* @typedef {import('./ui/menu.js').MenuItem} MenuItem
|
|
45
51
|
* @typedef {import('./ui/menu.js').MenuPlace} MenuPlace
|
|
46
52
|
* @typedef {import('./ui/site-list.js').SiteList} SiteList
|
|
53
|
+
* @typedef {import('./ui/text-dialog.js').FieldRequest} FieldRequest
|
|
54
|
+
* @typedef {import('./ui/text-dialog.js').FieldsRequest} FieldsRequest
|
|
47
55
|
* @typedef {import('./ui/text-dialog.js').TextRequest} TextRequest
|
|
48
56
|
* @typedef {import('./ui/toast.js').Toast} Toast
|
|
49
57
|
*/
|
package/src/index.js
CHANGED
|
@@ -18,12 +18,14 @@ export { focusedElement } from './dom/focus.js';
|
|
|
18
18
|
export { localize, translator } from './dom/i18n.js';
|
|
19
19
|
export { byId, setAboutLinks, statusFlasher, valueRow } from './dom/page.js';
|
|
20
20
|
export { setUpPage } from './dom/setup.js';
|
|
21
|
+
export { contentScriptSync } from './scripting/registration.js';
|
|
21
22
|
export { createSettingsSaver } from './settings/saver.js';
|
|
22
23
|
export { MAX_SYNC_ITEM_BYTES } from './settings/storage.js';
|
|
23
24
|
export { createSettingsStore } from './settings/store.js';
|
|
24
25
|
export {
|
|
25
26
|
MAX_HOST_LENGTH, hostPatterns, onSite, originPattern, siteHost, siteLabel, siteOrigin, withSite, withSiteReplaced, withoutSite,
|
|
26
27
|
} from './sites/sites.js';
|
|
28
|
+
export { eachLimited } from './time/pool.js';
|
|
27
29
|
export { within } from './time/time.js';
|
|
28
30
|
export { POLL_MS, watchSchemeOffscreen } from './toolbar/offscreen.js';
|
|
29
31
|
export { DARK_QUERY, TOOLBAR_DARK_KEY, TOOLBAR_SCHEME, reportColorScheme, schemeSender, watchColorScheme } from './toolbar/scheme.js';
|
|
@@ -32,17 +34,20 @@ export {
|
|
|
32
34
|
} from './toolbar/theme.js';
|
|
33
35
|
export { MENU_EDGE, createMenu, isInside, menuPosition, moreActionsButton } from './ui/menu.js';
|
|
34
36
|
export { SITE_LIST_MESSAGES, SITE_LIST_WORDS, createSiteList } from './ui/site-list.js';
|
|
35
|
-
export { askText, checkText } from './ui/text-dialog.js';
|
|
37
|
+
export { askFields, askText, checkText, fieldIds } from './ui/text-dialog.js';
|
|
36
38
|
export { TOAST_MS, createToast } from './ui/toast.js';
|
|
37
39
|
|
|
38
40
|
/**
|
|
39
41
|
* The page helpers' types, for a page's JSDoc.
|
|
40
42
|
*
|
|
43
|
+
* @typedef {import('./scripting/registration.js').ContentScript} ContentScript
|
|
41
44
|
* @typedef {import('./ui/menu.js').ActionMenu} ActionMenu
|
|
42
45
|
* @typedef {import('./ui/menu.js').MenuEntry} MenuEntry
|
|
43
46
|
* @typedef {import('./ui/menu.js').MenuItem} MenuItem
|
|
44
47
|
* @typedef {import('./ui/menu.js').MenuPlace} MenuPlace
|
|
45
48
|
* @typedef {import('./ui/site-list.js').SiteList} SiteList
|
|
49
|
+
* @typedef {import('./ui/text-dialog.js').FieldRequest} FieldRequest
|
|
50
|
+
* @typedef {import('./ui/text-dialog.js').FieldsRequest} FieldsRequest
|
|
46
51
|
* @typedef {import('./ui/text-dialog.js').TextRequest} TextRequest
|
|
47
52
|
* @typedef {import('./ui/toast.js').Toast} Toast
|
|
48
53
|
*/
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One registered content script kept in step with what may run: registered,
|
|
3
|
+
* updated or unregistered to match, each change after the last, so a quick
|
|
4
|
+
* run of setting and permission changes can't register and unregister out
|
|
5
|
+
* of order. For an extension whose content script runs on every site only
|
|
6
|
+
* while an optional all-sites grant is held, less a "Never on" list.
|
|
7
|
+
*
|
|
8
|
+
* @module scripting/registration
|
|
9
|
+
*/
|
|
10
|
+
export type ContentScript = Omit<chrome.scripting.RegisteredContentScript, 'id' | 'js'> & {
|
|
11
|
+
js: string[];
|
|
12
|
+
};
|
|
13
|
+
export type Scripting = Pick<typeof chrome.scripting, 'getRegisteredContentScripts' | 'registerContentScripts' | 'updateContentScripts' | 'unregisterContentScripts'>;
|
|
14
|
+
/** @typedef {Omit<chrome.scripting.RegisteredContentScript, 'id' | 'js'> & {js: string[]}} ContentScript The script, but its id: its `js`, `matches` and the rest. */
|
|
15
|
+
/**
|
|
16
|
+
* The parts of `chrome.scripting` a registration uses.
|
|
17
|
+
*
|
|
18
|
+
* @typedef {Pick<typeof chrome.scripting, 'getRegisteredContentScripts' | 'registerContentScripts' | 'updateContentScripts' | 'unregisterContentScripts'>} Scripting
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Keep the content script `id` registered as `want()` says: the script, or
|
|
22
|
+
* null for none. Each call asks `want()` afresh once the change before it is
|
|
23
|
+
* done, so the last call always wins.
|
|
24
|
+
*
|
|
25
|
+
* @param {Scripting} scripting - `chrome.scripting` (or a fake in tests).
|
|
26
|
+
* @param {string} id - The registered script's id.
|
|
27
|
+
* @param {() => Promise<ContentScript | null>} want - What should be registered now (settings and
|
|
28
|
+
* grants read at the time).
|
|
29
|
+
* @returns {() => Promise<void>} Makes the registration match; rejects if Chrome refuses.
|
|
30
|
+
* @example
|
|
31
|
+
* const sync = contentScriptSync(chrome.scripting, 'prices', async () => (await chrome.permissions.contains({ origins: ALL_SITES })
|
|
32
|
+
* ? { js: ['content/loader.js'], matches: ALL_SITES, excludeMatches: neverSites.flatMap(hostPatterns), runAt: 'document_idle' }
|
|
33
|
+
* : null));
|
|
34
|
+
* chrome.permissions.onAdded.addListener(() => sync());
|
|
35
|
+
*/
|
|
36
|
+
export declare function contentScriptSync(scripting: Scripting, id: string, want: () => Promise<ContentScript | null>): () => Promise<void>;
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One registered content script kept in step with what may run: registered,
|
|
3
|
+
* updated or unregistered to match, each change after the last, so a quick
|
|
4
|
+
* run of setting and permission changes can't register and unregister out
|
|
5
|
+
* of order. For an extension whose content script runs on every site only
|
|
6
|
+
* while an optional all-sites grant is held, less a "Never on" list.
|
|
7
|
+
*
|
|
8
|
+
* @module scripting/registration
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** @typedef {Omit<chrome.scripting.RegisteredContentScript, 'id' | 'js'> & {js: string[]}} ContentScript The script, but its id: its `js`, `matches` and the rest. */
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The parts of `chrome.scripting` a registration uses.
|
|
15
|
+
*
|
|
16
|
+
* @typedef {Pick<typeof chrome.scripting, 'getRegisteredContentScripts' | 'registerContentScripts' | 'updateContentScripts' | 'unregisterContentScripts'>} Scripting
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Keep the content script `id` registered as `want()` says: the script, or
|
|
21
|
+
* null for none. Each call asks `want()` afresh once the change before it is
|
|
22
|
+
* done, so the last call always wins.
|
|
23
|
+
*
|
|
24
|
+
* @param {Scripting} scripting - `chrome.scripting` (or a fake in tests).
|
|
25
|
+
* @param {string} id - The registered script's id.
|
|
26
|
+
* @param {() => Promise<ContentScript | null>} want - What should be registered now (settings and
|
|
27
|
+
* grants read at the time).
|
|
28
|
+
* @returns {() => Promise<void>} Makes the registration match; rejects if Chrome refuses.
|
|
29
|
+
* @example
|
|
30
|
+
* const sync = contentScriptSync(chrome.scripting, 'prices', async () => (await chrome.permissions.contains({ origins: ALL_SITES })
|
|
31
|
+
* ? { js: ['content/loader.js'], matches: ALL_SITES, excludeMatches: neverSites.flatMap(hostPatterns), runAt: 'document_idle' }
|
|
32
|
+
* : null));
|
|
33
|
+
* chrome.permissions.onAdded.addListener(() => sync());
|
|
34
|
+
*/
|
|
35
|
+
export function contentScriptSync(scripting, id, want) {
|
|
36
|
+
/** The last change, so the next waits for it. */
|
|
37
|
+
let queue = Promise.resolve();
|
|
38
|
+
const apply = async () => {
|
|
39
|
+
const script = await want();
|
|
40
|
+
const registered = (await scripting.getRegisteredContentScripts({ ids: [id] })).length > 0;
|
|
41
|
+
if (!script) {
|
|
42
|
+
if (registered) await scripting.unregisterContentScripts({ ids: [id] });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const full = { ...script, id };
|
|
46
|
+
if (registered) await scripting.updateContentScripts([full]);
|
|
47
|
+
else await scripting.registerContentScripts([full]);
|
|
48
|
+
};
|
|
49
|
+
return () => {
|
|
50
|
+
const run = queue.then(apply, apply);
|
|
51
|
+
queue = run.catch(() => {});
|
|
52
|
+
return run;
|
|
53
|
+
};
|
|
54
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A browser that downloads for real, for an extension that names, files or
|
|
3
|
+
* makes downloads. Playwright normally takes downloads over (CDP's
|
|
4
|
+
* `Browser.setDownloadBehavior` set to `allowAndName`), saving them under
|
|
5
|
+
* made-up names where no extension sees them: `downloads.onDeterminingFilename`
|
|
6
|
+
* never fires, and `chrome.downloads.download`'s names are lost. So the
|
|
7
|
+
* profile gets its own Downloads folder (Chrome's `download.default_directory`
|
|
8
|
+
* preference, written before the first start) and the behaviour goes back to
|
|
9
|
+
* Chrome's default: files land on disk as on a person's computer.
|
|
10
|
+
*
|
|
11
|
+
* @module testing/downloads
|
|
12
|
+
*/
|
|
13
|
+
import type { BrowserType } from 'playwright';
|
|
14
|
+
import type { LaunchOptions, LaunchedExtension } from './e2e.js';
|
|
15
|
+
/** @import { BrowserType } from 'playwright' */
|
|
16
|
+
/** @import { LaunchOptions, LaunchedExtension } from './e2e.js' */
|
|
17
|
+
/**
|
|
18
|
+
* Every file under a folder, as paths from it (Chrome's `.crdownload` files
|
|
19
|
+
* of downloads still running left out), sorted.
|
|
20
|
+
*
|
|
21
|
+
* @param {string} dir - The folder.
|
|
22
|
+
* @returns {Promise<string[]>} The files.
|
|
23
|
+
* @example
|
|
24
|
+
* await filesIn(downloads); // ['Trips/Lake.jpg', 'Trips/Lake 2.jpg']
|
|
25
|
+
*/
|
|
26
|
+
export declare function filesIn(dir: string): Promise<string[]>;
|
|
27
|
+
/**
|
|
28
|
+
* Load the unpacked extension at `dir` into a fresh profile whose downloads
|
|
29
|
+
* go to a folder of their own, really.
|
|
30
|
+
*
|
|
31
|
+
* @param {BrowserType} chromium - Playwright's `chromium`.
|
|
32
|
+
* @param {string} dir - The unpacked extension.
|
|
33
|
+
* @param {LaunchOptions} [options] - As `launchExtension`'s.
|
|
34
|
+
* @returns {Promise<LaunchedExtension & {downloads: string}>} The running extension, and its
|
|
35
|
+
* Downloads folder; `close()` deletes both.
|
|
36
|
+
* @example
|
|
37
|
+
* const { context, downloads, close } = await launchDownloading(chromium, 'extension');
|
|
38
|
+
*/
|
|
39
|
+
export declare function launchDownloading(chromium: BrowserType, dir: string, options?: LaunchOptions): Promise<LaunchedExtension & {
|
|
40
|
+
downloads: string;
|
|
41
|
+
}>;
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A browser that downloads for real, for an extension that names, files or
|
|
3
|
+
* makes downloads. Playwright normally takes downloads over (CDP's
|
|
4
|
+
* `Browser.setDownloadBehavior` set to `allowAndName`), saving them under
|
|
5
|
+
* made-up names where no extension sees them: `downloads.onDeterminingFilename`
|
|
6
|
+
* never fires, and `chrome.downloads.download`'s names are lost. So the
|
|
7
|
+
* profile gets its own Downloads folder (Chrome's `download.default_directory`
|
|
8
|
+
* preference, written before the first start) and the behaviour goes back to
|
|
9
|
+
* Chrome's default: files land on disk as on a person's computer.
|
|
10
|
+
*
|
|
11
|
+
* @module testing/downloads
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdir, readdir, writeFile } from 'node:fs/promises';
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { extensionProfile } from './e2e.js';
|
|
17
|
+
|
|
18
|
+
/** @import { BrowserType } from 'playwright' */
|
|
19
|
+
/** @import { LaunchOptions, LaunchedExtension } from './e2e.js' */
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every file under a folder, as paths from it (Chrome's `.crdownload` files
|
|
23
|
+
* of downloads still running left out), sorted.
|
|
24
|
+
*
|
|
25
|
+
* @param {string} dir - The folder.
|
|
26
|
+
* @returns {Promise<string[]>} The files.
|
|
27
|
+
* @example
|
|
28
|
+
* await filesIn(downloads); // ['Trips/Lake.jpg', 'Trips/Lake 2.jpg']
|
|
29
|
+
*/
|
|
30
|
+
export async function filesIn(dir) {
|
|
31
|
+
const entries = await readdir(dir, { recursive: true, withFileTypes: true });
|
|
32
|
+
return entries.filter((entry) => entry.isFile() && !entry.name.endsWith('.crdownload'))
|
|
33
|
+
.map((entry) => path.relative(dir, path.join(entry.parentPath, entry.name))).sort();
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Load the unpacked extension at `dir` into a fresh profile whose downloads
|
|
38
|
+
* go to a folder of their own, really.
|
|
39
|
+
*
|
|
40
|
+
* @param {BrowserType} chromium - Playwright's `chromium`.
|
|
41
|
+
* @param {string} dir - The unpacked extension.
|
|
42
|
+
* @param {LaunchOptions} [options] - As `launchExtension`'s.
|
|
43
|
+
* @returns {Promise<LaunchedExtension & {downloads: string}>} The running extension, and its
|
|
44
|
+
* Downloads folder; `close()` deletes both.
|
|
45
|
+
* @example
|
|
46
|
+
* const { context, downloads, close } = await launchDownloading(chromium, 'extension');
|
|
47
|
+
*/
|
|
48
|
+
export async function launchDownloading(chromium, dir, options = {}) {
|
|
49
|
+
const profile = await extensionProfile(chromium, dir, options);
|
|
50
|
+
const downloads = path.join(path.dirname(profile.dir), 'Downloads');
|
|
51
|
+
await mkdir(path.join(profile.dir, 'Default'), { recursive: true });
|
|
52
|
+
await mkdir(downloads, { recursive: true });
|
|
53
|
+
await writeFile(path.join(profile.dir, 'Default', 'Preferences'), JSON.stringify({
|
|
54
|
+
download: { default_directory: downloads, prompt_for_download: false, directory_upgrade: true },
|
|
55
|
+
}));
|
|
56
|
+
const launched = await profile.launch();
|
|
57
|
+
const page = await launched.context.newPage();
|
|
58
|
+
const cdp = await launched.context.newCDPSession(page);
|
|
59
|
+
await cdp.send('Browser.setDownloadBehavior', { behavior: 'default' });
|
|
60
|
+
await cdp.detach();
|
|
61
|
+
await page.close();
|
|
62
|
+
return {
|
|
63
|
+
...launched,
|
|
64
|
+
downloads,
|
|
65
|
+
async close() {
|
|
66
|
+
await launched.close();
|
|
67
|
+
await profile.remove();
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/testing/index.d.ts
CHANGED
|
@@ -9,8 +9,10 @@
|
|
|
9
9
|
export { TEST_BUILDS_ENV, testBuildDir } from '../build/test-build.js';
|
|
10
10
|
export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
|
|
11
11
|
export { CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until, } from './e2e.js';
|
|
12
|
-
export {
|
|
13
|
-
export {
|
|
12
|
+
export { exitPoint, parseLiveOptions, parseProxy } from './live.js';
|
|
13
|
+
export { eachLimited } from '../time/pool.js';
|
|
14
|
+
export { filesIn, launchDownloading } from './downloads.js';
|
|
15
|
+
export { serve, testCertificate } from './server.js';
|
|
14
16
|
export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
15
17
|
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
|
16
18
|
export { savedTabGroups } from './saved-groups.js';
|
|
@@ -19,6 +21,8 @@ export type CheckRunner = import('./e2e.js').CheckRunner;
|
|
|
19
21
|
export type ExtensionProfile = import('./e2e.js').ExtensionProfile;
|
|
20
22
|
export type LaunchedExtension = import('./e2e.js').LaunchedExtension;
|
|
21
23
|
export type LaunchOptions = import('./e2e.js').LaunchOptions;
|
|
24
|
+
export type Exit = import('./live.js').Exit;
|
|
25
|
+
export type LiveOptions = import('./live.js').LiveOptions;
|
|
22
26
|
export type SavedTabGroup = import('./saved-groups.js').SavedTabGroup;
|
|
23
27
|
export type Manifest = import('./warnings.js').Manifest;
|
|
24
28
|
export type Reply = import('./server.js').Reply;
|
|
@@ -31,6 +35,8 @@ export type TestServer = import('./server.js').TestServer;
|
|
|
31
35
|
* @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
|
|
32
36
|
* @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
|
|
33
37
|
* @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
|
|
38
|
+
* @typedef {import('./live.js').Exit} Exit
|
|
39
|
+
* @typedef {import('./live.js').LiveOptions} LiveOptions
|
|
34
40
|
* @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
|
|
35
41
|
* @typedef {import('./warnings.js').Manifest} Manifest
|
|
36
42
|
* @typedef {import('./server.js').Reply} Reply
|
package/src/testing/index.js
CHANGED
|
@@ -12,8 +12,10 @@ export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
|
|
|
12
12
|
export {
|
|
13
13
|
CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, until,
|
|
14
14
|
} from './e2e.js';
|
|
15
|
-
export {
|
|
16
|
-
export {
|
|
15
|
+
export { exitPoint, parseLiveOptions, parseProxy } from './live.js';
|
|
16
|
+
export { eachLimited } from '../time/pool.js';
|
|
17
|
+
export { filesIn, launchDownloading } from './downloads.js';
|
|
18
|
+
export { serve, testCertificate } from './server.js';
|
|
17
19
|
export { SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
18
20
|
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
|
19
21
|
export { savedTabGroups } from './saved-groups.js';
|
|
@@ -26,6 +28,8 @@ export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings
|
|
|
26
28
|
* @typedef {import('./e2e.js').ExtensionProfile} ExtensionProfile
|
|
27
29
|
* @typedef {import('./e2e.js').LaunchedExtension} LaunchedExtension
|
|
28
30
|
* @typedef {import('./e2e.js').LaunchOptions} LaunchOptions
|
|
31
|
+
* @typedef {import('./live.js').Exit} Exit
|
|
32
|
+
* @typedef {import('./live.js').LiveOptions} LiveOptions
|
|
29
33
|
* @typedef {import('./saved-groups.js').SavedTabGroup} SavedTabGroup
|
|
30
34
|
* @typedef {import('./warnings.js').Manifest} Manifest
|
|
31
35
|
* @typedef {import('./server.js').Reply} Reply
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a live checker (a run over real sites, never in CI) shares: its
|
|
3
|
+
* command line (which sites, how many at once, a proxy, how the browser
|
|
4
|
+
* looks) and where its traffic leaves for the internet, which decides what
|
|
5
|
+
* many sites show (cookie banners, prices, currency).
|
|
6
|
+
*
|
|
7
|
+
* @module testing/live
|
|
8
|
+
*/
|
|
9
|
+
import type { APIRequestContext } from 'playwright';
|
|
10
|
+
export type Proxy = {
|
|
11
|
+
/**
|
|
12
|
+
* e.g. "http://uk.example.net:8080" or "socks5://127.0.0.1:1080".
|
|
13
|
+
*/
|
|
14
|
+
server: string;
|
|
15
|
+
/**
|
|
16
|
+
* From the address, if it has one.
|
|
17
|
+
*/
|
|
18
|
+
username?: string;
|
|
19
|
+
/**
|
|
20
|
+
* From the address, if it has one.
|
|
21
|
+
*/
|
|
22
|
+
password?: string;
|
|
23
|
+
};
|
|
24
|
+
export type LiveOptions = {
|
|
25
|
+
/**
|
|
26
|
+
* Only sites whose address or name contains one of these (any case).
|
|
27
|
+
*/
|
|
28
|
+
only: string[];
|
|
29
|
+
/**
|
|
30
|
+
* Only these categories (by id).
|
|
31
|
+
*/
|
|
32
|
+
categories: string[];
|
|
33
|
+
/**
|
|
34
|
+
* Sites checked at once.
|
|
35
|
+
*/
|
|
36
|
+
concurrency: number;
|
|
37
|
+
/**
|
|
38
|
+
* The proxy every request goes through.
|
|
39
|
+
*/
|
|
40
|
+
proxy: Proxy | null;
|
|
41
|
+
/**
|
|
42
|
+
* The proxy's address without its password, for the output.
|
|
43
|
+
*/
|
|
44
|
+
proxyLabel: string;
|
|
45
|
+
/**
|
|
46
|
+
* The browser's language (navigator.language, Accept-Language).
|
|
47
|
+
*/
|
|
48
|
+
locale: string;
|
|
49
|
+
/**
|
|
50
|
+
* The browser's time zone.
|
|
51
|
+
*/
|
|
52
|
+
timezone: string;
|
|
53
|
+
/**
|
|
54
|
+
* Where the report goes ('' for the checker's default).
|
|
55
|
+
*/
|
|
56
|
+
out: string;
|
|
57
|
+
/**
|
|
58
|
+
* Show the usage and stop.
|
|
59
|
+
*/
|
|
60
|
+
help: boolean;
|
|
61
|
+
};
|
|
62
|
+
/** @import { APIRequestContext } from 'playwright' */
|
|
63
|
+
/**
|
|
64
|
+
* A proxy, as Playwright takes it.
|
|
65
|
+
*
|
|
66
|
+
* @typedef {object} Proxy
|
|
67
|
+
* @property {string} server e.g. "http://uk.example.net:8080" or "socks5://127.0.0.1:1080".
|
|
68
|
+
* @property {string} [username] From the address, if it has one.
|
|
69
|
+
* @property {string} [password] From the address, if it has one.
|
|
70
|
+
*/
|
|
71
|
+
/**
|
|
72
|
+
* A live checker's options.
|
|
73
|
+
*
|
|
74
|
+
* @typedef {object} LiveOptions
|
|
75
|
+
* @property {string[]} only Only sites whose address or name contains one of these (any case).
|
|
76
|
+
* @property {string[]} categories Only these categories (by id).
|
|
77
|
+
* @property {number} concurrency Sites checked at once.
|
|
78
|
+
* @property {Proxy | null} proxy The proxy every request goes through.
|
|
79
|
+
* @property {string} proxyLabel The proxy's address without its password, for the output.
|
|
80
|
+
* @property {string} locale The browser's language (navigator.language, Accept-Language).
|
|
81
|
+
* @property {string} timezone The browser's time zone.
|
|
82
|
+
* @property {string} out Where the report goes ('' for the checker's default).
|
|
83
|
+
* @property {boolean} help Show the usage and stop.
|
|
84
|
+
*/
|
|
85
|
+
/**
|
|
86
|
+
* Read a proxy address.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} address - e.g. "http://user:pass@host:3128".
|
|
89
|
+
* @returns {{ proxy: Proxy, label: string }} For Playwright, and for the output (no password).
|
|
90
|
+
* @throws {Error} When it isn't an address.
|
|
91
|
+
* @example
|
|
92
|
+
* parseProxy('http://me:secret@uk.example:8080').label; // 'http://me:•••@uk.example:8080'
|
|
93
|
+
*/
|
|
94
|
+
export declare function parseProxy(address: string): {
|
|
95
|
+
proxy: Proxy;
|
|
96
|
+
label: string;
|
|
97
|
+
};
|
|
98
|
+
/**
|
|
99
|
+
* Read a live checker's command line: `--only <word>` (repeatable, or bare
|
|
100
|
+
* words), `--category <id>` (repeatable), `--concurrency <n>`, `--proxy <url>`
|
|
101
|
+
* (default `HTTPS_PROXY`), `--locale <tag>`, `--timezone <zone>`, `--out <dir>`,
|
|
102
|
+
* `--help`. A value may follow an `=` ("--only=amazon"); commas list several.
|
|
103
|
+
*
|
|
104
|
+
* @param {readonly string[]} argv - The arguments after the script.
|
|
105
|
+
* @param {Readonly<Record<string, string | undefined>>} env - The environment (HTTPS_PROXY, https_proxy).
|
|
106
|
+
* @param {{ usage: string, concurrency?: number, maxConcurrency?: number, locale?: string, timezone?: string }} defaults - The
|
|
107
|
+
* usage text (shown with an unknown option), and the defaults: 3 at once (at most 8), en-GB, Europe/London.
|
|
108
|
+
* @returns {LiveOptions} The options.
|
|
109
|
+
* @throws {Error} On an unknown option or a bad value.
|
|
110
|
+
* @example
|
|
111
|
+
* parseLiveOptions(['--category', 'shops', 'amazon'], {}, { usage }).only; // ['amazon']
|
|
112
|
+
*/
|
|
113
|
+
export declare function parseLiveOptions(argv: readonly string[], env: Readonly<Record<string, string | undefined>>, { usage, concurrency, maxConcurrency, locale, timezone }: {
|
|
114
|
+
usage: string;
|
|
115
|
+
concurrency?: number;
|
|
116
|
+
maxConcurrency?: number;
|
|
117
|
+
locale?: string;
|
|
118
|
+
timezone?: string;
|
|
119
|
+
}): LiveOptions;
|
|
120
|
+
export type Exit = {
|
|
121
|
+
/**
|
|
122
|
+
* The public address.
|
|
123
|
+
*/
|
|
124
|
+
ip: string;
|
|
125
|
+
/**
|
|
126
|
+
* Its ISO country code ("GB", "TH").
|
|
127
|
+
*/
|
|
128
|
+
country: string;
|
|
129
|
+
/**
|
|
130
|
+
* Readable: city and organisation, where the service says.
|
|
131
|
+
*/
|
|
132
|
+
where: string;
|
|
133
|
+
/**
|
|
134
|
+
* The service that answered.
|
|
135
|
+
*/
|
|
136
|
+
source: string;
|
|
137
|
+
};
|
|
138
|
+
/**
|
|
139
|
+
* Where the browser's traffic leaves for the internet: its public IP's
|
|
140
|
+
* country, asked once through the same proxy the browser uses. Sites go by
|
|
141
|
+
* that address, not the browser's language or time zone.
|
|
142
|
+
*
|
|
143
|
+
* @param {APIRequestContext} request - A browser context's `request` (it uses the context's proxy).
|
|
144
|
+
* @returns {Promise<Exit | null>} null when no service answered.
|
|
145
|
+
*/
|
|
146
|
+
export declare function exitPoint(request: APIRequestContext): Promise<Exit | null>;
|