@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.
- package/README.md +166 -14
- package/bin/extension-kit.js +46 -10
- package/package.json +1 -1
- package/src/build/index.d.ts +4 -2
- package/src/build/index.js +6 -2
- package/src/build/store-frame.d.ts +176 -0
- package/src/build/store-frame.js +241 -0
- package/src/build/test-build.d.ts +85 -0
- package/src/build/test-build.js +82 -0
- package/src/build/toolbar-icons.d.ts +36 -18
- package/src/build/toolbar-icons.js +91 -24
- package/src/build/vendor.d.ts +78 -8
- package/src/build/vendor.js +133 -10
- package/src/build/zip.d.ts +3 -2
- package/src/build/zip.js +6 -4
- package/src/dom/focus.d.ts +28 -0
- package/src/dom/focus.js +36 -0
- package/src/dom/setup.d.ts +54 -0
- package/src/dom/setup.js +46 -0
- package/src/index.d.ts +29 -2
- package/src/index.js +23 -2
- package/src/sites/sites.d.ts +82 -0
- package/src/sites/sites.js +128 -0
- package/src/styles/chrome-ui.css +18 -7
- package/src/testing/browser.d.ts +52 -0
- package/src/testing/browser.js +79 -0
- package/src/testing/e2e.d.ts +18 -5
- package/src/testing/e2e.js +17 -5
- package/src/testing/index.d.ts +15 -4
- package/src/testing/index.js +15 -4
- package/src/testing/package.d.ts +62 -4
- package/src/testing/package.js +39 -8
- package/src/testing/pool.d.ts +23 -0
- package/src/testing/pool.js +33 -0
- package/src/testing/saved-groups.d.ts +33 -0
- package/src/testing/saved-groups.js +222 -0
- package/src/testing/time.d.ts +35 -0
- package/src/testing/time.js +46 -0
- package/src/testing/warnings.d.ts +57 -0
- package/src/testing/warnings.js +69 -0
- package/src/ui/menu.d.ts +257 -0
- package/src/ui/menu.js +283 -0
- package/src/ui/site-list.d.ts +199 -0
- package/src/ui/site-list.js +194 -0
- package/src/ui/text-dialog.d.ts +132 -0
- package/src/ui/text-dialog.js +187 -0
- package/src/ui/toast.d.ts +89 -0
- package/src/ui/toast.js +103 -0
package/src/testing/package.d.ts
CHANGED
|
@@ -20,17 +20,75 @@ export declare function filesUnder(dir: string): Promise<string[]>;
|
|
|
20
20
|
* @returns {Promise<string[]>} Entry names, sorted.
|
|
21
21
|
*/
|
|
22
22
|
export declare function zipEntries(from: string, exclude?: readonly string[]): Promise<string[]>;
|
|
23
|
+
export type ManifestFiles = {
|
|
24
|
+
/**
|
|
25
|
+
* The worker.
|
|
26
|
+
*/
|
|
27
|
+
background?: {
|
|
28
|
+
service_worker?: string;
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* The options page.
|
|
32
|
+
*/
|
|
33
|
+
options_ui?: {
|
|
34
|
+
page?: string;
|
|
35
|
+
};
|
|
36
|
+
/**
|
|
37
|
+
* The popup and toolbar icon.
|
|
38
|
+
*/
|
|
39
|
+
action?: {
|
|
40
|
+
default_popup?: string;
|
|
41
|
+
default_icon?: string | Record<string, string>;
|
|
42
|
+
};
|
|
43
|
+
/**
|
|
44
|
+
* The side panel.
|
|
45
|
+
*/
|
|
46
|
+
side_panel?: {
|
|
47
|
+
default_path?: string;
|
|
48
|
+
};
|
|
49
|
+
/**
|
|
50
|
+
* The extension's icons.
|
|
51
|
+
*/
|
|
52
|
+
icons?: Record<string, string>;
|
|
53
|
+
/**
|
|
54
|
+
* Files pages may load.
|
|
55
|
+
*/
|
|
56
|
+
web_accessible_resources?: {
|
|
57
|
+
resources: string[];
|
|
58
|
+
}[];
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The parts of a manifest that name files.
|
|
62
|
+
*
|
|
63
|
+
* @typedef {object} ManifestFiles
|
|
64
|
+
* @property {{service_worker?: string}} [background] The worker.
|
|
65
|
+
* @property {{page?: string}} [options_ui] The options page.
|
|
66
|
+
* @property {{default_popup?: string, default_icon?: string | Record<string, string>}} [action] The popup and toolbar icon.
|
|
67
|
+
* @property {{default_path?: string}} [side_panel] The side panel.
|
|
68
|
+
* @property {Record<string, string>} [icons] The extension's icons.
|
|
69
|
+
* @property {{resources: string[]}[]} [web_accessible_resources] Files pages may load.
|
|
70
|
+
*/
|
|
23
71
|
/**
|
|
24
72
|
* The paths a manifest names, from the extension's root.
|
|
25
73
|
*
|
|
26
|
-
* @param {
|
|
27
|
-
* @returns {string[]} Worker, pages, icons, side panel and web-accessible resources.
|
|
74
|
+
* @param {ManifestFiles} manifest - The parsed `manifest.json`.
|
|
75
|
+
* @returns {string[]} Worker, pages, icons, side panel and web-accessible resources (less patterns with `*`).
|
|
76
|
+
*/
|
|
77
|
+
export declare function manifestReferences(manifest: ManifestFiles): string[];
|
|
78
|
+
/**
|
|
79
|
+
* The file a reference names: the path without its `#fragment` or `?query`
|
|
80
|
+
* (`options.html#workspaces` is `options.html`). Pure.
|
|
81
|
+
*
|
|
82
|
+
* @param {string} reference - A path as the extension writes it.
|
|
83
|
+
* @returns {string} The path alone; '' for a reference to the page itself (`#top`).
|
|
28
84
|
*/
|
|
29
|
-
export declare function
|
|
85
|
+
export declare function referencedPath(reference: string): string;
|
|
30
86
|
/**
|
|
31
87
|
* Every path the extension names that isn't a file inside it: relative
|
|
32
88
|
* imports in its scripts, `chrome.runtime.getURL('…')` paths, `src`/`href`
|
|
33
|
-
* in its pages, the manifest's entries, and any `extra` paths.
|
|
89
|
+
* in its pages, the manifest's entries, and any `extra` paths. A `#fragment`
|
|
90
|
+
* or `?query` is ignored: `getURL('options.html#workspaces')` names
|
|
91
|
+
* `options.html`.
|
|
34
92
|
*
|
|
35
93
|
* @param {string} dir - The extension folder.
|
|
36
94
|
* @param {readonly string[]} [extra] - More paths from the root (say, a script injected by name).
|
package/src/testing/package.js
CHANGED
|
@@ -43,28 +43,57 @@ export async function zipEntries(from, exclude) {
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The parts of a manifest that name files.
|
|
48
|
+
*
|
|
49
|
+
* @typedef {object} ManifestFiles
|
|
50
|
+
* @property {{service_worker?: string}} [background] The worker.
|
|
51
|
+
* @property {{page?: string}} [options_ui] The options page.
|
|
52
|
+
* @property {{default_popup?: string, default_icon?: string | Record<string, string>}} [action] The popup and toolbar icon.
|
|
53
|
+
* @property {{default_path?: string}} [side_panel] The side panel.
|
|
54
|
+
* @property {Record<string, string>} [icons] The extension's icons.
|
|
55
|
+
* @property {{resources: string[]}[]} [web_accessible_resources] Files pages may load.
|
|
56
|
+
*/
|
|
57
|
+
|
|
46
58
|
/**
|
|
47
59
|
* The paths a manifest names, from the extension's root.
|
|
48
60
|
*
|
|
49
|
-
* @param {
|
|
50
|
-
* @returns {string[]} Worker, pages, icons, side panel and web-accessible resources.
|
|
61
|
+
* @param {ManifestFiles} manifest - The parsed `manifest.json`.
|
|
62
|
+
* @returns {string[]} Worker, pages, icons, side panel and web-accessible resources (less patterns with `*`).
|
|
51
63
|
*/
|
|
52
64
|
export function manifestReferences(manifest) {
|
|
65
|
+
const icon = manifest.action?.default_icon;
|
|
53
66
|
return [
|
|
54
67
|
manifest.background?.service_worker,
|
|
55
68
|
manifest.options_ui?.page,
|
|
56
69
|
manifest.action?.default_popup,
|
|
57
70
|
manifest.side_panel?.default_path,
|
|
58
71
|
...Object.values(manifest.icons ?? {}),
|
|
59
|
-
...Object.values(
|
|
60
|
-
...(manifest.web_accessible_resources ?? []).flatMap((
|
|
61
|
-
].filter((ref) => typeof ref === 'string'
|
|
72
|
+
...(typeof icon === 'string' ? [icon] : Object.values(icon ?? {})),
|
|
73
|
+
...(manifest.web_accessible_resources ?? []).flatMap((entry) => entry.resources),
|
|
74
|
+
].filter((ref) => typeof ref === 'string').filter((ref) => !ref.includes('*'));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The file a reference names: the path without its `#fragment` or `?query`
|
|
79
|
+
* (`options.html#workspaces` is `options.html`). Pure.
|
|
80
|
+
*
|
|
81
|
+
* @param {string} reference - A path as the extension writes it.
|
|
82
|
+
* @returns {string} The path alone; '' for a reference to the page itself (`#top`).
|
|
83
|
+
*/
|
|
84
|
+
export function referencedPath(reference) {
|
|
85
|
+
return reference.replace(/[?#].*$/s, '');
|
|
62
86
|
}
|
|
63
87
|
|
|
88
|
+
/** A reference with a scheme (`https:`, `data:`, `mailto:`) or protocol-relative: not a file of the extension. */
|
|
89
|
+
const ELSEWHERE = /^(?:[a-z][a-z\d+.-]*:|\/\/)/i;
|
|
90
|
+
|
|
64
91
|
/**
|
|
65
92
|
* Every path the extension names that isn't a file inside it: relative
|
|
66
93
|
* imports in its scripts, `chrome.runtime.getURL('…')` paths, `src`/`href`
|
|
67
|
-
* in its pages, the manifest's entries, and any `extra` paths.
|
|
94
|
+
* in its pages, the manifest's entries, and any `extra` paths. A `#fragment`
|
|
95
|
+
* or `?query` is ignored: `getURL('options.html#workspaces')` names
|
|
96
|
+
* `options.html`.
|
|
68
97
|
*
|
|
69
98
|
* @param {string} dir - The extension folder.
|
|
70
99
|
* @param {readonly string[]} [extra] - More paths from the root (say, a script injected by name).
|
|
@@ -76,7 +105,9 @@ export async function missingReferences(dir, extra = []) {
|
|
|
76
105
|
const missing = [];
|
|
77
106
|
/** @param {string} from @param {string} target */
|
|
78
107
|
const check = (from, target) => {
|
|
79
|
-
const
|
|
108
|
+
const name = referencedPath(target);
|
|
109
|
+
if (!name || ELSEWHERE.test(target)) return;
|
|
110
|
+
const file = path.resolve(root, from, name);
|
|
80
111
|
if (!file.startsWith(root) || !existsSync(file)) missing.push(`${from || 'root'} → ${target}`);
|
|
81
112
|
};
|
|
82
113
|
for (const file of await filesUnder(root)) {
|
|
@@ -87,7 +118,7 @@ export async function missingReferences(dir, extra = []) {
|
|
|
87
118
|
for (const [, ref] of source.matchAll(/getURL\('([^']+)'\)/g)) check('', ref);
|
|
88
119
|
} else if (file.endsWith('.html')) {
|
|
89
120
|
const source = await readFile(path.join(root, file), 'utf8');
|
|
90
|
-
for (const [, ref] of source.matchAll(/(?:src|href)="([^"
|
|
121
|
+
for (const [, ref] of source.matchAll(/(?:src|href)="([^"]+)"/g)) check(from, ref);
|
|
91
122
|
}
|
|
92
123
|
}
|
|
93
124
|
const manifest = JSON.parse(await readFile(path.join(root, 'manifest.json'), 'utf8'));
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working through a list a few at a time: a live check of many sites, each
|
|
3
|
+
* in one of a few browsers.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/pool
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Run `work` on every item, at most `limit` at once, each slot taking the
|
|
9
|
+
* next item as it finishes one. Errors are `work`'s to handle: one that
|
|
10
|
+
* throws stops its slot, and the returned promise rejects once the others
|
|
11
|
+
* are done.
|
|
12
|
+
*
|
|
13
|
+
* @template T
|
|
14
|
+
* @param {readonly T[]} items - The items.
|
|
15
|
+
* @param {number} limit - At most this many at once.
|
|
16
|
+
* @param {(item: T, slot: number, index: number) => Promise<void>} work - Deals with one item;
|
|
17
|
+
* `slot` (0 to limit − 1) is the same for everything one slot does, for per-slot resources
|
|
18
|
+
* (a browser each).
|
|
19
|
+
* @returns {Promise<void>} Once every item is done.
|
|
20
|
+
* @example
|
|
21
|
+
* await eachLimited(sites, 3, async (site, slot) => check(site, browsers[slot]));
|
|
22
|
+
*/
|
|
23
|
+
export declare function eachLimited<T>(items: readonly T[], limit: number, work: (item: T, slot: number, index: number) => Promise<void>): Promise<void>;
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Working through a list a few at a time: a live check of many sites, each
|
|
3
|
+
* in one of a few browsers.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/pool
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Run `work` on every item, at most `limit` at once, each slot taking the
|
|
10
|
+
* next item as it finishes one. Errors are `work`'s to handle: one that
|
|
11
|
+
* throws stops its slot, and the returned promise rejects once the others
|
|
12
|
+
* are done.
|
|
13
|
+
*
|
|
14
|
+
* @template T
|
|
15
|
+
* @param {readonly T[]} items - The items.
|
|
16
|
+
* @param {number} limit - At most this many at once.
|
|
17
|
+
* @param {(item: T, slot: number, index: number) => Promise<void>} work - Deals with one item;
|
|
18
|
+
* `slot` (0 to limit − 1) is the same for everything one slot does, for per-slot resources
|
|
19
|
+
* (a browser each).
|
|
20
|
+
* @returns {Promise<void>} Once every item is done.
|
|
21
|
+
* @example
|
|
22
|
+
* await eachLimited(sites, 3, async (site, slot) => check(site, browsers[slot]));
|
|
23
|
+
*/
|
|
24
|
+
export async function eachLimited(items, limit, work) {
|
|
25
|
+
let next = 0;
|
|
26
|
+
await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async (_, slot) => {
|
|
27
|
+
while (next < items.length) {
|
|
28
|
+
const index = next;
|
|
29
|
+
next += 1;
|
|
30
|
+
await work(items[index], slot, index);
|
|
31
|
+
}
|
|
32
|
+
}));
|
|
33
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
export type SavedTabGroup = {
|
|
2
|
+
/**
|
|
3
|
+
* Its title.
|
|
4
|
+
*/
|
|
5
|
+
title: string;
|
|
6
|
+
/**
|
|
7
|
+
* Whether it's pinned to the saved groups bar.
|
|
8
|
+
*/
|
|
9
|
+
pinned: boolean;
|
|
10
|
+
/**
|
|
11
|
+
* Its tabs' addresses, in order.
|
|
12
|
+
*/
|
|
13
|
+
urls: string[];
|
|
14
|
+
};
|
|
15
|
+
export type ProtoField = {
|
|
16
|
+
field: number;
|
|
17
|
+
value: number | Buffer;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Chrome's saved tab groups in a closed profile.
|
|
21
|
+
*
|
|
22
|
+
* @param {string} profile - The profile folder (as `extensionProfile` gives it).
|
|
23
|
+
* @returns {Promise<SavedTabGroup[]>} The saved groups, in no particular order; none when the
|
|
24
|
+
* profile has no store yet.
|
|
25
|
+
* @throws {Error} When the store has compacted tables this doesn't read.
|
|
26
|
+
* @example
|
|
27
|
+
* const profile = await extensionProfile(chromium, 'extension');
|
|
28
|
+
* const { worker, close } = await profile.launch();
|
|
29
|
+
* await worker.evaluate(async () => chrome.tabGroups.update(await chrome.tabs.group({tabIds: [tabId]}), {title: 'Research'}));
|
|
30
|
+
* await close();
|
|
31
|
+
* const groups = await savedTabGroups(profile.dir); // → [{title: 'Research', pinned: true, urls: [...]}]
|
|
32
|
+
*/
|
|
33
|
+
export declare function savedTabGroups(profile: string): Promise<SavedTabGroup[]>;
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's saved tab groups, read from a closed profile: no extension API lists them and their
|
|
3
|
+
* UI (the saved groups bar, the Tab groups menu) is native, so e2e checks read the store itself.
|
|
4
|
+
*
|
|
5
|
+
* Chromium keeps them in the profile's `Sync Data/LevelDB` (a DataTypeStore, kept even when
|
|
6
|
+
* signed out), one record per group and one per tab, keyed `saved_tab_group-dt-<guid>`. Each
|
|
7
|
+
* value is a `SavedTabGroupData` proto whose field 2 is the `SavedTabGroupSpecifics`
|
|
8
|
+
* (components/sync/protocol/saved_tab_group_specifics.proto): 1 guid, 4 group (2 title,
|
|
9
|
+
* 3 colour, 4 pinned position: present when pinned to the bar), 5 tab (1 group guid, 2 position,
|
|
10
|
+
* 3 url). A fresh test profile never fills a LevelDB log (4 MB) so everything is in `*.log`
|
|
11
|
+
* files, which this reads; it refuses a store that has compacted tables.
|
|
12
|
+
*
|
|
13
|
+
* Read only after the browser has closed, so every write is on disk.
|
|
14
|
+
*
|
|
15
|
+
* @module testing/saved-groups
|
|
16
|
+
*/
|
|
17
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A saved group as Chrome keeps it.
|
|
22
|
+
*
|
|
23
|
+
* @typedef {object} SavedTabGroup
|
|
24
|
+
* @property {string} title Its title.
|
|
25
|
+
* @property {boolean} pinned Whether it's pinned to the saved groups bar.
|
|
26
|
+
* @property {string[]} urls Its tabs' addresses, in order.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* One protobuf field: its number and value (a number for varints, the bytes for the rest).
|
|
31
|
+
*
|
|
32
|
+
* @typedef {{field: number, value: number | Buffer}} ProtoField
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
const KEY_PREFIX = 'saved_tab_group-dt-';
|
|
36
|
+
/** LevelDB log blocks: 32 KB, each record with a 7-byte header (checksum, length, type). */
|
|
37
|
+
const BLOCK = 32768;
|
|
38
|
+
const HEADER = 7;
|
|
39
|
+
/** Record types: a whole batch, or its first, middle or last fragment. */
|
|
40
|
+
const FULL = 1;
|
|
41
|
+
const FIRST = 2;
|
|
42
|
+
const MIDDLE = 3;
|
|
43
|
+
const LAST = 4;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Read a varint (LevelDB lengths, protobuf keys and values). Numbers past 2^53 lose precision,
|
|
47
|
+
* which only affects timestamps this never reads.
|
|
48
|
+
*
|
|
49
|
+
* @param {Buffer} buf - The bytes.
|
|
50
|
+
* @param {number} at - Where it starts.
|
|
51
|
+
* @returns {[value: number, next: number]} Its value and the offset after it.
|
|
52
|
+
*/
|
|
53
|
+
function varint(buf, at) {
|
|
54
|
+
let value = 0;
|
|
55
|
+
let scale = 1;
|
|
56
|
+
let byte;
|
|
57
|
+
do {
|
|
58
|
+
byte = buf[at++];
|
|
59
|
+
value += (byte & 0x7f) * scale;
|
|
60
|
+
scale *= 128;
|
|
61
|
+
} while (byte & 0x80);
|
|
62
|
+
return [value, at];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The write batches in one LevelDB log file, fragments joined.
|
|
67
|
+
*
|
|
68
|
+
* @param {Buffer} buf - The file.
|
|
69
|
+
* @returns {Buffer[]} Its batches, in order.
|
|
70
|
+
*/
|
|
71
|
+
function batches(buf) {
|
|
72
|
+
/** @type {Buffer[]} */
|
|
73
|
+
const out = [];
|
|
74
|
+
/** @type {Buffer[]} */
|
|
75
|
+
let pending = [];
|
|
76
|
+
let at = 0;
|
|
77
|
+
while (at + HEADER <= buf.length) {
|
|
78
|
+
const left = BLOCK - (at % BLOCK);
|
|
79
|
+
if (left < HEADER) {
|
|
80
|
+
at += left; // Block trailer: zero padding.
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
const length = buf.readUInt16LE(at + 4);
|
|
84
|
+
const type = buf[at + 6];
|
|
85
|
+
if (type === 0 && length === 0) {
|
|
86
|
+
at += left; // Pre-allocated, unwritten space.
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
const data = buf.subarray(at + HEADER, at + HEADER + length);
|
|
90
|
+
at += HEADER + length;
|
|
91
|
+
if (type === FULL) out.push(data);
|
|
92
|
+
else if (type === FIRST) pending = [data];
|
|
93
|
+
else if (type === MIDDLE) pending.push(data);
|
|
94
|
+
else if (type === LAST) out.push(Buffer.concat([...pending, data]));
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Apply one batch to the live keys: puts set a key, deletes remove it.
|
|
101
|
+
*
|
|
102
|
+
* @param {Buffer} batch - Sequence (8 bytes), count (4), then each tagged put or delete.
|
|
103
|
+
* @param {Map<string, Buffer>} live - The keys, changed in place.
|
|
104
|
+
*/
|
|
105
|
+
function apply(batch, live) {
|
|
106
|
+
const count = batch.readUInt32LE(8);
|
|
107
|
+
let at = 12;
|
|
108
|
+
for (let i = 0; i < count; i++) {
|
|
109
|
+
const put = batch[at++] === 1;
|
|
110
|
+
let length;
|
|
111
|
+
[length, at] = varint(batch, at);
|
|
112
|
+
const key = batch.subarray(at, at + length).toString('latin1');
|
|
113
|
+
at += length;
|
|
114
|
+
if (!put) {
|
|
115
|
+
live.delete(key);
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
[length, at] = varint(batch, at);
|
|
119
|
+
live.set(key, batch.subarray(at, at + length));
|
|
120
|
+
at += length;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* The fields of one protobuf message (one level).
|
|
126
|
+
*
|
|
127
|
+
* @param {Buffer} buf - The message.
|
|
128
|
+
* @returns {ProtoField[]} Its fields, in order.
|
|
129
|
+
*/
|
|
130
|
+
function fields(buf) {
|
|
131
|
+
/** @type {ProtoField[]} */
|
|
132
|
+
const out = [];
|
|
133
|
+
let at = 0;
|
|
134
|
+
while (at < buf.length) {
|
|
135
|
+
let key;
|
|
136
|
+
[key, at] = varint(buf, at);
|
|
137
|
+
const field = Math.floor(key / 8);
|
|
138
|
+
const wire = key % 8;
|
|
139
|
+
if (wire === 0) {
|
|
140
|
+
let value;
|
|
141
|
+
[value, at] = varint(buf, at);
|
|
142
|
+
out.push({ field, value });
|
|
143
|
+
} else if (wire === 2) {
|
|
144
|
+
let length;
|
|
145
|
+
[length, at] = varint(buf, at);
|
|
146
|
+
out.push({ field, value: buf.subarray(at, at + length) });
|
|
147
|
+
at += length;
|
|
148
|
+
} else if (wire === 1 || wire === 5) {
|
|
149
|
+
const size = wire === 1 ? 8 : 4;
|
|
150
|
+
out.push({ field, value: buf.subarray(at, at + size) });
|
|
151
|
+
at += size;
|
|
152
|
+
} else {
|
|
153
|
+
throw new Error(`Unexpected protobuf wire type ${wire}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* A field's bytes.
|
|
161
|
+
*
|
|
162
|
+
* @param {ProtoField[]} message - The message's fields.
|
|
163
|
+
* @param {number} field - The field number.
|
|
164
|
+
* @returns {Buffer | undefined} Its bytes, when present and not a number.
|
|
165
|
+
*/
|
|
166
|
+
const bytesOf = (message, field) => {
|
|
167
|
+
const value = message.find((f) => f.field === field)?.value;
|
|
168
|
+
return Buffer.isBuffer(value) ? value : undefined;
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Chrome's saved tab groups in a closed profile.
|
|
173
|
+
*
|
|
174
|
+
* @param {string} profile - The profile folder (as `extensionProfile` gives it).
|
|
175
|
+
* @returns {Promise<SavedTabGroup[]>} The saved groups, in no particular order; none when the
|
|
176
|
+
* profile has no store yet.
|
|
177
|
+
* @throws {Error} When the store has compacted tables this doesn't read.
|
|
178
|
+
* @example
|
|
179
|
+
* const profile = await extensionProfile(chromium, 'extension');
|
|
180
|
+
* const { worker, close } = await profile.launch();
|
|
181
|
+
* await worker.evaluate(async () => chrome.tabGroups.update(await chrome.tabs.group({tabIds: [tabId]}), {title: 'Research'}));
|
|
182
|
+
* await close();
|
|
183
|
+
* const groups = await savedTabGroups(profile.dir); // → [{title: 'Research', pinned: true, urls: [...]}]
|
|
184
|
+
*/
|
|
185
|
+
export async function savedTabGroups(profile) {
|
|
186
|
+
const dir = path.join(profile, 'Default', 'Sync Data', 'LevelDB');
|
|
187
|
+
const files = await readdir(dir).catch(() => /** @type {string[]} */ ([]));
|
|
188
|
+
if (files.some((file) => file.endsWith('.ldb'))) throw new Error(`${dir} has compacted tables; only logs are read`);
|
|
189
|
+
/** @type {Map<string, Buffer>} */
|
|
190
|
+
const live = new Map();
|
|
191
|
+
for (const file of files.filter((name) => name.endsWith('.log')).sort()) {
|
|
192
|
+
for (const batch of batches(await readFile(path.join(dir, file)))) apply(batch, live);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** @type {Map<string, SavedTabGroup>} */
|
|
196
|
+
const groups = new Map();
|
|
197
|
+
/** @type {{group: string, position: number, url: string}[]} */
|
|
198
|
+
const tabs = [];
|
|
199
|
+
for (const [key, value] of live) {
|
|
200
|
+
if (!key.startsWith(KEY_PREFIX)) continue;
|
|
201
|
+
const specifics = bytesOf(fields(value), 2);
|
|
202
|
+
if (!specifics) continue;
|
|
203
|
+
const spec = fields(specifics);
|
|
204
|
+
const guid = bytesOf(spec, 1)?.toString('utf8') ?? '';
|
|
205
|
+
const group = bytesOf(spec, 4);
|
|
206
|
+
const tab = bytesOf(spec, 5);
|
|
207
|
+
if (group) {
|
|
208
|
+
const g = fields(group);
|
|
209
|
+
groups.set(guid, { title: bytesOf(g, 2)?.toString('utf8') ?? '', pinned: g.some((f) => f.field === 4), urls: [] });
|
|
210
|
+
} else if (tab) {
|
|
211
|
+
const t = fields(tab);
|
|
212
|
+
const position = t.find((f) => f.field === 2)?.value;
|
|
213
|
+
tabs.push({
|
|
214
|
+
group: bytesOf(t, 1)?.toString('utf8') ?? '',
|
|
215
|
+
position: typeof position === 'number' ? position : 0,
|
|
216
|
+
url: bytesOf(t, 3)?.toString('utf8') ?? '',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
for (const tab of tabs.sort((a, b) => a.position - b.position)) groups.get(tab.group)?.urls.push(tab.url);
|
|
221
|
+
return [...groups.values()];
|
|
222
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time limits for tests that drive a real browser, where a page that stops
|
|
3
|
+
* responding would otherwise hold a run up forever.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/time
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Settle `promise` within `ms`, or settle the way `late` says. The timer is
|
|
9
|
+
* cleared as soon as either happens, so a finished run isn't kept waiting
|
|
10
|
+
* for it. The work itself isn't stopped.
|
|
11
|
+
*
|
|
12
|
+
* @template T
|
|
13
|
+
* @param {Promise<T>} promise - The work.
|
|
14
|
+
* @param {number} ms - The limit.
|
|
15
|
+
* @param {(resolve: (value: T) => void, reject: (error: Error) => void) => void} late - Runs when the
|
|
16
|
+
* limit passes first.
|
|
17
|
+
* @returns {Promise<T>} The work's outcome, or `late`'s.
|
|
18
|
+
*/
|
|
19
|
+
export declare function deadline<T>(promise: Promise<T>, ms: number, late: (resolve: (value: T) => void, reject: (error: Error) => void) => void): Promise<T>;
|
|
20
|
+
/**
|
|
21
|
+
* A promise's value within a time limit, or `fallback` when it's late or
|
|
22
|
+
* fails: for a step a live run should give up on and go past (a renderer
|
|
23
|
+
* that stopped responding, a close that never returns). The work itself
|
|
24
|
+
* isn't stopped; the timer is cleared once it settles.
|
|
25
|
+
*
|
|
26
|
+
* @template T
|
|
27
|
+
* @param {Promise<T>} promise - The work.
|
|
28
|
+
* @param {number} ms - The limit.
|
|
29
|
+
* @param {T} fallback - What to give if it's late or fails.
|
|
30
|
+
* @returns {Promise<T>} Its value, or the fallback. Never rejects.
|
|
31
|
+
* @example
|
|
32
|
+
* const list = await within(handled(worker, page), 8000, []);
|
|
33
|
+
* await within(page.close(), 15000, undefined);
|
|
34
|
+
*/
|
|
35
|
+
export declare function within<T>(promise: Promise<T>, ms: number, fallback: T): Promise<T>;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Time limits for tests that drive a real browser, where a page that stops
|
|
3
|
+
* responding would otherwise hold a run up forever.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/time
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Settle `promise` within `ms`, or settle the way `late` says. The timer is
|
|
10
|
+
* cleared as soon as either happens, so a finished run isn't kept waiting
|
|
11
|
+
* for it. The work itself isn't stopped.
|
|
12
|
+
*
|
|
13
|
+
* @template T
|
|
14
|
+
* @param {Promise<T>} promise - The work.
|
|
15
|
+
* @param {number} ms - The limit.
|
|
16
|
+
* @param {(resolve: (value: T) => void, reject: (error: Error) => void) => void} late - Runs when the
|
|
17
|
+
* limit passes first.
|
|
18
|
+
* @returns {Promise<T>} The work's outcome, or `late`'s.
|
|
19
|
+
*/
|
|
20
|
+
export function deadline(promise, ms, late) {
|
|
21
|
+
/** @type {ReturnType<typeof setTimeout> | undefined} */
|
|
22
|
+
let timer;
|
|
23
|
+
const limit = new Promise((resolve, reject) => {
|
|
24
|
+
timer = setTimeout(() => late(resolve, reject), ms);
|
|
25
|
+
});
|
|
26
|
+
return /** @type {Promise<T>} */ (Promise.race([promise, limit])).finally(() => clearTimeout(timer));
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A promise's value within a time limit, or `fallback` when it's late or
|
|
31
|
+
* fails: for a step a live run should give up on and go past (a renderer
|
|
32
|
+
* that stopped responding, a close that never returns). The work itself
|
|
33
|
+
* isn't stopped; the timer is cleared once it settles.
|
|
34
|
+
*
|
|
35
|
+
* @template T
|
|
36
|
+
* @param {Promise<T>} promise - The work.
|
|
37
|
+
* @param {number} ms - The limit.
|
|
38
|
+
* @param {T} fallback - What to give if it's late or fails.
|
|
39
|
+
* @returns {Promise<T>} Its value, or the fallback. Never rejects.
|
|
40
|
+
* @example
|
|
41
|
+
* const list = await within(handled(worker, page), 8000, []);
|
|
42
|
+
* await within(page.close(), 15000, undefined);
|
|
43
|
+
*/
|
|
44
|
+
export function within(promise, ms, fallback) {
|
|
45
|
+
return deadline(promise.catch(() => fallback), ms, (resolve) => resolve(fallback));
|
|
46
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's own install warnings for a manifest ("Read your browsing
|
|
3
|
+
* history"), the lines its install dialog and the Chrome Web Store list, read
|
|
4
|
+
* with `chrome.management.getPermissionWarningsByManifest` (no `management`
|
|
5
|
+
* permission needed) in a loaded extension's service worker. For an e2e check
|
|
6
|
+
* that a permission adds no warning, or that the warnings are exactly the
|
|
7
|
+
* ones the store listing explains.
|
|
8
|
+
*
|
|
9
|
+
* @module testing/warnings
|
|
10
|
+
*/
|
|
11
|
+
import type { Worker } from 'playwright';
|
|
12
|
+
export type Manifest = Record<string, unknown>;
|
|
13
|
+
/** @import { Worker } from 'playwright' */
|
|
14
|
+
/**
|
|
15
|
+
* A manifest, as `manifest.json` parses.
|
|
16
|
+
*
|
|
17
|
+
* @typedef {Record<string, unknown>} Manifest
|
|
18
|
+
*/
|
|
19
|
+
/**
|
|
20
|
+
* The manifest less some permissions (from `permissions`, `optional_permissions`
|
|
21
|
+
* and `host_permissions`). Pure: `manifest` is left as it was.
|
|
22
|
+
*
|
|
23
|
+
* @param {Manifest} manifest - The manifest.
|
|
24
|
+
* @param {readonly string[]} names - The permissions (or host patterns) to leave out.
|
|
25
|
+
* @returns {Manifest} A copy without them.
|
|
26
|
+
* @example
|
|
27
|
+
* withoutPermissions({permissions: ['tabs', 'alarms']}, ['alarms']); // → {permissions: ['tabs']}
|
|
28
|
+
*/
|
|
29
|
+
export declare function withoutPermissions(manifest: Manifest, names: readonly string[]): Manifest;
|
|
30
|
+
/**
|
|
31
|
+
* Chrome's install warnings for a manifest, as this browser words them.
|
|
32
|
+
*
|
|
33
|
+
* @param {Worker} worker - A loaded extension's service worker (`launchExtension`'s `worker`).
|
|
34
|
+
* @param {Manifest} manifest - The manifest to ask about (it needn't be the loaded extension's).
|
|
35
|
+
* @returns {Promise<string[]>} The warnings, in Chrome's order.
|
|
36
|
+
* @example
|
|
37
|
+
* assert.deepEqual(await installWarnings(worker, manifest), ['Read your browsing history']);
|
|
38
|
+
*/
|
|
39
|
+
export declare function installWarnings(worker: Worker, manifest: Manifest): Promise<string[]>;
|
|
40
|
+
/**
|
|
41
|
+
* Chrome's install warnings for a manifest, with and without some permissions,
|
|
42
|
+
* and the ones only the permissions bring (a warning they reword counts).
|
|
43
|
+
*
|
|
44
|
+
* @param {Worker} worker - A loaded extension's service worker.
|
|
45
|
+
* @param {Manifest} manifest - The manifest.
|
|
46
|
+
* @param {readonly string[]} names - The permissions in question.
|
|
47
|
+
* @returns {Promise<{warnings: string[], without: string[], added: string[]}>} The warnings with
|
|
48
|
+
* them, without them, and those in the first list only.
|
|
49
|
+
* @example
|
|
50
|
+
* const { warnings, added } = await warningsAddedBy(worker, manifest, ['alarms']);
|
|
51
|
+
* assert.deepEqual(added, []); // alarms asks the user nothing
|
|
52
|
+
*/
|
|
53
|
+
export declare function warningsAddedBy(worker: Worker, manifest: Manifest, names: readonly string[]): Promise<{
|
|
54
|
+
warnings: string[];
|
|
55
|
+
without: string[];
|
|
56
|
+
added: string[];
|
|
57
|
+
}>;
|