@arraypress/extension-kit 1.10.0 → 1.11.1
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 +103 -21
- package/bin/extension-kit.js +2 -1
- package/package.json +1 -1
- package/src/ai/host.d.ts +183 -0
- package/src/ai/host.js +225 -0
- package/src/ai/model.d.ts +2 -1
- package/src/ai/model.js +10 -2
- package/src/build/vendor.d.ts +29 -8
- package/src/build/vendor.js +136 -40
- package/src/content/highlight.d.ts +15 -6
- package/src/content/highlight.js +42 -17
- package/src/content/overlay.d.ts +19 -4
- package/src/content/overlay.js +44 -6
- package/src/content/slices.d.ts +3 -1
- package/src/content/slices.js +29 -14
- package/src/content/watch.d.ts +4 -2
- package/src/content/watch.js +17 -3
- package/src/dom/focus.d.ts +11 -0
- package/src/dom/focus.js +15 -0
- package/src/frame/window.d.ts +1 -1
- package/src/frame/window.js +8 -7
- package/src/index.d.ts +10 -3
- package/src/index.js +8 -3
- package/src/panel/show-on-page.d.ts +82 -0
- package/src/panel/show-on-page.js +83 -0
- package/src/scripting/call.d.ts +29 -9
- package/src/scripting/call.js +35 -11
- package/src/scripting/context-menus.d.ts +45 -0
- package/src/scripting/context-menus.js +53 -0
- package/src/scripting/registration.d.ts +2 -1
- package/src/scripting/registration.js +2 -1
- package/src/scripting/tab.d.ts +17 -5
- package/src/scripting/tab.js +15 -7
- package/src/settings/saver.js +16 -6
- package/src/settings/storage.d.ts +33 -2
- package/src/settings/storage.js +73 -3
- package/src/settings/store.d.ts +17 -0
- package/src/settings/store.js +87 -20
- package/src/sites/sites.d.ts +6 -1
- package/src/sites/sites.js +20 -3
- package/src/styles/chrome-ui.css +59 -30
- package/src/testing/e2e.d.ts +35 -4
- package/src/testing/e2e.js +67 -5
- package/src/testing/fake-model.d.ts +9 -1
- package/src/testing/fake-model.js +44 -23
- package/src/testing/fake-storage.d.ts +41 -13
- package/src/testing/fake-storage.js +163 -51
- package/src/testing/index.d.ts +3 -3
- package/src/testing/index.js +5 -3
- package/src/testing/live-run.d.ts +23 -2
- package/src/testing/live-run.js +64 -18
- package/src/testing/messages.d.ts +7 -3
- package/src/testing/messages.js +50 -9
- package/src/testing/package.d.ts +56 -7
- package/src/testing/package.js +57 -18
- package/src/testing/prompt.d.ts +4 -2
- package/src/testing/prompt.js +7 -3
- package/src/time/pool.d.ts +2 -1
- package/src/time/pool.js +4 -2
- package/src/toolbar/theme.d.ts +10 -2
- package/src/toolbar/theme.js +12 -4
- package/src/ui/menu.d.ts +13 -3
- package/src/ui/menu.js +48 -11
- package/src/ui/model-row.js +5 -1
- package/src/ui/site-list.js +12 -3
- package/src/ui/text-dialog.d.ts +3 -2
- package/src/ui/text-dialog.js +10 -3
- package/src/ui/toast.d.ts +3 -0
- package/src/ui/toast.js +23 -1
|
@@ -1,33 +1,65 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* An in-memory `chrome.storage` for tests, behaving as Chrome does
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
2
|
+
* An in-memory `chrome.storage` for tests, behaving as Chrome does (each point
|
|
3
|
+
* checked against Chromium): values are stored as Chrome converts them (what
|
|
4
|
+
* JSON can't hold is dropped, a `Date` is `{}`), `get` takes a key, keys, an
|
|
5
|
+
* object of defaults or nothing, `onChanged` fires only for values that
|
|
6
|
+
* changed (whatever order an object's keys come in), and every area has
|
|
7
|
+
* Chrome's quotas. Sync refuses an item over 8,192 bytes as Chrome counts them
|
|
8
|
+
* (`syncItemBytes`: UTF-8, `<` six bytes), more than 102,400 bytes or 512
|
|
9
|
+
* items in all, and more than 120 calls a minute or 1,800 an hour of `set`,
|
|
10
|
+
* `remove` or `clear` (each counted by itself, refused calls included); local
|
|
11
|
+
* and session hold 10 MB. Tests then check outcomes (what's stored), not
|
|
6
12
|
* calls.
|
|
7
13
|
*
|
|
8
14
|
* @module testing/fake-storage
|
|
9
15
|
*/
|
|
10
16
|
|
|
11
|
-
import { MAX_SYNC_ITEM_BYTES } from '../settings/storage.js';
|
|
17
|
+
import { MAX_SYNC_ITEM_BYTES, sameValue, syncItemBytes } from '../settings/storage.js';
|
|
12
18
|
|
|
13
19
|
/** @import { StorageChange } from '../settings/storage.js' */
|
|
14
20
|
|
|
15
|
-
/** Chrome Sync's write limit: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE`. */
|
|
21
|
+
/** Chrome Sync's write limit a minute: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_MINUTE`. */
|
|
16
22
|
export const SYNC_WRITES_PER_MINUTE = 120;
|
|
17
23
|
|
|
24
|
+
/** Chrome Sync's write limit an hour: `chrome.storage.sync.MAX_WRITE_OPERATIONS_PER_HOUR`. */
|
|
25
|
+
export const SYNC_WRITES_PER_HOUR = 1800;
|
|
26
|
+
|
|
27
|
+
/** All of sync's items together may take this many bytes (`chrome.storage.sync.QUOTA_BYTES`). */
|
|
28
|
+
export const SYNC_QUOTA_BYTES = 102_400;
|
|
29
|
+
|
|
30
|
+
/** The most items sync holds (`chrome.storage.sync.MAX_ITEMS`). */
|
|
31
|
+
export const SYNC_MAX_ITEMS = 512;
|
|
32
|
+
|
|
33
|
+
/** What local and session each hold (`chrome.storage.local.QUOTA_BYTES`), without `unlimitedStorage`. */
|
|
34
|
+
export const LOCAL_QUOTA_BYTES = 10_485_760;
|
|
35
|
+
|
|
18
36
|
/**
|
|
19
37
|
* @typedef {(changes: Record<string, StorageChange>, area: string) => unknown} ChangeListener
|
|
20
38
|
*/
|
|
21
39
|
|
|
40
|
+
/**
|
|
41
|
+
* An `onChanged` event.
|
|
42
|
+
*
|
|
43
|
+
* @template {(...args: any[]) => unknown} L
|
|
44
|
+
* @typedef {object} FakeEvent
|
|
45
|
+
* @property {(listener: L) => void} addListener
|
|
46
|
+
* @property {(listener: L) => void} removeListener
|
|
47
|
+
* @property {(listener: L) => boolean} hasListener
|
|
48
|
+
*/
|
|
49
|
+
|
|
22
50
|
/**
|
|
23
51
|
* One fake storage area.
|
|
24
52
|
*
|
|
25
53
|
* @typedef {object} FakeArea
|
|
26
54
|
* @property {Record<string, any>} data What it holds, to inspect (and seed) in tests.
|
|
27
|
-
* @property {number} writes How many writes it has taken.
|
|
28
|
-
* @property {(keys?: string | string[] | null) => Promise<Record<string, any>>} get
|
|
55
|
+
* @property {number} writes How many writes (`set`, `remove`, `clear`) it has taken.
|
|
56
|
+
* @property {(keys?: string | string[] | Record<string, unknown> | null) => Promise<Record<string, any>>} get
|
|
57
|
+
* A key's or keys' items, an object's keys with its values for the missing ones, or everything.
|
|
29
58
|
* @property {(items: Record<string, unknown>) => Promise<void>} set
|
|
30
59
|
* @property {(keys: string | string[]) => Promise<void>} remove
|
|
60
|
+
* @property {() => Promise<void>} clear
|
|
61
|
+
* @property {(keys?: string | string[] | null) => Promise<number>} getBytesInUse The bytes the keys' items (or all) take.
|
|
62
|
+
* @property {FakeEvent<(changes: Record<string, StorageChange>) => unknown>} onChanged This area's changes.
|
|
31
63
|
*/
|
|
32
64
|
|
|
33
65
|
/**
|
|
@@ -37,28 +69,79 @@ export const SYNC_WRITES_PER_MINUTE = 120;
|
|
|
37
69
|
* @property {FakeArea} local `chrome.storage.local`.
|
|
38
70
|
* @property {FakeArea} sync `chrome.storage.sync`, with Chrome's quotas.
|
|
39
71
|
* @property {FakeArea} session `chrome.storage.session`.
|
|
40
|
-
* @property {
|
|
72
|
+
* @property {FakeEvent<ChangeListener>} onChanged `chrome.storage.onChanged`.
|
|
41
73
|
* @property {() => Promise<void>} settled Waits for every listener, including the changes those listeners make.
|
|
42
74
|
*/
|
|
43
75
|
|
|
76
|
+
/** Stands for a value Chrome doesn't store (`undefined`, a function, `NaN`). */
|
|
77
|
+
const DROPPED = Symbol('dropped');
|
|
78
|
+
|
|
44
79
|
/**
|
|
45
|
-
*
|
|
80
|
+
* A value as Chrome stores it: what JSON holds, copied; `undefined`, functions, symbols and
|
|
81
|
+
* numbers that aren't finite dropped from objects (`null` in arrays); any other object (a
|
|
82
|
+
* `Date`, a `RegExp`, a `Map`) as `{}`.
|
|
46
83
|
*
|
|
47
|
-
* @param {unknown} value -
|
|
84
|
+
* @param {unknown} value - What an extension gave `set`.
|
|
85
|
+
* @returns {unknown} The stored copy, or {@link DROPPED}.
|
|
86
|
+
*/
|
|
87
|
+
function asStored(value) {
|
|
88
|
+
if (value === null || typeof value === 'string' || typeof value === 'boolean') return value;
|
|
89
|
+
if (typeof value === 'number') return Number.isFinite(value) ? value : DROPPED;
|
|
90
|
+
if (typeof value !== 'object') return DROPPED;
|
|
91
|
+
if (Array.isArray(value)) {
|
|
92
|
+
return value.map((item) => {
|
|
93
|
+
const stored = asStored(item);
|
|
94
|
+
return stored === DROPPED ? null : stored;
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
const prototype = Object.getPrototypeOf(value);
|
|
98
|
+
if (prototype !== Object.prototype && prototype !== null) return {};
|
|
99
|
+
return Object.fromEntries(Object.entries(value).map(([key, inner]) => [key, asStored(inner)]).filter(([, inner]) => inner !== DROPPED));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* A copy of what's stored, as `get` and `onChanged` hand it out.
|
|
104
|
+
*
|
|
105
|
+
* @param {unknown} value - A stored value, or undefined.
|
|
48
106
|
* @returns {any} The copy.
|
|
49
107
|
*/
|
|
50
|
-
const
|
|
108
|
+
const copied = (value) => (value === undefined ? undefined : structuredClone(value));
|
|
51
109
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
110
|
+
/**
|
|
111
|
+
* An event's listeners.
|
|
112
|
+
*
|
|
113
|
+
* @template {(...args: any[]) => unknown} L
|
|
114
|
+
* @returns {FakeEvent<L> & {listeners: L[]}} The event, and who listens.
|
|
115
|
+
*/
|
|
116
|
+
function fakeEvent() {
|
|
117
|
+
/** @type {L[]} */
|
|
118
|
+
const listeners = [];
|
|
119
|
+
return {
|
|
120
|
+
listeners,
|
|
121
|
+
addListener: (listener) => { if (!listeners.includes(listener)) listeners.push(listener); },
|
|
122
|
+
removeListener: (listener) => { if (listeners.includes(listener)) listeners.splice(listeners.indexOf(listener), 1); },
|
|
123
|
+
hasListener: (listener) => listeners.includes(listener),
|
|
124
|
+
};
|
|
125
|
+
}
|
|
54
126
|
|
|
55
127
|
/**
|
|
56
|
-
*
|
|
128
|
+
* A limit on calls in a window of time, as Chrome's quota service keeps one: the window opens
|
|
129
|
+
* with the first call and takes so many; the first call after it has passed opens the next.
|
|
57
130
|
*
|
|
58
|
-
* @param {
|
|
59
|
-
* @
|
|
131
|
+
* @param {number} most - Calls a window.
|
|
132
|
+
* @param {number} ms - The window.
|
|
133
|
+
* @param {() => number} now - The clock.
|
|
134
|
+
* @returns {() => boolean} Counts a call; false when it's over the limit.
|
|
60
135
|
*/
|
|
61
|
-
|
|
136
|
+
function callLimit(most, ms, now) {
|
|
137
|
+
let opened = -Infinity;
|
|
138
|
+
let left = 0;
|
|
139
|
+
return () => {
|
|
140
|
+
if (now() >= opened + ms) [opened, left] = [now(), most];
|
|
141
|
+
left -= 1;
|
|
142
|
+
return left >= 0;
|
|
143
|
+
};
|
|
144
|
+
}
|
|
62
145
|
|
|
63
146
|
/**
|
|
64
147
|
* Make a fake `chrome.storage`.
|
|
@@ -67,71 +150,89 @@ const totalBytes = (items) => Object.entries(items).reduce((sum, [key, value]) =
|
|
|
67
150
|
* @param {Record<string, unknown>} [initial.local] - What local holds.
|
|
68
151
|
* @param {Record<string, unknown>} [initial.sync] - What sync holds.
|
|
69
152
|
* @param {Record<string, unknown>} [initial.session] - What session holds.
|
|
70
|
-
* @param {() => number} [initial.now] - The clock sync's write
|
|
153
|
+
* @param {() => number} [initial.now] - The clock sync's write limits read. Default `Date.now`.
|
|
71
154
|
* @returns {FakeChromeStorage} The fake.
|
|
155
|
+
*
|
|
72
156
|
* @example
|
|
73
157
|
* const storage = fakeChromeStorage({ sync: { days: 3 } });
|
|
74
158
|
* await store.save(storage, { days: 5 });
|
|
75
159
|
* assert.equal(storage.sync.data.days, 5);
|
|
76
160
|
*/
|
|
77
161
|
export function fakeChromeStorage({ local = {}, sync = {}, session = {}, now = Date.now } = {}) {
|
|
78
|
-
/** @type {ChangeListener
|
|
79
|
-
const
|
|
162
|
+
/** @type {ReturnType<typeof fakeEvent<ChangeListener>>} */
|
|
163
|
+
const onChanged = fakeEvent();
|
|
80
164
|
/** @type {unknown[]} */
|
|
81
165
|
const running = [];
|
|
82
166
|
|
|
83
167
|
/**
|
|
84
168
|
* @param {string} name
|
|
85
169
|
* @param {Record<string, unknown>} initialData
|
|
86
|
-
* @param {{quotaPerItem?: number, quotaBytes?: number,
|
|
170
|
+
* @param {{quotaPerItem?: number, quotaBytes: number, maxItems?: number, limited?: boolean}} limits
|
|
87
171
|
* @returns {FakeArea}
|
|
88
172
|
*/
|
|
89
|
-
function area(name, initialData, { quotaPerItem, quotaBytes,
|
|
173
|
+
function area(name, initialData, { quotaPerItem, quotaBytes, maxItems, limited = false }) {
|
|
90
174
|
/** @type {Record<string, any>} */
|
|
91
|
-
const data =
|
|
92
|
-
/** @type {
|
|
93
|
-
const
|
|
175
|
+
const data = /** @type {Record<string, any>} */ (asStored(initialData));
|
|
176
|
+
/** @type {ReturnType<typeof fakeEvent<(changes: Record<string, StorageChange>) => unknown>>} */
|
|
177
|
+
const areaChanged = fakeEvent();
|
|
178
|
+
/** Each function's own limits, as Chrome counts them. @type {Map<string, (() => boolean)[]>} */
|
|
179
|
+
const limits = new Map();
|
|
94
180
|
/** @param {Record<string, StorageChange>} changes */
|
|
95
181
|
const fire = (changes) => {
|
|
96
182
|
if (!Object.keys(changes).length) return;
|
|
97
|
-
for (const listener of listeners) running.push(listener(
|
|
183
|
+
for (const listener of [...onChanged.listeners]) running.push(listener(copied(changes), name));
|
|
184
|
+
for (const listener of [...areaChanged.listeners]) running.push(listener(copied(changes)));
|
|
98
185
|
};
|
|
99
|
-
/**
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
186
|
+
/**
|
|
187
|
+
* A call counted against the function's limits (a minute's, an hour's); refused over one, as Chrome does.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} method - `set`, `remove` or `clear`.
|
|
190
|
+
*/
|
|
191
|
+
const count = (method) => {
|
|
192
|
+
if (!limited) return;
|
|
193
|
+
if (!limits.has(method)) limits.set(method, [callLimit(SYNC_WRITES_PER_MINUTE, 60_000, now), callLimit(SYNC_WRITES_PER_HOUR, 3_600_000, now)]);
|
|
194
|
+
const [minute, hour] = /** @type {(() => boolean)[]} */ (limits.get(method)).map((take) => take());
|
|
195
|
+
if (!minute) throw new Error('This request exceeds the MAX_WRITE_OPERATIONS_PER_MINUTE quota.');
|
|
196
|
+
if (!hour) throw new Error('This request exceeds the MAX_WRITE_OPERATIONS_PER_HOUR quota.');
|
|
107
197
|
};
|
|
198
|
+
/** @param {Record<string, unknown>} items @returns {number} */
|
|
199
|
+
const bytes = (items) => Object.entries(items).reduce((sum, [key, value]) => sum + syncItemBytes(key, value), 0);
|
|
108
200
|
/** @type {FakeArea} */
|
|
109
201
|
const fake = {
|
|
110
202
|
data,
|
|
111
203
|
writes: 0,
|
|
204
|
+
onChanged: areaChanged,
|
|
112
205
|
async get(keys) {
|
|
206
|
+
const defaults = keys !== null && typeof keys === 'object' && !Array.isArray(keys) ? /** @type {Record<string, unknown>} */ (asStored(keys)) : {};
|
|
207
|
+
const wanted = keys == null ? Object.keys(data) : typeof keys === 'string' ? [keys] : Array.isArray(keys) ? keys : Object.keys(keys);
|
|
208
|
+
return Object.fromEntries(wanted.filter((key) => key in data || key in defaults).map((key) => [key, copied(key in data ? data[key] : defaults[key])]));
|
|
209
|
+
},
|
|
210
|
+
async getBytesInUse(keys) {
|
|
113
211
|
const wanted = keys == null ? Object.keys(data) : [keys].flat();
|
|
114
|
-
return Object.fromEntries(wanted.filter((key) => key in data).map((key) => [key,
|
|
212
|
+
return bytes(Object.fromEntries(wanted.filter((key) => key in data).map((key) => [key, data[key]])));
|
|
115
213
|
},
|
|
116
214
|
async set(items) {
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
215
|
+
count('set');
|
|
216
|
+
const stored = /** @type {Record<string, unknown>} */ (asStored(items));
|
|
217
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
218
|
+
if (quotaPerItem && syncItemBytes(key, value) > quotaPerItem) throw new Error('Resource::kQuotaBytesPerItem quota exceeded');
|
|
121
219
|
}
|
|
122
|
-
|
|
123
|
-
|
|
220
|
+
const next = { ...data, ...stored };
|
|
221
|
+
if (bytes(next) > quotaBytes) throw new Error('Resource::kQuotaBytes quota exceeded');
|
|
222
|
+
if (maxItems && Object.keys(next).length > maxItems) throw new Error('Resource::kMaxItems quota exceeded');
|
|
223
|
+
fake.writes++;
|
|
124
224
|
/** @type {Record<string, StorageChange>} */
|
|
125
225
|
const changes = {};
|
|
126
|
-
for (const [key, value] of Object.entries(
|
|
127
|
-
if (
|
|
128
|
-
changes[key] = { oldValue: data[key], newValue:
|
|
129
|
-
data[key] =
|
|
226
|
+
for (const [key, value] of Object.entries(stored)) {
|
|
227
|
+
if (key in data && sameValue(data[key], value)) continue;
|
|
228
|
+
changes[key] = { ...(key in data ? { oldValue: data[key] } : {}), newValue: value };
|
|
229
|
+
data[key] = value;
|
|
130
230
|
}
|
|
131
231
|
fire(changes);
|
|
132
232
|
},
|
|
133
233
|
async remove(keys) {
|
|
134
|
-
|
|
234
|
+
count('remove');
|
|
235
|
+
fake.writes++;
|
|
135
236
|
/** @type {Record<string, StorageChange>} */
|
|
136
237
|
const changes = {};
|
|
137
238
|
for (const key of [keys].flat()) {
|
|
@@ -141,15 +242,26 @@ export function fakeChromeStorage({ local = {}, sync = {}, session = {}, now = D
|
|
|
141
242
|
}
|
|
142
243
|
fire(changes);
|
|
143
244
|
},
|
|
245
|
+
async clear() {
|
|
246
|
+
count('clear');
|
|
247
|
+
fake.writes++;
|
|
248
|
+
/** @type {Record<string, StorageChange>} */
|
|
249
|
+
const changes = {};
|
|
250
|
+
for (const key of Object.keys(data)) {
|
|
251
|
+
changes[key] = { oldValue: data[key] };
|
|
252
|
+
delete data[key];
|
|
253
|
+
}
|
|
254
|
+
fire(changes);
|
|
255
|
+
},
|
|
144
256
|
};
|
|
145
257
|
return fake;
|
|
146
258
|
}
|
|
147
259
|
|
|
148
260
|
return {
|
|
149
|
-
local: area('local', local),
|
|
150
|
-
sync: area('sync', sync, { quotaPerItem: MAX_SYNC_ITEM_BYTES, quotaBytes: SYNC_QUOTA_BYTES,
|
|
151
|
-
session: area('session', session),
|
|
152
|
-
onChanged
|
|
261
|
+
local: area('local', local, { quotaBytes: LOCAL_QUOTA_BYTES }),
|
|
262
|
+
sync: area('sync', sync, { quotaPerItem: MAX_SYNC_ITEM_BYTES, quotaBytes: SYNC_QUOTA_BYTES, maxItems: SYNC_MAX_ITEMS, limited: true }),
|
|
263
|
+
session: area('session', session, { quotaBytes: LOCAL_QUOTA_BYTES }),
|
|
264
|
+
onChanged,
|
|
153
265
|
async settled() {
|
|
154
266
|
while (running.length) await Promise.all(running.splice(0));
|
|
155
267
|
},
|
package/src/testing/index.d.ts
CHANGED
|
@@ -8,9 +8,9 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export { TEST_BUILDS_ENV, testBuildDir } from '../build/test-build.js';
|
|
10
10
|
export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
|
|
11
|
-
export { CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, runChecks, until, } from './e2e.js';
|
|
11
|
+
export { CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, runChecks, stopWorker, until, } from './e2e.js';
|
|
12
12
|
export { exitPoint, parseLiveOptions, parseProxy } from './live.js';
|
|
13
|
-
export { liveUsage, runLiveCheck } from './live-run.js';
|
|
13
|
+
export { REPORT_MARKER, liveUsage, reportFolder, runLiveCheck } from './live-run.js';
|
|
14
14
|
export { eachLimited } from '../time/pool.js';
|
|
15
15
|
export { filesIn, launchDownloading } from './downloads.js';
|
|
16
16
|
export { fakeLanguageModel } from './fake-model.js';
|
|
@@ -19,7 +19,7 @@ export { heartbeat } from './heartbeat.js';
|
|
|
19
19
|
export { playPermissionPrompt } from './prompt.js';
|
|
20
20
|
export { callTabModule, nodeAttribute, nodeText, pierceNodes, tabIdOf } from './pages.js';
|
|
21
21
|
export { serve, testCertificate } from './server.js';
|
|
22
|
-
export { SYNC_QUOTA_BYTES, SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
22
|
+
export { LOCAL_QUOTA_BYTES, SYNC_MAX_ITEMS, SYNC_QUOTA_BYTES, SYNC_WRITES_PER_HOUR, SYNC_WRITES_PER_MINUTE, fakeChromeStorage, } from './fake-storage.js';
|
|
23
23
|
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
|
24
24
|
export { savedTabGroups } from './saved-groups.js';
|
|
25
25
|
export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
|
package/src/testing/index.js
CHANGED
|
@@ -10,10 +10,10 @@
|
|
|
10
10
|
export { TEST_BUILDS_ENV, testBuildDir } from '../build/test-build.js';
|
|
11
11
|
export { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
|
|
12
12
|
export {
|
|
13
|
-
CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, runChecks, until,
|
|
13
|
+
CHECK_MS, checkRunner, checkToolbarTheme, extensionProfile, launchExtension, offscreenEmulator, recordIconCalls, runChecks, stopWorker, until,
|
|
14
14
|
} from './e2e.js';
|
|
15
15
|
export { exitPoint, parseLiveOptions, parseProxy } from './live.js';
|
|
16
|
-
export { liveUsage, runLiveCheck } from './live-run.js';
|
|
16
|
+
export { REPORT_MARKER, liveUsage, reportFolder, runLiveCheck } from './live-run.js';
|
|
17
17
|
export { eachLimited } from '../time/pool.js';
|
|
18
18
|
export { filesIn, launchDownloading } from './downloads.js';
|
|
19
19
|
export { fakeLanguageModel } from './fake-model.js';
|
|
@@ -22,7 +22,9 @@ export { heartbeat } from './heartbeat.js';
|
|
|
22
22
|
export { playPermissionPrompt } from './prompt.js';
|
|
23
23
|
export { callTabModule, nodeAttribute, nodeText, pierceNodes, tabIdOf } from './pages.js';
|
|
24
24
|
export { serve, testCertificate } from './server.js';
|
|
25
|
-
export {
|
|
25
|
+
export {
|
|
26
|
+
LOCAL_QUOTA_BYTES, SYNC_MAX_ITEMS, SYNC_QUOTA_BYTES, SYNC_WRITES_PER_HOUR, SYNC_WRITES_PER_MINUTE, fakeChromeStorage,
|
|
27
|
+
} from './fake-storage.js';
|
|
26
28
|
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
|
27
29
|
export { savedTabGroups } from './saved-groups.js';
|
|
28
30
|
export { installWarnings, warningsAddedBy, withoutPermissions } from './warnings.js';
|
|
@@ -80,10 +80,31 @@ export type LiveRun<S extends LiveSite, R> = {
|
|
|
80
80
|
* @returns {string} The text.
|
|
81
81
|
*/
|
|
82
82
|
export declare const liveUsage: (categories: string[], concurrency: number) => string;
|
|
83
|
+
/** The file that marks a folder as a live report the kit made, so the next run may replace it. */
|
|
84
|
+
export declare const REPORT_MARKER = ".live-report";
|
|
85
|
+
/**
|
|
86
|
+
* The folder a run's report goes in, made empty. Only a folder the kit made is ever emptied:
|
|
87
|
+
* the checker's own (`own`), or one carrying {@link REPORT_MARKER} from an earlier run. A new
|
|
88
|
+
* or empty folder is taken (and marked); a folder with anything else in it is refused, so
|
|
89
|
+
* `--out .` or `--out ~/Desktop` can't delete what's there.
|
|
90
|
+
*
|
|
91
|
+
* @param {string} dir - The folder.
|
|
92
|
+
* @param {{own?: boolean}} [options] - `own`: the checker's default folder, replaced without asking.
|
|
93
|
+
* @returns {Promise<string>} `dir`, empty but for the marker.
|
|
94
|
+
* @throws {Error} When the folder holds files and isn't a report folder.
|
|
95
|
+
*
|
|
96
|
+
* @example
|
|
97
|
+
* await reportFolder('/tmp/report'); // made, or emptied if an earlier run made it
|
|
98
|
+
*/
|
|
99
|
+
export declare function reportFolder(dir: string, { own }?: {
|
|
100
|
+
own?: boolean;
|
|
101
|
+
}): Promise<string>;
|
|
83
102
|
/**
|
|
84
103
|
* Run a live check: read the command line, visit the chosen sites, give back every visit.
|
|
85
104
|
* Prints the usage and exits for `--help` (or an option it doesn't know), and exits when no
|
|
86
|
-
* site matches.
|
|
105
|
+
* site matches or `--out` names a folder with other files in it ({@link reportFolder}). A visit
|
|
106
|
+
* that throws, runs out of time or loses its browser is `failed` with why, and the run goes on
|
|
107
|
+
* (the slot gets a new browser).
|
|
87
108
|
*
|
|
88
109
|
* @template {LiveSite} S
|
|
89
110
|
* @template R
|
|
@@ -96,7 +117,7 @@ export declare const liveUsage: (categories: string[], concurrency: number) => s
|
|
|
96
117
|
* @param {string[]} run.categories - The categories' ids, for the usage.
|
|
97
118
|
* @param {number} run.pageMs - The most a visit may take.
|
|
98
119
|
* @param {(browser: LaunchedExtension, page: Page, site: S) => Promise<R>} run.visit - Check one site, on a fresh page.
|
|
99
|
-
* @param {(site: S, error: string) => R} run.failed - A visit that couldn't finish
|
|
120
|
+
* @param {(site: S, error: string) => R} run.failed - A visit that couldn't finish: `'timed out'`, or the error's message.
|
|
100
121
|
* @param {(result: {site: S, visit: R}) => void} [run.onResult] - Each visit as it finishes (a line of output).
|
|
101
122
|
* @param {{argv?: string[], env?: Record<string, string | undefined>, concurrency?: number, findExit?: typeof exitPoint}} [run.command] - The
|
|
102
123
|
* arguments and environment (default the process's), the most sites at once (default 4), and how the exit point
|
package/src/testing/live-run.js
CHANGED
|
@@ -10,9 +10,9 @@
|
|
|
10
10
|
* @module testing/live-run
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { mkdir, rm } from 'node:fs/promises';
|
|
13
|
+
import { mkdir, readdir, rm, writeFile } from 'node:fs/promises';
|
|
14
14
|
import path from 'node:path';
|
|
15
|
-
import { within } from '../time/time.js';
|
|
15
|
+
import { deadline, within } from '../time/time.js';
|
|
16
16
|
import { eachLimited } from '../time/pool.js';
|
|
17
17
|
import { chromeUserAgent, closeOnSignal, realisticBrowser } from './browser.js';
|
|
18
18
|
import { launchExtension } from './e2e.js';
|
|
@@ -60,10 +60,43 @@ export const liveUsage = (categories, concurrency) => `Usage: npm run live -- [o
|
|
|
60
60
|
--out <dir> write the report there (default test/live/report)
|
|
61
61
|
--help`;
|
|
62
62
|
|
|
63
|
+
/** The file that marks a folder as a live report the kit made, so the next run may replace it. */
|
|
64
|
+
export const REPORT_MARKER = '.live-report';
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* The folder a run's report goes in, made empty. Only a folder the kit made is ever emptied:
|
|
68
|
+
* the checker's own (`own`), or one carrying {@link REPORT_MARKER} from an earlier run. A new
|
|
69
|
+
* or empty folder is taken (and marked); a folder with anything else in it is refused, so
|
|
70
|
+
* `--out .` or `--out ~/Desktop` can't delete what's there.
|
|
71
|
+
*
|
|
72
|
+
* @param {string} dir - The folder.
|
|
73
|
+
* @param {{own?: boolean}} [options] - `own`: the checker's default folder, replaced without asking.
|
|
74
|
+
* @returns {Promise<string>} `dir`, empty but for the marker.
|
|
75
|
+
* @throws {Error} When the folder holds files and isn't a report folder.
|
|
76
|
+
*
|
|
77
|
+
* @example
|
|
78
|
+
* await reportFolder('/tmp/report'); // made, or emptied if an earlier run made it
|
|
79
|
+
*/
|
|
80
|
+
export async function reportFolder(dir, { own = false } = {}) {
|
|
81
|
+
const entries = await readdir(dir).catch(() => /** @type {string[]} */ ([]));
|
|
82
|
+
if (entries.length && !own && !entries.includes(REPORT_MARKER)) {
|
|
83
|
+
throw new Error(`${dir} has files in it and isn't a report folder: choose a new or empty folder for --out`);
|
|
84
|
+
}
|
|
85
|
+
if (entries.length) await rm(dir, { recursive: true, force: true });
|
|
86
|
+
await mkdir(dir, { recursive: true });
|
|
87
|
+
await writeFile(path.join(dir, REPORT_MARKER), 'A live check\'s report: the next run replaces this folder.\n');
|
|
88
|
+
return dir;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Stands for a visit that ran out of time. */
|
|
92
|
+
const LATE = Symbol('late');
|
|
93
|
+
|
|
63
94
|
/**
|
|
64
95
|
* Run a live check: read the command line, visit the chosen sites, give back every visit.
|
|
65
96
|
* Prints the usage and exits for `--help` (or an option it doesn't know), and exits when no
|
|
66
|
-
* site matches.
|
|
97
|
+
* site matches or `--out` names a folder with other files in it ({@link reportFolder}). A visit
|
|
98
|
+
* that throws, runs out of time or loses its browser is `failed` with why, and the run goes on
|
|
99
|
+
* (the slot gets a new browser).
|
|
67
100
|
*
|
|
68
101
|
* @template {LiveSite} S
|
|
69
102
|
* @template R
|
|
@@ -76,7 +109,7 @@ export const liveUsage = (categories, concurrency) => `Usage: npm run live -- [o
|
|
|
76
109
|
* @param {string[]} run.categories - The categories' ids, for the usage.
|
|
77
110
|
* @param {number} run.pageMs - The most a visit may take.
|
|
78
111
|
* @param {(browser: LaunchedExtension, page: Page, site: S) => Promise<R>} run.visit - Check one site, on a fresh page.
|
|
79
|
-
* @param {(site: S, error: string) => R} run.failed - A visit that couldn't finish
|
|
112
|
+
* @param {(site: S, error: string) => R} run.failed - A visit that couldn't finish: `'timed out'`, or the error's message.
|
|
80
113
|
* @param {(result: {site: S, visit: R}) => void} [run.onResult] - Each visit as it finishes (a line of output).
|
|
81
114
|
* @param {{argv?: string[], env?: Record<string, string | undefined>, concurrency?: number, findExit?: typeof exitPoint}} [run.command] - The
|
|
82
115
|
* arguments and environment (default the process's), the most sites at once (default 4), and how the exit point
|
|
@@ -109,9 +142,14 @@ export async function runLiveCheck({ chromium, build, prefix, root, sites, categ
|
|
|
109
142
|
console.error('No pages match.');
|
|
110
143
|
process.exit(2);
|
|
111
144
|
}
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
145
|
+
/** @type {string} */
|
|
146
|
+
let dir;
|
|
147
|
+
try {
|
|
148
|
+
dir = await reportFolder(options.out ? path.resolve(options.out) : path.join(root, 'test', 'live', 'report'), { own: !options.out });
|
|
149
|
+
} catch (error) {
|
|
150
|
+
console.error(error instanceof Error ? error.message : error);
|
|
151
|
+
process.exit(2);
|
|
152
|
+
}
|
|
115
153
|
|
|
116
154
|
const browserOptions = realisticBrowser(options.proxy ? { proxy: options.proxy } : {});
|
|
117
155
|
const plain = await chromium.launch({ channel: 'chromium', ...browserOptions });
|
|
@@ -133,19 +171,27 @@ export async function runLiveCheck({ chromium, build, prefix, root, sites, categ
|
|
|
133
171
|
/** @type {{site: S, visit: R}[]} */
|
|
134
172
|
const results = [];
|
|
135
173
|
await eachLimited(chosen, options.concurrency, async (site, slot, index) => {
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
174
|
+
/** @type {R} */
|
|
175
|
+
let outcome;
|
|
176
|
+
try {
|
|
177
|
+
browsers[slot] ??= await launchExtension(chromium, build, { prefix: `${prefix}${slot}-`, ...launchOptions });
|
|
178
|
+
const launched = /** @type {LaunchedExtension} */ (browsers[slot]);
|
|
179
|
+
await within(launched.context.clearCookies(), 10_000, undefined);
|
|
180
|
+
const page = await launched.context.newPage();
|
|
181
|
+
try {
|
|
182
|
+
const result = await deadline(/** @type {Promise<R | typeof LATE>} */ (visit(launched, page, site)), pageMs, (resolve) => resolve(LATE));
|
|
183
|
+
if (result === LATE) throw new Error('timed out');
|
|
184
|
+
outcome = result;
|
|
185
|
+
} finally {
|
|
186
|
+
await within(page.close(), 15_000, undefined);
|
|
187
|
+
}
|
|
188
|
+
} catch (error) {
|
|
189
|
+
// A visit that failed may have left its browser stuck, or lost it: the slot gets a new one.
|
|
190
|
+
await within(Promise.resolve(browsers[slot]?.close()), 15_000, undefined);
|
|
146
191
|
browsers[slot] = null;
|
|
192
|
+
outcome = failed(site, error instanceof Error ? error.message : String(error));
|
|
147
193
|
}
|
|
148
|
-
results[index] = { site, visit:
|
|
194
|
+
results[index] = { site, visit: outcome };
|
|
149
195
|
onResult(results[index]);
|
|
150
196
|
});
|
|
151
197
|
return {
|
|
@@ -21,13 +21,17 @@ export type Messages = Record<string, {
|
|
|
21
21
|
*/
|
|
22
22
|
export declare function readMessages(extensionDir: string, locale: string): Messages;
|
|
23
23
|
/**
|
|
24
|
-
* `chrome.i18n.getMessage` over a locale's real messages
|
|
24
|
+
* `chrome.i18n.getMessage` over a locale's real messages, as Chrome reads them (checked against
|
|
25
|
+
* Chromium): names found whatever their case, named placeholders and `$1`–`$9` filled, `$$` a
|
|
26
|
+
* dollar sign, '' for a missing key, `undefined` for more than nine substitutions.
|
|
25
27
|
*
|
|
26
28
|
* @param {string} extensionDir - The extension's folder.
|
|
27
29
|
* @param {string} [locale] - Default "en"; another locale is read over en.
|
|
28
|
-
* @returns {(key: string, substitutions?: string | string[]) => string} The lookup.
|
|
30
|
+
* @returns {(key: string, substitutions?: string | number | (string | number)[]) => string} The lookup.
|
|
31
|
+
* @throws {Error} When a message names a placeholder it doesn't define (Chrome refuses to load the extension).
|
|
32
|
+
*
|
|
29
33
|
* @example
|
|
30
34
|
* const t = messageLookup('extension');
|
|
31
35
|
* t('optionsMoreActions', ['example.com']); // 'More actions for example.com'
|
|
32
36
|
*/
|
|
33
|
-
export declare function messageLookup(extensionDir: string, locale?: string): (key: string, substitutions?: string | string[]) => string;
|
|
37
|
+
export declare function messageLookup(extensionDir: string, locale?: string): (key: string, substitutions?: string | number | (string | number)[]) => string;
|
package/src/testing/messages.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
* Chrome's message lookup for an extension's unit tests: its real `_locales`
|
|
3
3
|
* files, a locale's messages over `en`'s, named placeholders ("$SITE$" →
|
|
4
4
|
* `{site: {content: '$1'}}`) filled from substitutions as
|
|
5
|
-
* `chrome.i18n.getMessage` fills them
|
|
5
|
+
* `chrome.i18n.getMessage` fills them, and refused as Chrome refuses them (a
|
|
6
|
+
* placeholder that isn't defined). Node only. From the copies in Better
|
|
6
7
|
* Prices, Better Downloads, Better Saves and Clean Links (`test/helpers/i18n.js`).
|
|
7
8
|
*
|
|
8
9
|
* @module testing/messages
|
|
@@ -29,23 +30,63 @@ export function readMessages(extensionDir, locale) {
|
|
|
29
30
|
return JSON.parse(readFileSync(path.join(extensionDir, '_locales', locale, 'messages.json'), 'utf8'));
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
/** A named placeholder in a message, as Chrome reads one: `$SITE$` (letters, digits, `_`, `@`). */
|
|
34
|
+
const PLACEHOLDER = /\$([A-Za-z0-9_@]+)\$/g;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A message with its named placeholders put in, as Chrome does when it loads an extension.
|
|
38
|
+
*
|
|
39
|
+
* @param {string} key - The message's name, for the error.
|
|
40
|
+
* @param {Messages[string]} entry - The message.
|
|
41
|
+
* @returns {string} The message, `$1`-style substitutions still to fill.
|
|
42
|
+
* @throws {Error} When it names a placeholder it doesn't define: Chrome refuses to load such an extension.
|
|
43
|
+
*/
|
|
44
|
+
function withPlaceholders(key, entry) {
|
|
45
|
+
const contents = new Map(Object.entries(entry.placeholders ?? {}).map(([name, { content }]) => [name.toLowerCase(), content]));
|
|
46
|
+
return entry.message.replace(PLACEHOLDER, (_, name) => {
|
|
47
|
+
const content = contents.get(name.toLowerCase());
|
|
48
|
+
if (content === undefined) throw new Error(`Message “${key}” uses $${name}$ but has no such placeholder: Chrome wouldn't load the extension`);
|
|
49
|
+
return content;
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
32
53
|
/**
|
|
33
|
-
*
|
|
54
|
+
* Fill a message's substitutions as Chrome does: `$1`–`$9` from the list ('' past its end), `$$`
|
|
55
|
+
* a dollar sign; a `$` before anything else is dropped with what follows it.
|
|
56
|
+
*
|
|
57
|
+
* @param {string} message - The message, named placeholders already put in.
|
|
58
|
+
* @param {readonly string[]} subs - The substitutions.
|
|
59
|
+
* @returns {string} The text.
|
|
60
|
+
*/
|
|
61
|
+
function substituted(message, subs) {
|
|
62
|
+
return message.replace(/\$(\$+|[\s\S])?/g, (_, next) => {
|
|
63
|
+
if (next === undefined) return '';
|
|
64
|
+
if (next.startsWith('$')) return next;
|
|
65
|
+
return /[1-9]/.test(next) ? String(subs[Number(next) - 1] ?? '') : '';
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* `chrome.i18n.getMessage` over a locale's real messages, as Chrome reads them (checked against
|
|
71
|
+
* Chromium): names found whatever their case, named placeholders and `$1`–`$9` filled, `$$` a
|
|
72
|
+
* dollar sign, '' for a missing key, `undefined` for more than nine substitutions.
|
|
34
73
|
*
|
|
35
74
|
* @param {string} extensionDir - The extension's folder.
|
|
36
75
|
* @param {string} [locale] - Default "en"; another locale is read over en.
|
|
37
|
-
* @returns {(key: string, substitutions?: string | string[]) => string} The lookup.
|
|
76
|
+
* @returns {(key: string, substitutions?: string | number | (string | number)[]) => string} The lookup.
|
|
77
|
+
* @throws {Error} When a message names a placeholder it doesn't define (Chrome refuses to load the extension).
|
|
78
|
+
*
|
|
38
79
|
* @example
|
|
39
80
|
* const t = messageLookup('extension');
|
|
40
81
|
* t('optionsMoreActions', ['example.com']); // 'More actions for example.com'
|
|
41
82
|
*/
|
|
42
83
|
export function messageLookup(extensionDir, locale = 'en') {
|
|
43
|
-
const
|
|
84
|
+
const read = { ...readMessages(extensionDir, 'en'), ...(locale === 'en' ? {} : readMessages(extensionDir, locale)) };
|
|
85
|
+
const messages = new Map(Object.entries(read).map(([key, entry]) => [key.toLowerCase(), withPlaceholders(key, entry)]));
|
|
44
86
|
return (key, substitutions = []) => {
|
|
45
|
-
const
|
|
46
|
-
if (
|
|
47
|
-
const
|
|
48
|
-
return
|
|
49
|
-
.replace(/\$(\d)/g, (/** @type {string} */ __, /** @type {string} */ n) => subs[Number(n) - 1] ?? ''));
|
|
87
|
+
const subs = [substitutions].flat().map(String);
|
|
88
|
+
if (subs.length > 9) return /** @type {string} */ (/** @type {unknown} */ (undefined));
|
|
89
|
+
const message = messages.get(key.toLowerCase());
|
|
90
|
+
return message === undefined ? '' : substituted(message, subs);
|
|
50
91
|
};
|
|
51
92
|
}
|