@arraypress/extension-kit 1.7.0 → 1.9.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 +64 -3
- package/package.json +1 -1
- package/src/build/store-frame.d.ts +10 -91
- package/src/build/store-frame.js +11 -159
- package/src/build/vendor.js +1 -1
- package/src/content/overlay.d.ts +47 -0
- package/src/content/overlay.js +42 -0
- package/src/content/slices.d.ts +16 -1
- package/src/content/slices.js +29 -10
- package/src/frame/window.d.ts +182 -0
- package/src/frame/window.js +299 -0
- package/src/index.d.ts +18 -3
- package/src/index.js +13 -3
- package/src/panel/side-panel.d.ts +35 -0
- package/src/panel/side-panel.js +41 -0
- package/src/scripting/call.d.ts +63 -0
- package/src/scripting/call.js +57 -0
- package/src/settings/saver.d.ts +13 -0
- package/src/settings/saver.js +11 -0
- package/src/testing/heartbeat.d.ts +23 -0
- package/src/testing/heartbeat.js +43 -0
- package/src/testing/index.d.ts +2 -0
- package/src/testing/index.js +2 -0
- package/src/testing/prompt.d.ts +20 -0
- package/src/testing/prompt.js +29 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calling one of an extension module's functions in a page: `executeScript`
|
|
3
|
+
* runs a small function in the page's isolated world that imports the module
|
|
4
|
+
* (by its extension address, so it must be listed in `web_accessible_resources`)
|
|
5
|
+
* and calls the export named, with arguments that survive structured cloning.
|
|
6
|
+
* The module, and anything it keeps, stays loaded in that world between calls,
|
|
7
|
+
* so a content script can hold a session (a picker, an inspector) across them.
|
|
8
|
+
*
|
|
9
|
+
* @module scripting/call
|
|
10
|
+
*/
|
|
11
|
+
export type CallScripting = Pick<typeof chrome.scripting, 'executeScript'>;
|
|
12
|
+
export type FrameResult<T> = {
|
|
13
|
+
/**
|
|
14
|
+
* The frame (0 is the top one).
|
|
15
|
+
*/
|
|
16
|
+
frameId: number;
|
|
17
|
+
/**
|
|
18
|
+
* The function's result, once its promise settled.
|
|
19
|
+
*/
|
|
20
|
+
result: T;
|
|
21
|
+
};
|
|
22
|
+
/**
|
|
23
|
+
* The parts of `chrome.scripting` a call uses.
|
|
24
|
+
*
|
|
25
|
+
* @typedef {Pick<typeof chrome.scripting, 'executeScript'>} CallScripting
|
|
26
|
+
*/
|
|
27
|
+
/**
|
|
28
|
+
* What one frame's call gave back.
|
|
29
|
+
*
|
|
30
|
+
* @template T
|
|
31
|
+
* @typedef {object} FrameResult
|
|
32
|
+
* @property {number} frameId The frame (0 is the top one).
|
|
33
|
+
* @property {T} result The function's result, once its promise settled.
|
|
34
|
+
*/
|
|
35
|
+
/**
|
|
36
|
+
* The function `executeScript` runs in the page: alone, without closures.
|
|
37
|
+
*
|
|
38
|
+
* @param {string} module - The module's path in the extension, `content/main.js`.
|
|
39
|
+
* @param {string} name - The export to call.
|
|
40
|
+
* @param {unknown[]} args - Its arguments.
|
|
41
|
+
* @returns {Promise<unknown>} What it returns.
|
|
42
|
+
*/
|
|
43
|
+
export declare const importAndCall: (module: string, name: string, args: unknown[]) => Promise<unknown>;
|
|
44
|
+
/**
|
|
45
|
+
* Call `name` from the extension module `module` in the target's frames.
|
|
46
|
+
*
|
|
47
|
+
* @template [T=unknown]
|
|
48
|
+
* @param {CallScripting} scripting - `chrome.scripting` (or a fake in tests).
|
|
49
|
+
* @param {chrome.scripting.InjectionTarget} target - The tab, and which frames (`allFrames`, `frameIds`).
|
|
50
|
+
* @param {string} module - The module's path in the extension (listed in `web_accessible_resources`).
|
|
51
|
+
* @param {string} name - The exported function.
|
|
52
|
+
* @param {unknown[]} [args] - Its arguments (structured-cloneable).
|
|
53
|
+
* @param {{immediately?: boolean}} [options] - `immediately` (default true): don't wait for the
|
|
54
|
+
* page to be idle, which a page still loading ads or a player may not be for a long time.
|
|
55
|
+
* @returns {Promise<FrameResult<T>[]>} Each frame's result, frames Chrome couldn't run in left out;
|
|
56
|
+
* rejects when it can run in none (a page Chrome keeps to itself, no access).
|
|
57
|
+
* @example
|
|
58
|
+
* const [{ result }] = await callInPage(chrome.scripting, { tabId }, 'content/main.js', 'scan', [options]);
|
|
59
|
+
* await callInPage(chrome.scripting, { tabId, allFrames: true }, 'content/main.js', 'highlight', [key]);
|
|
60
|
+
*/
|
|
61
|
+
export declare function callInPage<T = unknown>(scripting: CallScripting, target: chrome.scripting.InjectionTarget, module: string, name: string, args?: unknown[], { immediately }?: {
|
|
62
|
+
immediately?: boolean;
|
|
63
|
+
}): Promise<FrameResult<T>[]>;
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Calling one of an extension module's functions in a page: `executeScript`
|
|
3
|
+
* runs a small function in the page's isolated world that imports the module
|
|
4
|
+
* (by its extension address, so it must be listed in `web_accessible_resources`)
|
|
5
|
+
* and calls the export named, with arguments that survive structured cloning.
|
|
6
|
+
* The module, and anything it keeps, stays loaded in that world between calls,
|
|
7
|
+
* so a content script can hold a session (a picker, an inspector) across them.
|
|
8
|
+
*
|
|
9
|
+
* @module scripting/call
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The parts of `chrome.scripting` a call uses.
|
|
14
|
+
*
|
|
15
|
+
* @typedef {Pick<typeof chrome.scripting, 'executeScript'>} CallScripting
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What one frame's call gave back.
|
|
20
|
+
*
|
|
21
|
+
* @template T
|
|
22
|
+
* @typedef {object} FrameResult
|
|
23
|
+
* @property {number} frameId The frame (0 is the top one).
|
|
24
|
+
* @property {T} result The function's result, once its promise settled.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The function `executeScript` runs in the page: alone, without closures.
|
|
29
|
+
*
|
|
30
|
+
* @param {string} module - The module's path in the extension, `content/main.js`.
|
|
31
|
+
* @param {string} name - The export to call.
|
|
32
|
+
* @param {unknown[]} args - Its arguments.
|
|
33
|
+
* @returns {Promise<unknown>} What it returns.
|
|
34
|
+
*/
|
|
35
|
+
export const importAndCall = (module, name, args) => import(chrome.runtime.getURL(module)).then((loaded) => loaded[name](...args));
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Call `name` from the extension module `module` in the target's frames.
|
|
39
|
+
*
|
|
40
|
+
* @template [T=unknown]
|
|
41
|
+
* @param {CallScripting} scripting - `chrome.scripting` (or a fake in tests).
|
|
42
|
+
* @param {chrome.scripting.InjectionTarget} target - The tab, and which frames (`allFrames`, `frameIds`).
|
|
43
|
+
* @param {string} module - The module's path in the extension (listed in `web_accessible_resources`).
|
|
44
|
+
* @param {string} name - The exported function.
|
|
45
|
+
* @param {unknown[]} [args] - Its arguments (structured-cloneable).
|
|
46
|
+
* @param {{immediately?: boolean}} [options] - `immediately` (default true): don't wait for the
|
|
47
|
+
* page to be idle, which a page still loading ads or a player may not be for a long time.
|
|
48
|
+
* @returns {Promise<FrameResult<T>[]>} Each frame's result, frames Chrome couldn't run in left out;
|
|
49
|
+
* rejects when it can run in none (a page Chrome keeps to itself, no access).
|
|
50
|
+
* @example
|
|
51
|
+
* const [{ result }] = await callInPage(chrome.scripting, { tabId }, 'content/main.js', 'scan', [options]);
|
|
52
|
+
* await callInPage(chrome.scripting, { tabId, allFrames: true }, 'content/main.js', 'highlight', [key]);
|
|
53
|
+
*/
|
|
54
|
+
export async function callInPage(scripting, target, module, name, args = [], { immediately = true } = {}) {
|
|
55
|
+
const frames = await scripting.executeScript({ target, injectImmediately: immediately, func: importAndCall, args: [module, name, args] });
|
|
56
|
+
return frames.map((frame) => ({ frameId: frame.frameId, result: /** @type {T} */ (frame.result) }));
|
|
57
|
+
}
|
package/src/settings/saver.d.ts
CHANGED
|
@@ -41,6 +41,14 @@ export type SettingsSaver<S extends Record<string, unknown>> = {
|
|
|
41
41
|
* Cancel the timers (tests, or a page being torn down).
|
|
42
42
|
*/
|
|
43
43
|
stop: () => void;
|
|
44
|
+
/**
|
|
45
|
+
* Hear the settings change elsewhere (another tab, another computer through sync):
|
|
46
|
+
* `onChange` gets them all, this page's changes not yet written laid over them. `events` is
|
|
47
|
+
* `chrome.storage.onChanged`.
|
|
48
|
+
*/
|
|
49
|
+
follow: (onChange: (settings: S) => void, events: {
|
|
50
|
+
addListener(listener: (changes: Record<string, import('./storage.js').StorageChange>, area: string) => void): void;
|
|
51
|
+
}) => void;
|
|
44
52
|
};
|
|
45
53
|
/** @import { SettingsStore } from './store.js' */
|
|
46
54
|
/** @import { SyncedStorage } from './storage.js' */
|
|
@@ -59,6 +67,10 @@ export type SettingsSaver<S extends Record<string, unknown>> = {
|
|
|
59
67
|
* @property {() => Promise<unknown>} flush Write what's queued now (call it as the page is hidden);
|
|
60
68
|
* resolves once every write so far has finished.
|
|
61
69
|
* @property {() => void} stop Cancel the timers (tests, or a page being torn down).
|
|
70
|
+
* @property {(onChange: (settings: S) => void, events: {addListener(listener: (changes: Record<string, import('./storage.js').StorageChange>, area: string) => void): void}) => void} follow
|
|
71
|
+
* Hear the settings change elsewhere (another tab, another computer through sync):
|
|
72
|
+
* `onChange` gets them all, this page's changes not yet written laid over them. `events` is
|
|
73
|
+
* `chrome.storage.onChanged`.
|
|
62
74
|
*/
|
|
63
75
|
/**
|
|
64
76
|
* Make a debounced saver over a settings store.
|
|
@@ -72,5 +84,6 @@ export type SettingsSaver<S extends Record<string, unknown>> = {
|
|
|
72
84
|
* const saver = createSettingsSaver(store, chrome.storage, { onSaved: showSaved });
|
|
73
85
|
* toggle.addEventListener('change', () => saver.save({ compact: toggle.checked }));
|
|
74
86
|
* addEventListener('pagehide', () => saver.flush());
|
|
87
|
+
* saver.follow((settings) => show(settings), chrome.storage.onChanged);
|
|
75
88
|
*/
|
|
76
89
|
export declare function createSettingsSaver<S extends Record<string, unknown>>(store: SettingsStore<S>, storage: SyncedStorage, { delay, retryAfter, onSaved }?: SaverOptions<S>): SettingsSaver<S>;
|
package/src/settings/saver.js
CHANGED
|
@@ -27,6 +27,10 @@
|
|
|
27
27
|
* @property {() => Promise<unknown>} flush Write what's queued now (call it as the page is hidden);
|
|
28
28
|
* resolves once every write so far has finished.
|
|
29
29
|
* @property {() => void} stop Cancel the timers (tests, or a page being torn down).
|
|
30
|
+
* @property {(onChange: (settings: S) => void, events: {addListener(listener: (changes: Record<string, import('./storage.js').StorageChange>, area: string) => void): void}) => void} follow
|
|
31
|
+
* Hear the settings change elsewhere (another tab, another computer through sync):
|
|
32
|
+
* `onChange` gets them all, this page's changes not yet written laid over them. `events` is
|
|
33
|
+
* `chrome.storage.onChanged`.
|
|
30
34
|
*/
|
|
31
35
|
|
|
32
36
|
/**
|
|
@@ -41,6 +45,7 @@
|
|
|
41
45
|
* const saver = createSettingsSaver(store, chrome.storage, { onSaved: showSaved });
|
|
42
46
|
* toggle.addEventListener('change', () => saver.save({ compact: toggle.checked }));
|
|
43
47
|
* addEventListener('pagehide', () => saver.flush());
|
|
48
|
+
* saver.follow((settings) => show(settings), chrome.storage.onChanged);
|
|
44
49
|
*/
|
|
45
50
|
export function createSettingsSaver(store, storage, { delay = 1000, retryAfter = 60_000, onSaved = () => {} } = {}) {
|
|
46
51
|
/** @type {Partial<S>} */
|
|
@@ -90,6 +95,12 @@ export function createSettingsSaver(store, storage, { delay = 1000, retryAfter =
|
|
|
90
95
|
timer = setTimeout(flush, delay);
|
|
91
96
|
},
|
|
92
97
|
flush,
|
|
98
|
+
follow(onChange, events) {
|
|
99
|
+
events.addListener(async (changes, area) => {
|
|
100
|
+
if (!store.changed(changes, area).length) return;
|
|
101
|
+
onChange(store.normalize({ ...(await store.load(storage)), ...pending }));
|
|
102
|
+
});
|
|
103
|
+
},
|
|
93
104
|
stop() {
|
|
94
105
|
clearTimeout(timer);
|
|
95
106
|
clearTimeout(retryTimer);
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a page's main thread is held, for performance checks: a heartbeat in the page. From
|
|
3
|
+
* Topicless' and Better Screenshots' e2e helpers.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/heartbeat
|
|
6
|
+
*/
|
|
7
|
+
import type { Page } from 'playwright';
|
|
8
|
+
/** @import { Page } from 'playwright' */
|
|
9
|
+
/**
|
|
10
|
+
* Start a heartbeat in a page: a timer that fires again and again, costing nothing between
|
|
11
|
+
* beats; the longest gap between two is the longest the main thread was held (the Long Tasks
|
|
12
|
+
* API misses an extension's isolated world's work, and a `MessageChannel` heartbeat counts as
|
|
13
|
+
* task time itself). Gaps while the tab is hidden (another tab in front) don't count: Chrome
|
|
14
|
+
* throttles a hidden tab's timers.
|
|
15
|
+
*
|
|
16
|
+
* @param {Page} page - The page.
|
|
17
|
+
* @returns {Promise<() => Promise<number>>} Stops it and gives the longest gap (ms).
|
|
18
|
+
* @example
|
|
19
|
+
* const stop = await heartbeat(page);
|
|
20
|
+
* await doTheWork();
|
|
21
|
+
* const held = await stop(); // e.g. 17
|
|
22
|
+
*/
|
|
23
|
+
export declare function heartbeat(page: Page): Promise<() => Promise<number>>;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How long a page's main thread is held, for performance checks: a heartbeat in the page. From
|
|
3
|
+
* Topicless' and Better Screenshots' e2e helpers.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/heartbeat
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** @import { Page } from 'playwright' */
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Start a heartbeat in a page: a timer that fires again and again, costing nothing between
|
|
12
|
+
* beats; the longest gap between two is the longest the main thread was held (the Long Tasks
|
|
13
|
+
* API misses an extension's isolated world's work, and a `MessageChannel` heartbeat counts as
|
|
14
|
+
* task time itself). Gaps while the tab is hidden (another tab in front) don't count: Chrome
|
|
15
|
+
* throttles a hidden tab's timers.
|
|
16
|
+
*
|
|
17
|
+
* @param {Page} page - The page.
|
|
18
|
+
* @returns {Promise<() => Promise<number>>} Stops it and gives the longest gap (ms).
|
|
19
|
+
* @example
|
|
20
|
+
* const stop = await heartbeat(page);
|
|
21
|
+
* await doTheWork();
|
|
22
|
+
* const held = await stop(); // e.g. 17
|
|
23
|
+
*/
|
|
24
|
+
export async function heartbeat(page) {
|
|
25
|
+
await page.evaluate(() => {
|
|
26
|
+
const beat = { longest: 0, running: true };
|
|
27
|
+
Object.assign(window, { extensionKitBeat: beat });
|
|
28
|
+
let last = performance.now();
|
|
29
|
+
const tick = () => {
|
|
30
|
+
const now = performance.now();
|
|
31
|
+
// A hidden tab's timers are throttled by Chrome: those gaps aren't the page being held.
|
|
32
|
+
if (!document.hidden) beat.longest = Math.max(beat.longest, now - last);
|
|
33
|
+
last = now;
|
|
34
|
+
if (beat.running) setTimeout(tick, 0);
|
|
35
|
+
};
|
|
36
|
+
setTimeout(tick, 0);
|
|
37
|
+
});
|
|
38
|
+
return () => page.evaluate(() => {
|
|
39
|
+
const beat = /** @type {{longest: number, running: boolean}} */ (/** @type {any} */ (window).extensionKitBeat);
|
|
40
|
+
beat.running = false;
|
|
41
|
+
return beat.longest;
|
|
42
|
+
});
|
|
43
|
+
}
|
package/src/testing/index.d.ts
CHANGED
|
@@ -14,6 +14,8 @@ export { eachLimited } from '../time/pool.js';
|
|
|
14
14
|
export { filesIn, launchDownloading } from './downloads.js';
|
|
15
15
|
export { fakeLanguageModel } from './fake-model.js';
|
|
16
16
|
export { messageLookup, readMessages } from './messages.js';
|
|
17
|
+
export { heartbeat } from './heartbeat.js';
|
|
18
|
+
export { playPermissionPrompt } from './prompt.js';
|
|
17
19
|
export { serve, testCertificate } from './server.js';
|
|
18
20
|
export { SYNC_QUOTA_BYTES, SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
19
21
|
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
package/src/testing/index.js
CHANGED
|
@@ -17,6 +17,8 @@ export { eachLimited } from '../time/pool.js';
|
|
|
17
17
|
export { filesIn, launchDownloading } from './downloads.js';
|
|
18
18
|
export { fakeLanguageModel } from './fake-model.js';
|
|
19
19
|
export { messageLookup, readMessages } from './messages.js';
|
|
20
|
+
export { heartbeat } from './heartbeat.js';
|
|
21
|
+
export { playPermissionPrompt } from './prompt.js';
|
|
20
22
|
export { serve, testCertificate } from './server.js';
|
|
21
23
|
export { SYNC_QUOTA_BYTES, SYNC_WRITES_PER_MINUTE, fakeChromeStorage } from './fake-storage.js';
|
|
22
24
|
export { filesUnder, manifestReferences, missingReferences, referencedPath, zipEntries } from './package.js';
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's permission prompt, played in an extension page for an e2e test: Playwright can't
|
|
3
|
+
* click Chrome's own prompt. From Topicless' and Better Screenshots' e2e helpers.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/prompt
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Stand in for Chrome's permission prompt in a page: each `chrome.permissions.request` is
|
|
9
|
+
* recorded on `window.asked` (what was asked, and whether a click was behind it,
|
|
10
|
+
* `navigator.userActivation.isActive`) and answered from `answers` (the next one; the last is
|
|
11
|
+
* kept), as a person clicking “Allow” or “Deny” would. Runs in the page: give it to
|
|
12
|
+
* `page.evaluate` or `page.addInitScript`, so it takes no closures.
|
|
13
|
+
*
|
|
14
|
+
* @param {boolean[]} answers - The answers, in order.
|
|
15
|
+
* @example
|
|
16
|
+
* await page.evaluate(playPermissionPrompt, [true]);
|
|
17
|
+
* await page.click('#download');
|
|
18
|
+
* const asked = await page.evaluate(() => window.asked); // [{ permissions: ['downloads'], origins: undefined, gesture: true }]
|
|
19
|
+
*/
|
|
20
|
+
export declare function playPermissionPrompt(answers: boolean[]): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's permission prompt, played in an extension page for an e2e test: Playwright can't
|
|
3
|
+
* click Chrome's own prompt. From Topicless' and Better Screenshots' e2e helpers.
|
|
4
|
+
*
|
|
5
|
+
* @module testing/prompt
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Stand in for Chrome's permission prompt in a page: each `chrome.permissions.request` is
|
|
10
|
+
* recorded on `window.asked` (what was asked, and whether a click was behind it,
|
|
11
|
+
* `navigator.userActivation.isActive`) and answered from `answers` (the next one; the last is
|
|
12
|
+
* kept), as a person clicking “Allow” or “Deny” would. Runs in the page: give it to
|
|
13
|
+
* `page.evaluate` or `page.addInitScript`, so it takes no closures.
|
|
14
|
+
*
|
|
15
|
+
* @param {boolean[]} answers - The answers, in order.
|
|
16
|
+
* @example
|
|
17
|
+
* await page.evaluate(playPermissionPrompt, [true]);
|
|
18
|
+
* await page.click('#download');
|
|
19
|
+
* const asked = await page.evaluate(() => window.asked); // [{ permissions: ['downloads'], origins: undefined, gesture: true }]
|
|
20
|
+
*/
|
|
21
|
+
export function playPermissionPrompt(answers) {
|
|
22
|
+
/** @type {{permissions?: string[], origins?: string[], gesture: boolean}[]} */
|
|
23
|
+
const asked = [];
|
|
24
|
+
Object.assign(window, { asked });
|
|
25
|
+
chrome.permissions.request = /** @type {typeof chrome.permissions.request} */ (/** @type {unknown} */ (async (/** @type {chrome.permissions.Permissions} */ permissions) => {
|
|
26
|
+
asked.push({ permissions: permissions.permissions, origins: permissions.origins, gesture: navigator.userActivation.isActive });
|
|
27
|
+
return answers.length > 1 ? answers.shift() : answers[0];
|
|
28
|
+
}));
|
|
29
|
+
}
|