@arraypress/extension-kit 1.9.0 → 1.11.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 +145 -18
- 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/index.d.ts +2 -1
- package/src/build/index.js +2 -1
- package/src/build/store-shots.d.ts +177 -0
- package/src/build/store-shots.js +173 -0
- package/src/build/test-build.d.ts +13 -1
- package/src/build/test-build.js +41 -2
- package/src/build/vendor.d.ts +29 -8
- package/src/build/vendor.js +136 -40
- package/src/content/elements.d.ts +50 -0
- package/src/content/elements.js +88 -0
- package/src/content/highlight.d.ts +63 -0
- package/src/content/highlight.js +117 -0
- package/src/content/overlay.d.ts +31 -6
- package/src/content/overlay.js +56 -8
- package/src/content/page-toast.d.ts +26 -0
- package/src/content/page-toast.js +72 -0
- 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/copy.d.ts +36 -0
- package/src/dom/copy.js +84 -0
- package/src/dom/focus.d.ts +11 -0
- package/src/dom/focus.js +15 -0
- package/src/dom/i18n.d.ts +17 -0
- package/src/dom/i18n.js +15 -0
- package/src/dom/make.d.ts +53 -0
- package/src/dom/make.js +71 -0
- package/src/frame/window.d.ts +8 -1
- package/src/frame/window.js +11 -8
- package/src/index.d.ts +31 -6
- package/src/index.js +24 -6
- 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 +80 -0
- package/src/scripting/tab.js +89 -0
- 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 +63 -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 +8 -2
- package/src/testing/index.js +8 -2
- package/src/testing/live-run.d.ts +151 -0
- package/src/testing/live-run.js +206 -0
- 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/pages.d.ts +114 -0
- package/src/testing/pages.js +109 -0
- 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 +31 -11
- package/src/ui/model-row.js +5 -1
- package/src/ui/notice.d.ts +58 -0
- package/src/ui/notice.js +43 -0
- 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.js +2 -0
package/src/ai/host.js
ADDED
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chrome's on-device model for a service worker, wherever Chrome puts the
|
|
3
|
+
* Prompt API. Chrome's documentation says `LanguageModel` isn't in workers
|
|
4
|
+
* (https://developer.chrome.com/docs/ai/prompt-api); Chrome 157 and Chromium
|
|
5
|
+
* 156 do have it in an extension's service worker (checked in both: an
|
|
6
|
+
* unpacked extension, `typeof LanguageModel` in the worker, an extension page
|
|
7
|
+
* and an offscreen document, `'function'` in all three). So neither is
|
|
8
|
+
* promised, and a worker that uses the model shouldn't depend on which holds:
|
|
9
|
+
* {@link createHostedModelSession} is a `ModelSession` that asks the worker's
|
|
10
|
+
* own `LanguageModel` when there is one, and otherwise the extension's
|
|
11
|
+
* offscreen document, which has a page's Prompt API and answers over
|
|
12
|
+
* `chrome.runtime` messages ({@link serveModelOffscreen}).
|
|
13
|
+
*
|
|
14
|
+
* The offscreen document is the one the worker already keeps for the toolbar
|
|
15
|
+
* glyph (an extension gets only one): its script calls both
|
|
16
|
+
* `watchSchemeOffscreen` and `serveModelOffscreen`. It's told the session's
|
|
17
|
+
* options and instructions with each request, so it needs none of the
|
|
18
|
+
* extension's own code.
|
|
19
|
+
*
|
|
20
|
+
* @module ai/host
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { within } from '../time/time.js';
|
|
24
|
+
import { chromeLanguageModel, createModelSession } from './model.js';
|
|
25
|
+
|
|
26
|
+
/** @import { AskOptions, Availability, InitialPrompt, LanguageModelOptions, LanguageModelStatic, ModelAnswer, ModelSession } from './model.js' */
|
|
27
|
+
|
|
28
|
+
/** The request's message type: `{type: MODEL_HOST, method, name, …}`. */
|
|
29
|
+
export const MODEL_HOST = 'model-host';
|
|
30
|
+
|
|
31
|
+
/** How long past an answer's time limit the worker waits for the offscreen document to say so itself (ms). */
|
|
32
|
+
export const HOST_GRACE_MS = 1000;
|
|
33
|
+
|
|
34
|
+
/** The model's states, to check an answer that came over a message. */
|
|
35
|
+
const STATES = ['available', 'downloadable', 'downloading', 'unavailable', 'missing'];
|
|
36
|
+
|
|
37
|
+
/** Why a question got no answer, to check the same. */
|
|
38
|
+
const REASONS = ['unavailable', 'late', 'aborted', 'failed'];
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A request to the offscreen document.
|
|
42
|
+
*
|
|
43
|
+
* @typedef {object} HostRequest
|
|
44
|
+
* @property {typeof MODEL_HOST} type The message type.
|
|
45
|
+
* @property {'availability' | 'ask' | 'abort' | 'release'} method What's asked.
|
|
46
|
+
* @property {string} name The session's name (one base session each).
|
|
47
|
+
* @property {LanguageModelOptions} [options] What its sessions are made with.
|
|
48
|
+
* @property {InitialPrompt[]} [initialPrompts] Its instructions and examples.
|
|
49
|
+
* @property {string} [id] A question's id, for `abort`.
|
|
50
|
+
* @property {string} [prompt] The question.
|
|
51
|
+
* @property {number} [timeoutMs] Its time limit.
|
|
52
|
+
* @property {object} [responseConstraint] The answer's JSON Schema.
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The parts of `chrome.runtime` the worker's side uses.
|
|
57
|
+
*
|
|
58
|
+
* @typedef {{sendMessage(message: unknown): Promise<unknown>}} HostSender
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The parts of `chrome.runtime` the offscreen document's side uses.
|
|
63
|
+
*
|
|
64
|
+
* @typedef {object} HostRuntime
|
|
65
|
+
* @property {string} id This extension's id: requests from anyone else are ignored.
|
|
66
|
+
* @property {{addListener(listener: (message: unknown, sender: {id?: string, tab?: unknown}, respond: (answer: unknown) => void) => boolean): void}} onMessage
|
|
67
|
+
* `chrome.runtime.onMessage`.
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @typedef {object} HostedSessionSetup
|
|
72
|
+
* @property {string} name The session's name, its own among the extension's sessions ('topics').
|
|
73
|
+
* @property {LanguageModelOptions} options What sessions are made (and availability asked) with.
|
|
74
|
+
* @property {InitialPrompt[]} [initialPrompts] The instructions and examples every question starts from.
|
|
75
|
+
* @property {HostSender} runtime `chrome.runtime`.
|
|
76
|
+
* @property {() => Promise<unknown>} open Makes sure the offscreen document is open
|
|
77
|
+
* (`() => watchFromOffscreen(chrome)`); only called when the worker has no Prompt API.
|
|
78
|
+
* @property {() => LanguageModelStatic | undefined} [model] The worker's own Prompt API, read when used.
|
|
79
|
+
* Default Chrome's.
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A model session for a service worker: the worker's own Prompt API when it
|
|
84
|
+
* has one, else the offscreen document's. The same `ModelSession` either way.
|
|
85
|
+
* An answer's JSON Schema crosses to the offscreen document as JSON, so it
|
|
86
|
+
* must be a plain object there (not a RegExp).
|
|
87
|
+
*
|
|
88
|
+
* @param {HostedSessionSetup} setup - The session, and how to reach the offscreen document.
|
|
89
|
+
* @returns {ModelSession} The session.
|
|
90
|
+
* @example
|
|
91
|
+
* // background.js
|
|
92
|
+
* const words = createHostedModelSession({
|
|
93
|
+
* name: 'words', options: ENGLISH, initialPrompts: [{ role: 'system', content: 'You list words.' }],
|
|
94
|
+
* runtime: chrome.runtime, open: () => watchFromOffscreen(chrome),
|
|
95
|
+
* });
|
|
96
|
+
* const { text } = await words.ask('Topic: football', { timeoutMs: 20_000 });
|
|
97
|
+
*/
|
|
98
|
+
export function createHostedModelSession({ name, options, initialPrompts = [], runtime, open, model = chromeLanguageModel }) {
|
|
99
|
+
const local = createModelSession({ model, options, initialPrompts });
|
|
100
|
+
let asked = 0;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Ask the offscreen document.
|
|
104
|
+
*
|
|
105
|
+
* @param {HostRequest['method']} method
|
|
106
|
+
* @param {Partial<HostRequest>} [more]
|
|
107
|
+
* @returns {Promise<unknown>} Its answer; rejects when there's no document to hear it.
|
|
108
|
+
*/
|
|
109
|
+
const send = async (method, more = {}) => {
|
|
110
|
+
await open();
|
|
111
|
+
return runtime.sendMessage(/** @type {HostRequest} */ ({ type: MODEL_HOST, method, name, options, initialPrompts, ...more }));
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
return {
|
|
115
|
+
async availability() {
|
|
116
|
+
if (model()) return local.availability();
|
|
117
|
+
const state = await send('availability').catch(() => 'missing');
|
|
118
|
+
return STATES.includes(/** @type {string} */ (state)) ? /** @type {Availability} */ (state) : 'missing';
|
|
119
|
+
},
|
|
120
|
+
|
|
121
|
+
async ask(prompt, askOptions) {
|
|
122
|
+
if (model()) return local.ask(prompt, askOptions);
|
|
123
|
+
const { timeoutMs, signal, responseConstraint } = askOptions;
|
|
124
|
+
if (signal?.aborted) return { text: null, reason: 'aborted' };
|
|
125
|
+
const id = `${name}:${Date.now()}:${asked++}`;
|
|
126
|
+
const onAbort = () => { runtime.sendMessage({ type: MODEL_HOST, method: 'abort', name, id }).catch(() => {}); };
|
|
127
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
128
|
+
try {
|
|
129
|
+
// The document keeps the time limit; the worker's own is for a document that never answers.
|
|
130
|
+
const answer = /** @type {Partial<ModelAnswer> | 'gone' | 'late' | undefined} */ (await within(
|
|
131
|
+
send('ask', { id, prompt, timeoutMs, ...(responseConstraint && { responseConstraint }) }).catch(() => 'gone'),
|
|
132
|
+
timeoutMs + HOST_GRACE_MS,
|
|
133
|
+
'late',
|
|
134
|
+
));
|
|
135
|
+
if (answer === 'late') {
|
|
136
|
+
onAbort();
|
|
137
|
+
return { text: null, reason: signal?.aborted ? 'aborted' : 'late' };
|
|
138
|
+
}
|
|
139
|
+
if (answer && typeof answer === 'object') {
|
|
140
|
+
if (typeof answer.text === 'string' && answer.reason === 'answered') return { text: answer.text, reason: 'answered' };
|
|
141
|
+
if (REASONS.includes(/** @type {string} */ (answer.reason))) return { text: null, reason: /** @type {Exclude<ModelAnswer['reason'], 'answered'>} */ (answer.reason) };
|
|
142
|
+
}
|
|
143
|
+
return { text: null, reason: signal?.aborted ? 'aborted' : 'unavailable' };
|
|
144
|
+
} finally {
|
|
145
|
+
signal?.removeEventListener('abort', onAbort);
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
release() {
|
|
150
|
+
local.release();
|
|
151
|
+
if (!model()) runtime.sendMessage({ type: MODEL_HOST, method: 'release', name }).catch(() => {});
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Answer the worker's model requests, in the offscreen document: one base
|
|
158
|
+
* session per name, made with the options and instructions the request
|
|
159
|
+
* carries (and again if they change). Requests from another extension, or
|
|
160
|
+
* from a content script (a web page's side), are ignored.
|
|
161
|
+
*
|
|
162
|
+
* @param {HostRuntime} runtime - `chrome.runtime`.
|
|
163
|
+
* @param {{model?: () => LanguageModelStatic | undefined}} [options] - The document's Prompt API. Default Chrome's.
|
|
164
|
+
* @returns {void}
|
|
165
|
+
* @example
|
|
166
|
+
* // offscreen/offscreen.js
|
|
167
|
+
* watchSchemeOffscreen(chrome.runtime);
|
|
168
|
+
* serveModelOffscreen(chrome.runtime);
|
|
169
|
+
*/
|
|
170
|
+
export function serveModelOffscreen(runtime, { model = chromeLanguageModel } = {}) {
|
|
171
|
+
/** Each name's session, and the setup it was made with. @type {Map<string, {setup: string, session: ModelSession}>} */
|
|
172
|
+
const sessions = new Map();
|
|
173
|
+
/** The questions being answered, to stop one. @type {Map<string, AbortController>} */
|
|
174
|
+
const asking = new Map();
|
|
175
|
+
|
|
176
|
+
/** @param {HostRequest} request @returns {ModelSession} */
|
|
177
|
+
const sessionFor = ({ name, options = {}, initialPrompts = [] }) => {
|
|
178
|
+
const setup = JSON.stringify([options, initialPrompts]);
|
|
179
|
+
const kept = sessions.get(name);
|
|
180
|
+
if (kept?.setup === setup) return kept.session;
|
|
181
|
+
kept?.session.release();
|
|
182
|
+
const session = createModelSession({ model, options, initialPrompts });
|
|
183
|
+
sessions.set(name, { setup, session });
|
|
184
|
+
return session;
|
|
185
|
+
};
|
|
186
|
+
|
|
187
|
+
/** @param {HostRequest} request @returns {Promise<unknown>} */
|
|
188
|
+
const answer = async (request) => {
|
|
189
|
+
switch (request.method) {
|
|
190
|
+
case 'availability':
|
|
191
|
+
return sessionFor(request).availability();
|
|
192
|
+
case 'ask': {
|
|
193
|
+
const stop = new AbortController();
|
|
194
|
+
const id = String(request.id);
|
|
195
|
+
asking.set(id, stop);
|
|
196
|
+
try {
|
|
197
|
+
return await sessionFor(request).ask(String(request.prompt ?? ''), /** @type {AskOptions} */ ({
|
|
198
|
+
timeoutMs: Number(request.timeoutMs) || 0,
|
|
199
|
+
signal: stop.signal,
|
|
200
|
+
...(request.responseConstraint && { responseConstraint: request.responseConstraint }),
|
|
201
|
+
}));
|
|
202
|
+
} finally {
|
|
203
|
+
asking.delete(id);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
case 'abort':
|
|
207
|
+
asking.get(String(request.id))?.abort();
|
|
208
|
+
return null;
|
|
209
|
+
case 'release':
|
|
210
|
+
sessions.get(request.name)?.session.release();
|
|
211
|
+
sessions.delete(request.name);
|
|
212
|
+
return null;
|
|
213
|
+
default:
|
|
214
|
+
return null;
|
|
215
|
+
}
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
runtime.onMessage.addListener((message, sender, respond) => {
|
|
219
|
+
const request = /** @type {HostRequest | null | undefined} */ (message);
|
|
220
|
+
if (request?.type !== MODEL_HOST || typeof request.name !== 'string') return false;
|
|
221
|
+
if (sender.id !== runtime.id || sender.tab) return false;
|
|
222
|
+
answer(request).then(respond, () => respond(null));
|
|
223
|
+
return true;
|
|
224
|
+
});
|
|
225
|
+
}
|
package/src/ai/model.d.ts
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* only downloads from a user gesture) and which reports progress;
|
|
10
10
|
* - one base session, with the instructions and examples, is made once and
|
|
11
11
|
* kept; each question goes to a `clone()` of it, so answers don't leak into
|
|
12
|
-
* each other;
|
|
12
|
+
* each other; a base session Chrome has let go since (it can't be cloned)
|
|
13
|
+
* is made again;
|
|
13
14
|
* - each answer has a time limit: an `AbortSignal` stops the answer once
|
|
14
15
|
* it's late, and the caller gets a reason instead of a hang;
|
|
15
16
|
* - the first session after Chrome starts can take ~18 s while the model
|
package/src/ai/model.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
* only downloads from a user gesture) and which reports progress;
|
|
10
10
|
* - one base session, with the instructions and examples, is made once and
|
|
11
11
|
* kept; each question goes to a `clone()` of it, so answers don't leak into
|
|
12
|
-
* each other;
|
|
12
|
+
* each other; a base session Chrome has let go since (it can't be cloned)
|
|
13
|
+
* is made again;
|
|
13
14
|
* - each answer has a time limit: an `AbortSignal` stops the answer once
|
|
14
15
|
* it's late, and the caller gets a reason instead of a hang;
|
|
15
16
|
* - the first session after Chrome starts can take ~18 s while the model
|
|
@@ -265,7 +266,14 @@ export function createModelSession({ model, options, initialPrompts = [] }) {
|
|
|
265
266
|
* @returns {Promise<string>} The answer.
|
|
266
267
|
*/
|
|
267
268
|
async function ask(api, prompt, signal, responseConstraint) {
|
|
268
|
-
const
|
|
269
|
+
const made = baseSession(api);
|
|
270
|
+
const kept = await made;
|
|
271
|
+
const session = await kept.clone({ signal }).catch(async (error) => {
|
|
272
|
+
if (signal.aborted) throw error;
|
|
273
|
+
// Chrome let the base session go (the model was updated, or its process ended): a new one, once.
|
|
274
|
+
if (base === made) base = null;
|
|
275
|
+
return (await baseSession(api)).clone({ signal });
|
|
276
|
+
});
|
|
269
277
|
try {
|
|
270
278
|
return await session.prompt(prompt, { signal, ...(responseConstraint && { responseConstraint }) });
|
|
271
279
|
} finally {
|
package/src/build/index.d.ts
CHANGED
|
@@ -7,7 +7,8 @@
|
|
|
7
7
|
export { clonesFromReport, disallowedClones, findDisallowedClones } from './duplication.js';
|
|
8
8
|
export { placeholderWarning, printReleaseChecks, repositoryReminder } from './release-checks.js';
|
|
9
9
|
export * as storeFrame from './store-frame.js';
|
|
10
|
-
export {
|
|
10
|
+
export { PANEL_HEADER_H, PANEL_W, contextMenuHtml, pngUrl, storeShotHtml, storeShots } from './store-shots.js';
|
|
11
|
+
export { HOOK_FILE, PANEL_OPENER, TEST_BUILDS_ENV, buildTestCopy, testBuildDir } from './test-build.js';
|
|
11
12
|
export { ICON_SIZES, TOOLBAR_COLORS, TOOLBAR_ICON_COLORS, TOOLBAR_ICON_SIZES, renderIcons, renderToolbarIcons, toolbarIconFile, } from './toolbar-icons.js';
|
|
12
13
|
export { RUNTIME_ENTRIES, STYLESHEET, VENDOR_STAMP, checkVendored, packageDir, vendorKit, vendorPackage } from './vendor.js';
|
|
13
14
|
export { ZIP_EXCLUDE, shipsFile, zipExtension } from './zip.js';
|
package/src/build/index.js
CHANGED
|
@@ -8,7 +8,8 @@
|
|
|
8
8
|
export { clonesFromReport, disallowedClones, findDisallowedClones } from './duplication.js';
|
|
9
9
|
export { placeholderWarning, printReleaseChecks, repositoryReminder } from './release-checks.js';
|
|
10
10
|
export * as storeFrame from './store-frame.js';
|
|
11
|
-
export {
|
|
11
|
+
export { PANEL_HEADER_H, PANEL_W, contextMenuHtml, pngUrl, storeShotHtml, storeShots } from './store-shots.js';
|
|
12
|
+
export { HOOK_FILE, PANEL_OPENER, TEST_BUILDS_ENV, buildTestCopy, testBuildDir } from './test-build.js';
|
|
12
13
|
export {
|
|
13
14
|
ICON_SIZES, TOOLBAR_COLORS, TOOLBAR_ICON_COLORS, TOOLBAR_ICON_SIZES, renderIcons, renderToolbarIcons, toolbarIconFile,
|
|
14
15
|
} from './toolbar-icons.js';
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store screenshots for the whole family, in one style: a caption band in sentence case over
|
|
3
|
+
* Chrome's window (light theme) with the extension pinned in the toolbar, real captures inside.
|
|
4
|
+
* What headless Chrome can't capture is composed around the real extension UI, as Chrome draws
|
|
5
|
+
* it: the toolbar popup hanging from the extension's button (the popup page captured on its own),
|
|
6
|
+
* the side panel beside the page (the panel page captured on its own) and the right-click menu
|
|
7
|
+
* (drawn, with the extension's real menu titles).
|
|
8
|
+
*
|
|
9
|
+
* {@link storeShotHtml} lays one out (pure); {@link storeShots} renders them in a Playwright
|
|
10
|
+
* page at 2× and writes 1280×800 PNGs. Captures are taken at 2× too
|
|
11
|
+
* (`deviceScaleFactor: 2`) and shown at their CSS size, so text stays sharp.
|
|
12
|
+
*
|
|
13
|
+
* @module build/store-shots
|
|
14
|
+
*/
|
|
15
|
+
import type { Tab, Theme } from '../frame/window.js';
|
|
16
|
+
import type { Geometry } from './store-frame.js';
|
|
17
|
+
/** @import { Tab, Theme } from '../frame/window.js' */
|
|
18
|
+
/** @import { Geometry } from './store-frame.js' */
|
|
19
|
+
/** The side panel's width, as Chrome opens it (px). */
|
|
20
|
+
export declare const PANEL_W = 360;
|
|
21
|
+
/** The side panel's header: the extension's icon and name, and Chrome's close button (px). Capture a panel page `PANEL_HEADER_H` shorter than the content. */
|
|
22
|
+
export declare const PANEL_HEADER_H = 40;
|
|
23
|
+
export type MenuItem = {
|
|
24
|
+
/**
|
|
25
|
+
* Its words (none: a separator).
|
|
26
|
+
*/
|
|
27
|
+
label?: string;
|
|
28
|
+
/**
|
|
29
|
+
* A 16px icon's URL (the extension's, beside its own item).
|
|
30
|
+
*/
|
|
31
|
+
icon?: string;
|
|
32
|
+
/**
|
|
33
|
+
* It opens a submenu (an arrow).
|
|
34
|
+
*/
|
|
35
|
+
submenu?: boolean;
|
|
36
|
+
/**
|
|
37
|
+
* The pointer is over it.
|
|
38
|
+
*/
|
|
39
|
+
highlighted?: boolean;
|
|
40
|
+
/**
|
|
41
|
+
* Greyed out.
|
|
42
|
+
*/
|
|
43
|
+
disabled?: boolean;
|
|
44
|
+
};
|
|
45
|
+
/**
|
|
46
|
+
* One row of the right-click menu.
|
|
47
|
+
*
|
|
48
|
+
* @typedef {object} MenuItem
|
|
49
|
+
* @property {string} [label] Its words (none: a separator).
|
|
50
|
+
* @property {string} [icon] A 16px icon's URL (the extension's, beside its own item).
|
|
51
|
+
* @property {boolean} [submenu] It opens a submenu (an arrow).
|
|
52
|
+
* @property {boolean} [highlighted] The pointer is over it.
|
|
53
|
+
* @property {boolean} [disabled] Greyed out.
|
|
54
|
+
*/
|
|
55
|
+
/**
|
|
56
|
+
* The right-click menu as macOS draws Chrome's (a native menu): rounded, translucent, 13px rows,
|
|
57
|
+
* the row under the pointer in the accent colour; a submenu beside its highlighted row. Its class
|
|
58
|
+
* names start `ctx` (the window's own styles have `.sep`, `.tab`…).
|
|
59
|
+
*
|
|
60
|
+
* @param {object} menu - What to draw.
|
|
61
|
+
* @param {number} menu.x - Its left edge, in the window's content area (px).
|
|
62
|
+
* @param {number} menu.y - Its top edge, in the content area.
|
|
63
|
+
* @param {readonly MenuItem[]} menu.items - Its rows.
|
|
64
|
+
* @param {readonly MenuItem[]} [menu.submenu] - The highlighted row's submenu.
|
|
65
|
+
* @returns {string} The HTML, positioned in the content area.
|
|
66
|
+
*/
|
|
67
|
+
export declare function contextMenuHtml({ x, y, items, submenu }: {
|
|
68
|
+
x: number;
|
|
69
|
+
y: number;
|
|
70
|
+
items: readonly MenuItem[];
|
|
71
|
+
submenu?: readonly MenuItem[];
|
|
72
|
+
}): string;
|
|
73
|
+
export type Shot = {
|
|
74
|
+
/**
|
|
75
|
+
* The caption, sentence case, a few words.
|
|
76
|
+
*/
|
|
77
|
+
caption: string;
|
|
78
|
+
/**
|
|
79
|
+
* The window's tabs.
|
|
80
|
+
*/
|
|
81
|
+
tabs: readonly Tab[];
|
|
82
|
+
/**
|
|
83
|
+
* The address bar's text.
|
|
84
|
+
*/
|
|
85
|
+
url: string;
|
|
86
|
+
/**
|
|
87
|
+
* The start of `url` shown in full; the rest dimmed.
|
|
88
|
+
*/
|
|
89
|
+
urlHost?: string;
|
|
90
|
+
/**
|
|
91
|
+
* The page's capture (a data: URL), at the content's size (less the side panel's when there's one).
|
|
92
|
+
*/
|
|
93
|
+
page: string;
|
|
94
|
+
/**
|
|
95
|
+
* The popup's capture and its size: hung from the extension's toolbar button.
|
|
96
|
+
*/
|
|
97
|
+
popup?: {
|
|
98
|
+
image: string;
|
|
99
|
+
width: number;
|
|
100
|
+
height: number;
|
|
101
|
+
};
|
|
102
|
+
/**
|
|
103
|
+
* The side panel's capture (`PANEL_W` wide, the content's height less `PANEL_HEADER_H`), the extension's name and icon for its header.
|
|
104
|
+
*/
|
|
105
|
+
panel?: {
|
|
106
|
+
image: string;
|
|
107
|
+
title: string;
|
|
108
|
+
icon: string;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* A right-click menu over the page.
|
|
112
|
+
*/
|
|
113
|
+
menu?: Parameters<typeof contextMenuHtml>[0];
|
|
114
|
+
/**
|
|
115
|
+
* The mouse pointer, in the content area.
|
|
116
|
+
*/
|
|
117
|
+
pointer?: {
|
|
118
|
+
x: number;
|
|
119
|
+
y: number;
|
|
120
|
+
};
|
|
121
|
+
};
|
|
122
|
+
/**
|
|
123
|
+
* One store screenshot.
|
|
124
|
+
*
|
|
125
|
+
* @typedef {object} Shot
|
|
126
|
+
* @property {string} caption The caption, sentence case, a few words.
|
|
127
|
+
* @property {readonly Tab[]} tabs The window's tabs.
|
|
128
|
+
* @property {string} url The address bar's text.
|
|
129
|
+
* @property {string} [urlHost] The start of `url` shown in full; the rest dimmed.
|
|
130
|
+
* @property {string} page The page's capture (a data: URL), at the content's size (less the side panel's when there's one).
|
|
131
|
+
* @property {{image: string, width: number, height: number}} [popup] The popup's capture and its size: hung from the extension's toolbar button.
|
|
132
|
+
* @property {{image: string, title: string, icon: string}} [panel] The side panel's capture (`PANEL_W` wide, the content's height less `PANEL_HEADER_H`), the extension's name and icon for its header.
|
|
133
|
+
* @property {Parameters<typeof contextMenuHtml>[0]} [menu] A right-click menu over the page.
|
|
134
|
+
* @property {{x: number, y: number}} [pointer] The mouse pointer, in the content area.
|
|
135
|
+
*/
|
|
136
|
+
/**
|
|
137
|
+
* The HTML of one store screenshot: the caption, Chrome's window with the extension's toolbar
|
|
138
|
+
* button, and the captures; a popup hangs from the button (shown pressed), a side panel sits at
|
|
139
|
+
* the window's right, a menu over the page.
|
|
140
|
+
*
|
|
141
|
+
* @param {Shot & {toolbarIcon: string, theme?: Theme, geo?: Geometry}} shot - What to draw; the extension's toolbar icon (a data: URL).
|
|
142
|
+
* @returns {string} A whole HTML document, {@link W}×{@link H}.
|
|
143
|
+
*/
|
|
144
|
+
export declare function storeShotHtml({ caption, tabs, url, urlHost, page, popup, panel, menu, pointer, toolbarIcon, theme, geo }: Shot & {
|
|
145
|
+
toolbarIcon: string;
|
|
146
|
+
theme?: Theme;
|
|
147
|
+
geo?: Geometry;
|
|
148
|
+
}): string;
|
|
149
|
+
/**
|
|
150
|
+
* A data: URL of a PNG.
|
|
151
|
+
*
|
|
152
|
+
* @param {Uint8Array} png - The PNG's bytes.
|
|
153
|
+
* @returns {string} The URL.
|
|
154
|
+
*/
|
|
155
|
+
export declare const pngUrl: (png: Uint8Array) => string;
|
|
156
|
+
/**
|
|
157
|
+
* A store-screenshot writer: lays each shot out with {@link storeShotHtml} in a page of the
|
|
158
|
+
* given browser at 2×, waits for its images, and saves it as a {@link W}×{@link H} PNG.
|
|
159
|
+
*
|
|
160
|
+
* @param {object} options - Where and how.
|
|
161
|
+
* @param {import('playwright').Browser | import('playwright').BrowserContext} options.browser - A browser (or a context) to draw in.
|
|
162
|
+
* @param {string} options.dir - The folder to write to (made if missing).
|
|
163
|
+
* @param {string} options.toolbarIcon - The extension's toolbar icon, a data: URL.
|
|
164
|
+
* @returns {{geo: Geometry, shoot: (file: string, shot: Shot) => Promise<string>}} The window's geometry
|
|
165
|
+
* (capture pages at `geo.content`), and `shoot`, which writes one and returns its path.
|
|
166
|
+
* @example
|
|
167
|
+
* const { geo, shoot } = storeShots({ browser, dir: 'store/screenshots', toolbarIcon });
|
|
168
|
+
* await shoot('1-menu.png', { caption: 'Copy any page as Markdown', tabs, url, page: pngUrl(await page.screenshot()) });
|
|
169
|
+
*/
|
|
170
|
+
export declare function storeShots({ browser, dir, toolbarIcon }: {
|
|
171
|
+
browser: import('playwright').Browser | import('playwright').BrowserContext;
|
|
172
|
+
dir: string;
|
|
173
|
+
toolbarIcon: string;
|
|
174
|
+
}): {
|
|
175
|
+
geo: Geometry;
|
|
176
|
+
shoot: (file: string, shot: Shot) => Promise<string>;
|
|
177
|
+
};
|
|
@@ -0,0 +1,173 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Store screenshots for the whole family, in one style: a caption band in sentence case over
|
|
3
|
+
* Chrome's window (light theme) with the extension pinned in the toolbar, real captures inside.
|
|
4
|
+
* What headless Chrome can't capture is composed around the real extension UI, as Chrome draws
|
|
5
|
+
* it: the toolbar popup hanging from the extension's button (the popup page captured on its own),
|
|
6
|
+
* the side panel beside the page (the panel page captured on its own) and the right-click menu
|
|
7
|
+
* (drawn, with the extension's real menu titles).
|
|
8
|
+
*
|
|
9
|
+
* {@link storeShotHtml} lays one out (pure); {@link storeShots} renders them in a Playwright
|
|
10
|
+
* page at 2× and writes 1280×800 PNGs. Captures are taken at 2× too
|
|
11
|
+
* (`deviceScaleFactor: 2`) and shown at their CSS size, so text stays sharp.
|
|
12
|
+
*
|
|
13
|
+
* @module build/store-shots
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { mkdir, writeFile } from 'node:fs/promises';
|
|
17
|
+
import path from 'node:path';
|
|
18
|
+
import { esc } from '../frame/window.js';
|
|
19
|
+
import { H, W, WIN, canvas, cursor, geometry, windowChrome } from './store-frame.js';
|
|
20
|
+
|
|
21
|
+
/** @import { Tab, Theme } from '../frame/window.js' */
|
|
22
|
+
/** @import { Geometry } from './store-frame.js' */
|
|
23
|
+
|
|
24
|
+
/** The side panel's width, as Chrome opens it (px). */
|
|
25
|
+
export const PANEL_W = 360;
|
|
26
|
+
|
|
27
|
+
/** The side panel's header: the extension's icon and name, and Chrome's close button (px). Capture a panel page `PANEL_HEADER_H` shorter than the content. */
|
|
28
|
+
export const PANEL_HEADER_H = 40;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* One row of the right-click menu.
|
|
32
|
+
*
|
|
33
|
+
* @typedef {object} MenuItem
|
|
34
|
+
* @property {string} [label] Its words (none: a separator).
|
|
35
|
+
* @property {string} [icon] A 16px icon's URL (the extension's, beside its own item).
|
|
36
|
+
* @property {boolean} [submenu] It opens a submenu (an arrow).
|
|
37
|
+
* @property {boolean} [highlighted] The pointer is over it.
|
|
38
|
+
* @property {boolean} [disabled] Greyed out.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The right-click menu as macOS draws Chrome's (a native menu): rounded, translucent, 13px rows,
|
|
43
|
+
* the row under the pointer in the accent colour; a submenu beside its highlighted row. Its class
|
|
44
|
+
* names start `ctx` (the window's own styles have `.sep`, `.tab`…).
|
|
45
|
+
*
|
|
46
|
+
* @param {object} menu - What to draw.
|
|
47
|
+
* @param {number} menu.x - Its left edge, in the window's content area (px).
|
|
48
|
+
* @param {number} menu.y - Its top edge, in the content area.
|
|
49
|
+
* @param {readonly MenuItem[]} menu.items - Its rows.
|
|
50
|
+
* @param {readonly MenuItem[]} [menu.submenu] - The highlighted row's submenu.
|
|
51
|
+
* @returns {string} The HTML, positioned in the content area.
|
|
52
|
+
*/
|
|
53
|
+
export function contextMenuHtml({ x, y, items, submenu }) {
|
|
54
|
+
/** @param {readonly MenuItem[]} rows @returns {string} */
|
|
55
|
+
const list = (rows) => rows.map((row) => (row.label === undefined
|
|
56
|
+
? '<div class="ctx-sep"></div>'
|
|
57
|
+
: `<div class="ctx-row${row.highlighted ? ' on' : ''}${row.disabled ? ' off' : ''}"><span class="ctx-ic">${row.icon ? `<img src="${row.icon}" width="16" height="16" alt="">` : ''}</span><span class="ctx-label">${esc(row.label)}</span>${row.submenu ? '<span class="ctx-arrow">›</span>' : ''}</div>`)).join('');
|
|
58
|
+
const at = items.findIndex((row) => row.highlighted && row.submenu);
|
|
59
|
+
const top = (/** @type {number} */ index) => 5 + items.slice(0, index).reduce((sum, row) => sum + (row.label === undefined ? 11 : 22), 0);
|
|
60
|
+
return `<div class="ctx" style="left:${x}px;top:${y}px">${list(items)}</div>`
|
|
61
|
+
+ (submenu && at >= 0 ? `<div class="ctx" style="left:${x + 236}px;top:${y + top(at) - 5}px">${list(submenu)}</div>` : '')
|
|
62
|
+
+ `<style>
|
|
63
|
+
.ctx { position: absolute; z-index: 10; width: 240px; box-sizing: border-box; padding: 5px; border-radius: 10px; background: rgba(246,246,246,.96);
|
|
64
|
+
box-shadow: 0 0 0 .5px rgba(0,0,0,.22), 0 10px 30px rgba(0,0,0,.22); font: 13px/22px system-ui; color: #1d1d1f; }
|
|
65
|
+
.ctx-row { display: flex; align-items: center; height: 22px; padding: 0 8px 0 4px; border-radius: 5px; white-space: nowrap; }
|
|
66
|
+
.ctx-row.on { background: #0a82ff; color: #fff; }
|
|
67
|
+
.ctx-row.off { color: #b0b0b0; }
|
|
68
|
+
.ctx-ic { width: 22px; flex: none; display: flex; }
|
|
69
|
+
.ctx-label { flex: 1; overflow: hidden; text-overflow: ellipsis; }
|
|
70
|
+
.ctx-arrow { flex: none; font-size: 15px; opacity: .7; }
|
|
71
|
+
.ctx-sep { height: 1px; margin: 5px 8px; background: rgba(0,0,0,.11); }
|
|
72
|
+
</style>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* One store screenshot.
|
|
77
|
+
*
|
|
78
|
+
* @typedef {object} Shot
|
|
79
|
+
* @property {string} caption The caption, sentence case, a few words.
|
|
80
|
+
* @property {readonly Tab[]} tabs The window's tabs.
|
|
81
|
+
* @property {string} url The address bar's text.
|
|
82
|
+
* @property {string} [urlHost] The start of `url` shown in full; the rest dimmed.
|
|
83
|
+
* @property {string} page The page's capture (a data: URL), at the content's size (less the side panel's when there's one).
|
|
84
|
+
* @property {{image: string, width: number, height: number}} [popup] The popup's capture and its size: hung from the extension's toolbar button.
|
|
85
|
+
* @property {{image: string, title: string, icon: string}} [panel] The side panel's capture (`PANEL_W` wide, the content's height less `PANEL_HEADER_H`), the extension's name and icon for its header.
|
|
86
|
+
* @property {Parameters<typeof contextMenuHtml>[0]} [menu] A right-click menu over the page.
|
|
87
|
+
* @property {{x: number, y: number}} [pointer] The mouse pointer, in the content area.
|
|
88
|
+
*/
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* The HTML of one store screenshot: the caption, Chrome's window with the extension's toolbar
|
|
92
|
+
* button, and the captures; a popup hangs from the button (shown pressed), a side panel sits at
|
|
93
|
+
* the window's right, a menu over the page.
|
|
94
|
+
*
|
|
95
|
+
* @param {Shot & {toolbarIcon: string, theme?: Theme, geo?: Geometry}} shot - What to draw; the extension's toolbar icon (a data: URL).
|
|
96
|
+
* @returns {string} A whole HTML document, {@link W}×{@link H}.
|
|
97
|
+
*/
|
|
98
|
+
export function storeShotHtml({ caption, tabs, url, urlHost, page, popup, panel, menu, pointer, toolbarIcon, theme = 'light', geo = geometry(1) }) {
|
|
99
|
+
const { width: cw, height: ch } = geo.content;
|
|
100
|
+
const pageW = panel ? cw - PANEL_W : cw;
|
|
101
|
+
const content = `<div style="position:relative;display:flex;width:${cw}px;height:${ch}px">
|
|
102
|
+
<img src="${page}" width="${pageW}" height="${ch}" alt="" style="display:block;flex:none">
|
|
103
|
+
${panel ? `<div class="panel"><div class="panel-head"><img src="${panel.icon}" width="16" height="16" alt=""><span>${esc(panel.title)}</span><svg viewBox="0 0 24 24" width="18" height="18"><path fill="#474747" d="M19 6.41 17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12z"/></svg></div>
|
|
104
|
+
<img src="${panel.image}" width="${PANEL_W}" height="${ch - PANEL_HEADER_H}" alt="" style="display:block"></div>` : ''}
|
|
105
|
+
${menu ? contextMenuHtml(menu) : ''}
|
|
106
|
+
${pointer ? cursor(pointer.x, pointer.y) : ''}
|
|
107
|
+
</div>
|
|
108
|
+
<style>
|
|
109
|
+
.panel { flex: none; width: ${PANEL_W}px; height: ${ch}px; background: #fff; border-left: 1px solid #d3d7dc; box-sizing: content-box; }
|
|
110
|
+
.panel-head { height: ${PANEL_HEADER_H}px; display: flex; align-items: center; gap: 10px; padding: 0 12px 0 14px; font: 500 13px system-ui; color: #1f1f1f; }
|
|
111
|
+
.panel-head span { flex: 1; }
|
|
112
|
+
</style>`;
|
|
113
|
+
const overlays = popup ? `<div class="popup" style="width:${popup.width}px;height:${popup.height}px"><img src="${popup.image}" width="${popup.width}" height="${popup.height}" alt=""></div>
|
|
114
|
+
<style>
|
|
115
|
+
.popup { position: absolute; z-index: 30; border-radius: 10px; overflow: hidden; background: #fff; box-shadow: 0 0 0 .5px rgba(0,0,0,.2), 0 4px 12px rgba(0,0,0,.14), 0 12px 32px rgba(0,0,0,.16); }
|
|
116
|
+
.popup img { display: block; }
|
|
117
|
+
.toolbar > img.pressed { border-radius: 50%; background: #e3e3e3; box-shadow: 0 0 0 9px #e3e3e3; }
|
|
118
|
+
</style>
|
|
119
|
+
<script>
|
|
120
|
+
// Hang the popup from the extension's button, as Chrome does: its right edge near the button's, just under the toolbar.
|
|
121
|
+
const button = document.querySelector('.toolbar > img');
|
|
122
|
+
button.classList.add('pressed');
|
|
123
|
+
const box = button.getBoundingClientRect();
|
|
124
|
+
const popupBox = document.querySelector('.popup');
|
|
125
|
+
popupBox.style.left = (box.right + 14 - ${popup.width}) + 'px';
|
|
126
|
+
popupBox.style.top = (${WIN.y + WIN.strip + WIN.toolbar} - 2) + 'px';
|
|
127
|
+
</script>` : '';
|
|
128
|
+
return canvas({ caption: esc(caption), theme, chrome: windowChrome({ tabs, url, urlHost, theme, w: geo.w, ext: [toolbarIcon] }), content, overlays, geo });
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* A data: URL of a PNG.
|
|
133
|
+
*
|
|
134
|
+
* @param {Uint8Array} png - The PNG's bytes.
|
|
135
|
+
* @returns {string} The URL.
|
|
136
|
+
*/
|
|
137
|
+
export const pngUrl = (png) => `data:image/png;base64,${Buffer.from(png).toString('base64')}`;
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* A store-screenshot writer: lays each shot out with {@link storeShotHtml} in a page of the
|
|
141
|
+
* given browser at 2×, waits for its images, and saves it as a {@link W}×{@link H} PNG.
|
|
142
|
+
*
|
|
143
|
+
* @param {object} options - Where and how.
|
|
144
|
+
* @param {import('playwright').Browser | import('playwright').BrowserContext} options.browser - A browser (or a context) to draw in.
|
|
145
|
+
* @param {string} options.dir - The folder to write to (made if missing).
|
|
146
|
+
* @param {string} options.toolbarIcon - The extension's toolbar icon, a data: URL.
|
|
147
|
+
* @returns {{geo: Geometry, shoot: (file: string, shot: Shot) => Promise<string>}} The window's geometry
|
|
148
|
+
* (capture pages at `geo.content`), and `shoot`, which writes one and returns its path.
|
|
149
|
+
* @example
|
|
150
|
+
* const { geo, shoot } = storeShots({ browser, dir: 'store/screenshots', toolbarIcon });
|
|
151
|
+
* await shoot('1-menu.png', { caption: 'Copy any page as Markdown', tabs, url, page: pngUrl(await page.screenshot()) });
|
|
152
|
+
*/
|
|
153
|
+
export function storeShots({ browser, dir, toolbarIcon }) {
|
|
154
|
+
const geo = geometry(1);
|
|
155
|
+
return {
|
|
156
|
+
geo,
|
|
157
|
+
shoot: async (file, shot) => {
|
|
158
|
+
const context = 'newContext' in browser ? await browser.newContext({ viewport: { width: W, height: H }, deviceScaleFactor: 2 }) : null;
|
|
159
|
+
const page = context ? await context.newPage() : await /** @type {import('playwright').BrowserContext} */ (browser).newPage();
|
|
160
|
+
try {
|
|
161
|
+
await page.setViewportSize({ width: W, height: H });
|
|
162
|
+
await page.setContent(storeShotHtml({ ...shot, toolbarIcon, geo }), { waitUntil: 'load' });
|
|
163
|
+
await page.evaluate(() => Promise.all([...document.images].map((img) => img.decode().catch(() => undefined))));
|
|
164
|
+
await mkdir(dir, { recursive: true });
|
|
165
|
+
const out = path.join(dir, file);
|
|
166
|
+
await writeFile(out, await page.screenshot({ type: 'png', scale: 'css', clip: { x: 0, y: 0, width: W, height: H } }));
|
|
167
|
+
return out;
|
|
168
|
+
} finally {
|
|
169
|
+
await (context ?? page).close();
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
}
|
|
@@ -54,6 +54,12 @@ export type TestBuild = {
|
|
|
54
54
|
* Changes the copy's manifest in place.
|
|
55
55
|
*/
|
|
56
56
|
manifest?: (manifest: TestManifest) => void;
|
|
57
|
+
/**
|
|
58
|
+
* A side panel page (`panel/panel.html`): adds {@link PANEL_OPENER}(`?tab=<id>`), whose button opens that panel beside the tab the way the toolbar click does
|
|
59
|
+
* (the kit's `openSidePanel`, during a real click), which Playwright can't click. Its title turns
|
|
60
|
+
* `opened` (or `error: …`). The kit must be vendored at `vendor/extension-kit/`.
|
|
61
|
+
*/
|
|
62
|
+
panelOpener?: string;
|
|
57
63
|
};
|
|
58
64
|
/**
|
|
59
65
|
* A Chrome extension manifest, as far as a test build changes it.
|
|
@@ -70,7 +76,13 @@ export type TestBuild = {
|
|
|
70
76
|
* imports the real worker and exposes what a test can't reach headlessly (a menu click, the
|
|
71
77
|
* toolbar button).
|
|
72
78
|
* @property {(manifest: TestManifest) => void} [manifest] Changes the copy's manifest in place.
|
|
79
|
+
* @property {string} [panelOpener] A side panel page (`panel/panel.html`): adds {@link PANEL_OPENER}
|
|
80
|
+
* (`?tab=<id>`), whose button opens that panel beside the tab the way the toolbar click does
|
|
81
|
+
* (the kit's `openSidePanel`, during a real click), which Playwright can't click. Its title turns
|
|
82
|
+
* `opened` (or `error: …`). The kit must be vendored at `vendor/extension-kit/`.
|
|
73
83
|
*/
|
|
84
|
+
/** The page {@link TestBuild}'s `panelOpener` adds, from the build's root. */
|
|
85
|
+
export declare const PANEL_OPENER = "e2e/open-panel.html";
|
|
74
86
|
/**
|
|
75
87
|
* Copy an extension folder into a test build, with its own hook and manifest.
|
|
76
88
|
*
|
|
@@ -82,4 +94,4 @@ export type TestBuild = {
|
|
|
82
94
|
* manifest: (manifest) => { manifest.host_permissions = ['http://127.0.0.1/*']; },
|
|
83
95
|
* });
|
|
84
96
|
*/
|
|
85
|
-
export declare function buildTestCopy({ from, to, hook, manifest: change }: TestBuild): Promise<string>;
|
|
97
|
+
export declare function buildTestCopy({ from, to, hook, manifest: change, panelOpener }: TestBuild): Promise<string>;
|