@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.
@@ -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
+ }
@@ -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>;
@@ -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
+ }
@@ -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';
@@ -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
+ }