@arraypress/extension-kit 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Drawing kit for store screenshots: a 1280×800 canvas with a caption band and a Chrome window
3
+ * (macOS, light or dark, default theme). Everything here is presentation around real captures;
4
+ * nothing is a feature of the extension.
5
+ *
6
+ * Window metrics follow Chrome's 2023 refresh on macOS (tabs 34px high in a 42px strip, a 46px
7
+ * toolbar with a pill omnibox).
8
+ *
9
+ * Use it from an extension's `store/tools/make-screenshots.mjs`: capture the page content with
10
+ * Playwright at `geometry(zoom).content`, then screenshot `canvas({caption, chrome:
11
+ * windowChrome({tabs, url}), content})` at {@link W}×{@link H}.
12
+ *
13
+ * @module build/store-frame
14
+ */
15
+
16
+ /** The store screenshot's width. */
17
+ export const W = 1280;
18
+ /** The store screenshot's height. */
19
+ export const H = 800;
20
+ const CAPTION_H = 112;
21
+ /** Where the window sits: its left edge, top edge, full width, and tab strip and toolbar heights. */
22
+ export const WIN = { x: 64, y: CAPTION_H, w: 1152, strip: 42, toolbar: 46 };
23
+
24
+ /**
25
+ * How a shot's window is laid out.
26
+ *
27
+ * @typedef {object} Geometry
28
+ * @property {number} zoom The window's scale.
29
+ * @property {number} w The window's unscaled width.
30
+ * @property {{ width: number, height: number }} content The size to capture page content at.
31
+ */
32
+
33
+ /**
34
+ * A shot's window is drawn at `zoom` (scaled about its top-left corner) so text stays legible
35
+ * when the store shows screenshots small. Content is captured at 2× so the scaling stays sharp.
36
+ *
37
+ * @param {number} [zoom] - The window's scale. Default 1.
38
+ * @returns {Geometry} The window's unscaled width and the size to capture page content at.
39
+ */
40
+ export function geometry(zoom = 1) {
41
+ const w = Math.round((W - 2 * WIN.x) / zoom);
42
+ return { zoom, w, content: { width: w, height: Math.round((H - WIN.y) / zoom) - WIN.strip - WIN.toolbar } };
43
+ }
44
+
45
+ /**
46
+ * @param {unknown} s - Text.
47
+ * @returns {string} It, escaped for HTML text and attributes.
48
+ */
49
+ const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
50
+
51
+ /** Material icon paths (24×24). */
52
+ const P = {
53
+ back: 'M20 11H7.83l5.59-5.59L12 4l-8 8 8 8 1.41-1.41L7.83 13H20v-2z',
54
+ forward: 'M12 4l-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8z',
55
+ reload: 'M17.65 6.35C16.2 4.9 14.21 4 12 4c-4.42 0-7.99 3.58-7.99 8s3.57 8 7.99 8c3.73 0 6.84-2.55 7.73-6h-2.08c-.82 2.33-3.04 4-5.65 4-3.31 0-6-2.69-6-6s2.69-6 6-6c1.66 0 3.14.69 4.22 1.78L13 11h7V4l-2.35 2.35z',
56
+ close: 'M19 6.41L17.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',
57
+ add: 'M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z',
58
+ more: 'M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z',
59
+ puzzle: 'M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z',
60
+ star: 'M22 9.24l-7.19-.62L12 2 9.19 8.63 2 9.24l5.46 4.73L5.82 21 12 17.27 18.18 21l-1.63-7.03L22 9.24zM12 15.4l-3.76 2.27 1-4.28-3.32-2.88 4.38-.38L12 6.1l1.71 4.04 4.38.38-3.32 2.88 1 4.28L12 15.4z',
61
+ tune: 'M3 17v2h6v-2H3zM3 5v2h10V5H3zm10 16v-2h8v-2h-8v-2h-2v6h2zM7 9v2H3v2h4v2h2V9H7zm14 4v-2H11v2h10zm-6-4h2V7h4V5h-4V3h-2v6z',
62
+ sound: 'M3 9v6h4l5 5V4L7 9H3zm13.5 3c0-1.77-1.02-3.29-2.5-4.03v8.05c1.48-.73 2.5-2.25 2.5-4.02zM14 3.23v2.06c2.89.86 5 3.54 5 6.71s-2.11 5.85-5 6.71v2.06c4.01-.91 7-4.49 7-8.77s-2.99-7.86-7-8.77z',
63
+ muted: 'M16.5 12c0-1.77-1.02-3.29-2.5-4.03v2.21l2.45 2.45c.03-.2.05-.41.05-.63zm2.5 0c0 .94-.2 1.82-.54 2.64l1.51 1.51C20.63 14.91 21 13.5 21 12c0-4.28-2.99-7.86-7-8.77v2.06c2.89.86 5 3.54 5 6.71zM4.27 3L3 4.27 7.73 9H3v6h4l5 5v-6.73l4.25 4.25c-.67.52-1.42.93-2.25 1.18v2.06c1.38-.31 2.63-.95 3.69-1.81L19.73 21 21 19.73l-9-9L4.27 3zM12 4L9.91 6.09 12 8.18V4z',
64
+ person: 'M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z',
65
+ };
66
+
67
+ /**
68
+ * A Material icon as inline SVG, in the text colour.
69
+ *
70
+ * @param {keyof typeof P} name - Which icon.
71
+ * @param {number} [size] - Width and height in px. Default 20.
72
+ * @returns {string} The SVG markup.
73
+ */
74
+ const icon = (name, size = 20) =>
75
+ `<svg viewBox="0 0 24 24" width="${size}" height="${size}"><path fill="currentColor" d="${P[name]}"/></svg>`;
76
+
77
+ /**
78
+ * A letter favicon for made-up sites (no real brands).
79
+ *
80
+ * @param {string} letter - The letter.
81
+ * @param {string} bg - Background colour.
82
+ * @param {string} [fg] - Letter colour. Default white.
83
+ * @returns {string} A data: URL of the SVG.
84
+ */
85
+ export const letterIcon = (letter, bg, fg = '#fff') =>
86
+ `data:image/svg+xml,${encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><rect width="16" height="16" rx="4" fill="${bg}"/><text x="8" y="12" font-family="-apple-system,Helvetica,Arial" font-size="11" font-weight="700" text-anchor="middle" fill="${fg}">${letter}</text></svg>`)}`;
87
+
88
+ /** Chrome's light and dark window colours, and the canvas behind. */
89
+ export const THEMES = {
90
+ light: {
91
+ bg: '#eef2f8', caption: '#1f1f1f', sub: '#444746',
92
+ frame: '#dde3ea', tabText: '#1f1f1f', tabTextInactive: '#474747', tabSep: '#a8afb8', activeTab: '#ffffff',
93
+ toolbar: '#ffffff', toolbarIcon: '#474747', omnibox: '#e9eef6', url: '#1f1f1f', urlDim: '#5e5e5e',
94
+ contentLine: '#d3d7dc', shadow: '0 24px 60px rgba(31,45,70,.20), 0 0 0 .5px rgba(0,0,0,.18)',
95
+ },
96
+ dark: {
97
+ bg: '#1b1c1f', caption: '#e3e3e3', sub: '#c4c7c5',
98
+ frame: '#1f1f1f', tabText: '#e3e3e3', tabTextInactive: '#c7c7c7', tabSep: '#5f6368', activeTab: '#3c3c3c',
99
+ toolbar: '#3c3c3c', toolbarIcon: '#c7c7c7', omnibox: '#282828', url: '#e3e3e3', urlDim: '#a8a8a8',
100
+ contentLine: '#2a2a2a', shadow: '0 24px 60px rgba(0,0,0,.55), 0 0 0 .5px rgba(255,255,255,.14)',
101
+ },
102
+ };
103
+
104
+ /** @typedef {keyof typeof THEMES} Theme */
105
+
106
+ /**
107
+ * One tab in the drawn window.
108
+ *
109
+ * @typedef {object} Tab
110
+ * @property {string} title Its title.
111
+ * @property {string} icon Its favicon's URL.
112
+ * @property {boolean} [active] The selected tab.
113
+ * @property {boolean} [audible] Playing sound.
114
+ * @property {boolean} [muted] Muted.
115
+ */
116
+
117
+ const TAB_H = 34;
118
+
119
+ /**
120
+ * Lay out tabs like Chrome: equal widths up to 240px, shrinking to fit.
121
+ *
122
+ * @param {readonly Tab[]} tabs - The tabs.
123
+ * @param {number} w - The window's width.
124
+ * @returns {{ x: number, width: number }[]} Each tab's window-relative x and width.
125
+ */
126
+ function tabLayout(tabs, w) {
127
+ const left = 84;
128
+ const right = 60;
129
+ const max = 240;
130
+ const avail = w - left - right - 36;
131
+ const width = Math.min(max, Math.floor(avail / tabs.length));
132
+ return tabs.map((_, i) => ({ x: left + i * width, width }));
133
+ }
134
+
135
+ /**
136
+ * The window's tab strip and toolbar, as HTML. Positions are window-relative.
137
+ *
138
+ * @param {object} options - What to draw.
139
+ * @param {readonly Tab[]} options.tabs - The tabs, left to right.
140
+ * @param {string} options.url - The address bar's text.
141
+ * @param {string} [options.urlHost] - The start of `url` to show in full; the rest is dimmed.
142
+ * @param {Theme} [options.theme] - Default light.
143
+ * @param {number} [options.w] - The window's width. Default the full window.
144
+ * @param {readonly string[]} [options.ext] - Toolbar extension icons' URLs.
145
+ * @returns {string} The HTML, with its own styles.
146
+ */
147
+ export function windowChrome({ tabs, url, urlHost, theme = 'light', w = WIN.w, ext = [] }) {
148
+ const t = THEMES[theme];
149
+ const layout = tabLayout(tabs, w);
150
+ const tabHtml = tabs.map((tab, i) => {
151
+ const { x, width } = layout[i];
152
+ const sepAfter = i < tabs.length - 1 && !tab.active && !tabs[i + 1].active;
153
+ const alert = tab.muted ? icon('muted', 16) : tab.audible ? icon('sound', 16) : '';
154
+ const showClose = tab.active || width >= 120;
155
+ return `<div class="tab${tab.active ? ' active' : ''}" style="left:${x}px;width:${width}px">
156
+ <img class="fav" src="${tab.icon}" width="16" height="16" alt="">
157
+ <span class="title">${esc(tab.title)}</span>
158
+ ${alert ? `<span class="alert">${alert}</span>` : ''}
159
+ ${showClose ? `<span class="x">${icon('close', 14)}</span>` : ''}
160
+ </div>${sepAfter ? `<span class="sep" style="left:${x + width - 1}px"></span>` : ''}`;
161
+ }).join('');
162
+ const last = layout[layout.length - 1];
163
+ const plusX = last.x + last.width + 4;
164
+ const extIcons = ext.map((src) => `<img src="${src}" width="16" height="16" alt="" style="margin:0 9px">`).join('');
165
+ return `
166
+ <div class="strip">
167
+ <span class="lights"><i style="background:#ff5f57"></i><i style="background:#febc2e"></i><i style="background:#28c840"></i></span>
168
+ ${tabHtml}
169
+ <span class="plus" style="left:${plusX}px">${icon('add', 18)}</span>
170
+ </div>
171
+ <div class="toolbar">
172
+ <span class="tb">${icon('back')}</span><span class="tb dim">${icon('forward')}</span><span class="tb">${icon('reload')}</span>
173
+ <div class="omni"><span class="site">${icon('tune', 16)}</span><span class="url">${urlHost ? `${esc(urlHost)}<span class="dim">${esc(url.slice(urlHost.length))}</span>` : esc(url)}</span><span class="star">${icon('star', 18)}</span></div>
174
+ ${extIcons}<span class="tb">${icon('puzzle')}</span>
175
+ <span class="avatar">${icon('person', 18)}</span><span class="tb">${icon('more')}</span>
176
+ </div>
177
+ <style>
178
+ .strip { position: relative; height: ${WIN.strip}px; background: ${t.frame}; }
179
+ .lights { position: absolute; left: 16px; top: 15px; display: flex; gap: 8px; }
180
+ .lights i { width: 12px; height: 12px; border-radius: 50%; display: block; box-shadow: inset 0 0 0 .5px rgba(0,0,0,.12); }
181
+ .tab { position: absolute; bottom: 0; height: ${TAB_H}px; display: flex; align-items: center; gap: 8px; padding: 0 10px 0 14px; box-sizing: border-box; color: ${t.tabTextInactive}; font: 12px/16px system-ui; }
182
+ .tab.active { background: ${t.activeTab}; color: ${t.tabText}; border-radius: 10px 10px 0 0; z-index: 1; }
183
+ .tab.active::before, .tab.active::after { content: ''; position: absolute; bottom: 0; width: 10px; height: 10px; }
184
+ .tab.active::before { left: -10px; background: radial-gradient(circle at 0 0, transparent 10px, ${t.activeTab} 10.5px); }
185
+ .tab.active::after { right: -10px; background: radial-gradient(circle at 100% 0, transparent 10px, ${t.activeTab} 10.5px); }
186
+ .tab .fav { flex: none; }
187
+ .tab .title { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 18px), transparent); }
188
+ .tab .alert, .tab .x { flex: none; display: flex; color: ${t.toolbarIcon}; }
189
+ .sep { position: absolute; bottom: 9px; width: 1px; height: 18px; background: ${t.tabSep}; }
190
+ .plus { position: absolute; top: 11px; display: flex; color: ${t.toolbarIcon}; padding: 2px; }
191
+ .toolbar { height: ${WIN.toolbar}px; background: ${t.toolbar}; display: flex; align-items: center; gap: 4px; padding: 0 8px; box-sizing: border-box; border-bottom: 1px solid ${t.contentLine}; }
192
+ .tb { width: 34px; height: 34px; display: grid; place-items: center; color: ${t.toolbarIcon}; flex: none; }
193
+ .tb.dim { opacity: .45; }
194
+ .omni { flex: 1; min-width: 0; height: 34px; margin: 0 8px; border-radius: 17px; background: ${t.omnibox}; display: flex; align-items: center; gap: 10px; padding: 0 12px; color: ${t.url}; font: 14px system-ui; }
195
+ .omni .site { display: flex; color: ${t.toolbarIcon}; }
196
+ .omni .url { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
197
+ .omni .dim { color: ${t.urlDim}; }
198
+ .omni .star { display: flex; color: ${t.toolbarIcon}; }
199
+ .avatar { flex: none; width: 26px; height: 26px; margin: 0 4px; border-radius: 50%; background: #7cacf8; color: #fff; display: grid; place-items: end center; overflow: hidden; }
200
+ </style>`;
201
+ }
202
+
203
+ /**
204
+ * The macOS arrow pointer.
205
+ *
206
+ * @param {number} x - Its tip, across the canvas.
207
+ * @param {number} y - Its tip, down the canvas.
208
+ * @returns {string} Absolutely positioned SVG.
209
+ */
210
+ export const cursor = (x, y) => `<svg style="position:absolute;left:${x - 4}px;top:${y - 3}px;z-index:20;filter:drop-shadow(0 1px 1.5px rgba(0,0,0,.35))" width="22" height="30" viewBox="0 0 22 30">
211
+ <path d="M4 3 L4 24 L9 19.5 L12.4 27 L15.6 25.6 L12.3 18.3 L19 18.3 Z" fill="#000" stroke="#fff" stroke-width="1.6" stroke-linejoin="round"/></svg>`;
212
+
213
+ /**
214
+ * The full page: caption band, window, content and overlays.
215
+ *
216
+ * @param {object} options - What to draw.
217
+ * @param {string} options.caption - The headline above the window.
218
+ * @param {Theme} [options.theme] - Default light.
219
+ * @param {string} options.chrome - From windowChrome.
220
+ * @param {string} options.content - An <img> src (a capture at `geo.content` size) or raw HTML.
221
+ * @param {string} [options.overlays] - HTML drawn over the window.
222
+ * @param {Geometry} [options.geo] - Default geometry(1).
223
+ * @returns {string} A whole HTML document, W×H.
224
+ */
225
+ export function canvas({ caption, theme = 'light', chrome, content, overlays = '', geo = geometry(1) }) {
226
+ const t = THEMES[theme];
227
+ const { width: cw, height: ch } = geo.content;
228
+ const inner = content.startsWith('<') ? content : `<img src="${content}" width="${cw}" height="${ch}" alt="" style="display:block">`;
229
+ return `<!doctype html><html><head><meta charset="utf-8"><style>
230
+ html, body { margin: 0; }
231
+ body { width: ${W}px; height: ${H}px; overflow: hidden; position: relative; background: ${t.bg}; -webkit-font-smoothing: antialiased; }
232
+ .caption { position: absolute; left: 0; right: 0; top: 0; height: ${CAPTION_H}px; display: grid; place-items: center; font: 500 32px/1.2 system-ui; letter-spacing: -.2px; color: ${t.caption}; }
233
+ .stage { position: absolute; left: 0; top: 0; width: ${W}px; height: ${H}px; transform: scale(${geo.zoom}); transform-origin: ${WIN.x}px ${WIN.y}px; }
234
+ .window { position: absolute; left: ${WIN.x}px; top: ${WIN.y}px; width: ${geo.w}px; height: ${ch + WIN.strip + WIN.toolbar + 20}px; border-radius: 12px 12px 0 0; overflow: hidden; box-shadow: ${t.shadow}; }
235
+ .content { position: absolute; left: 0; top: ${WIN.strip + WIN.toolbar}px; width: ${cw}px; height: ${ch}px; overflow: hidden; background: ${theme === 'dark' ? '#202124' : '#fff'}; }
236
+ </style></head><body>
237
+ <div class="caption">${caption}</div>
238
+ <div class="stage"><div class="window">${chrome}<div class="content">${inner}</div></div>
239
+ ${overlays}</div>
240
+ </body></html>`;
241
+ }
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Test builds: copies of an extension folder with what the store build never
3
+ * has (a test hook as the service worker, host access standing in for a
4
+ * click on Chrome's permission prompt, …), in the hidden `.test-builds/`
5
+ * folder, out of the way of the folder people load unpacked.
6
+ *
7
+ * The folder can be moved with {@link TEST_BUILDS_ENV}, so a change can be
8
+ * built and tested while another run (a long live check) uses the usual
9
+ * builds, or a run can use an older build for before-and-after numbers.
10
+ *
11
+ * @module build/test-build
12
+ */
13
+ /** The environment variable that moves the test builds' folder (absolute, or relative to the extension's root). */
14
+ export declare const TEST_BUILDS_ENV = "EXTENSION_TEST_BUILDS";
15
+ /** The service worker a test hook is copied to. */
16
+ export declare const HOOK_FILE = "e2e-hook.js";
17
+ /**
18
+ * Where a test build lives: `<root>/.test-builds/<name>`, or
19
+ * `$EXTENSION_TEST_BUILDS/<name>` when that's set.
20
+ *
21
+ * @param {string} root - The extension's repository.
22
+ * @param {string} [name] - The build. Default `e2e`.
23
+ * @param {Readonly<Record<string, string | undefined>>} [env] - Default `process.env`.
24
+ * @returns {string} The build's folder.
25
+ * @example
26
+ * testBuildDir(root); // '<root>/.test-builds/e2e'
27
+ * // EXTENSION_TEST_BUILDS=/tmp/before npm run e2e
28
+ * testBuildDir(root, 'e2e-all-sites'); // '/tmp/before/e2e-all-sites'
29
+ */
30
+ export declare function testBuildDir(root: string, name?: string, env?: Readonly<Record<string, string | undefined>>): string;
31
+ export type TestManifest = {
32
+ background?: {
33
+ service_worker?: string;
34
+ };
35
+ host_permissions?: string[];
36
+ permissions?: string[];
37
+ } & Record<string, unknown>;
38
+ export type TestBuild = {
39
+ /**
40
+ * The extension folder.
41
+ */
42
+ from: string;
43
+ /**
44
+ * The build's folder ({@link testBuildDir}). Replaced.
45
+ */
46
+ to: string;
47
+ /**
48
+ * A script that becomes the service worker (as {@link HOOK_FILE}): one that
49
+ * imports the real worker and exposes what a test can't reach headlessly (a menu click, the
50
+ * toolbar button).
51
+ */
52
+ hook?: string;
53
+ /**
54
+ * Changes the copy's manifest in place.
55
+ */
56
+ manifest?: (manifest: TestManifest) => void;
57
+ };
58
+ /**
59
+ * A Chrome extension manifest, as far as a test build changes it.
60
+ *
61
+ * @typedef {{background?: {service_worker?: string}, host_permissions?: string[], permissions?: string[]} & Record<string, unknown>} TestManifest
62
+ */
63
+ /**
64
+ * What {@link buildTestCopy} takes.
65
+ *
66
+ * @typedef {object} TestBuild
67
+ * @property {string} from The extension folder.
68
+ * @property {string} to The build's folder ({@link testBuildDir}). Replaced.
69
+ * @property {string} [hook] A script that becomes the service worker (as {@link HOOK_FILE}): one that
70
+ * imports the real worker and exposes what a test can't reach headlessly (a menu click, the
71
+ * toolbar button).
72
+ * @property {(manifest: TestManifest) => void} [manifest] Changes the copy's manifest in place.
73
+ */
74
+ /**
75
+ * Copy an extension folder into a test build, with its own hook and manifest.
76
+ *
77
+ * @param {TestBuild} build - From where, to where, and what changes.
78
+ * @returns {Promise<string>} The build's folder.
79
+ * @example
80
+ * await buildTestCopy({
81
+ * from: path.join(root, 'extension'), to: testBuildDir(root), hook: path.join(root, 'test/e2e/hook.js'),
82
+ * manifest: (manifest) => { manifest.host_permissions = ['http://127.0.0.1/*']; },
83
+ * });
84
+ */
85
+ export declare function buildTestCopy({ from, to, hook, manifest: change }: TestBuild): Promise<string>;
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Test builds: copies of an extension folder with what the store build never
3
+ * has (a test hook as the service worker, host access standing in for a
4
+ * click on Chrome's permission prompt, …), in the hidden `.test-builds/`
5
+ * folder, out of the way of the folder people load unpacked.
6
+ *
7
+ * The folder can be moved with {@link TEST_BUILDS_ENV}, so a change can be
8
+ * built and tested while another run (a long live check) uses the usual
9
+ * builds, or a run can use an older build for before-and-after numbers.
10
+ *
11
+ * @module build/test-build
12
+ */
13
+
14
+ import { cp, readFile, rm, writeFile } from 'node:fs/promises';
15
+ import path from 'node:path';
16
+
17
+ /** The environment variable that moves the test builds' folder (absolute, or relative to the extension's root). */
18
+ export const TEST_BUILDS_ENV = 'EXTENSION_TEST_BUILDS';
19
+
20
+ /** The service worker a test hook is copied to. */
21
+ export const HOOK_FILE = 'e2e-hook.js';
22
+
23
+ /**
24
+ * Where a test build lives: `<root>/.test-builds/<name>`, or
25
+ * `$EXTENSION_TEST_BUILDS/<name>` when that's set.
26
+ *
27
+ * @param {string} root - The extension's repository.
28
+ * @param {string} [name] - The build. Default `e2e`.
29
+ * @param {Readonly<Record<string, string | undefined>>} [env] - Default `process.env`.
30
+ * @returns {string} The build's folder.
31
+ * @example
32
+ * testBuildDir(root); // '<root>/.test-builds/e2e'
33
+ * // EXTENSION_TEST_BUILDS=/tmp/before npm run e2e
34
+ * testBuildDir(root, 'e2e-all-sites'); // '/tmp/before/e2e-all-sites'
35
+ */
36
+ export function testBuildDir(root, name = 'e2e', env = process.env) {
37
+ return path.resolve(root, env[TEST_BUILDS_ENV] || '.test-builds', name);
38
+ }
39
+
40
+ /**
41
+ * A Chrome extension manifest, as far as a test build changes it.
42
+ *
43
+ * @typedef {{background?: {service_worker?: string}, host_permissions?: string[], permissions?: string[]} & Record<string, unknown>} TestManifest
44
+ */
45
+
46
+ /**
47
+ * What {@link buildTestCopy} takes.
48
+ *
49
+ * @typedef {object} TestBuild
50
+ * @property {string} from The extension folder.
51
+ * @property {string} to The build's folder ({@link testBuildDir}). Replaced.
52
+ * @property {string} [hook] A script that becomes the service worker (as {@link HOOK_FILE}): one that
53
+ * imports the real worker and exposes what a test can't reach headlessly (a menu click, the
54
+ * toolbar button).
55
+ * @property {(manifest: TestManifest) => void} [manifest] Changes the copy's manifest in place.
56
+ */
57
+
58
+ /**
59
+ * Copy an extension folder into a test build, with its own hook and manifest.
60
+ *
61
+ * @param {TestBuild} build - From where, to where, and what changes.
62
+ * @returns {Promise<string>} The build's folder.
63
+ * @example
64
+ * await buildTestCopy({
65
+ * from: path.join(root, 'extension'), to: testBuildDir(root), hook: path.join(root, 'test/e2e/hook.js'),
66
+ * manifest: (manifest) => { manifest.host_permissions = ['http://127.0.0.1/*']; },
67
+ * });
68
+ */
69
+ export async function buildTestCopy({ from, to, hook, manifest: change }) {
70
+ await rm(to, { recursive: true, force: true });
71
+ await cp(from, to, { recursive: true });
72
+ const file = path.join(to, 'manifest.json');
73
+ /** @type {TestManifest} */
74
+ const manifest = JSON.parse(await readFile(file, 'utf8'));
75
+ if (hook) {
76
+ await cp(hook, path.join(to, HOOK_FILE));
77
+ manifest.background = { ...manifest.background, service_worker: HOOK_FILE };
78
+ }
79
+ change?.(manifest);
80
+ await writeFile(file, `${JSON.stringify(manifest, null, 2)}\n`);
81
+ return to;
82
+ }
@@ -1,13 +1,19 @@
1
1
  /**
2
- * Toolbar glyphs: render an extension's monochrome toolbar icon in Chrome's
3
- * own toolbar-icon colours, light and dark, at 16, 32 and 48px, for
4
- * toolbar/theme.js to switch between.
2
+ * An extension's icons, rendered from SVG in Playwright's Chromium.
5
3
  *
6
- * A folder holds the sources: `glyph.svg` (16×16 viewBox, drawn in
7
- * `currentColor`) and, optionally, `glyph-16.svg`, the same mark hand-fitted
8
- * to whole pixels for the 16px file (a 1.5px stroke smears at 1x). Out come
9
- * `light-16.png`, `dark-16.png`, … beside them. The caller passes its own
10
- * Playwright, so this package needs none.
4
+ * The toolbar glyphs: an extension's monochrome toolbar icon in Chrome's own
5
+ * toolbar-icon colours, light and dark, at 16, 32 and 48px, for
6
+ * toolbar/theme.js to switch between. A folder holds the sources:
7
+ * `glyph.svg` (16×16 viewBox, drawn in `currentColor`) and, optionally,
8
+ * `glyph-16.svg`, the same mark hand-fitted to whole pixels for the 16px file
9
+ * (a 1.5px stroke smears at 1x). Out come `light-16.png`, `dark-16.png`, …
10
+ * beside them.
11
+ *
12
+ * The extension's own icon (the manifest's `icons`, the Chrome Web Store's
13
+ * tile): `icon.svg`, in its own colours, rendered to `icon-16.png` … `icon-128.png`
14
+ * beside it.
15
+ *
16
+ * The caller passes its own Playwright, so this package needs none.
11
17
  *
12
18
  * @module build/toolbar-icons
13
19
  */
@@ -50,6 +56,8 @@ export declare const TOOLBAR_ICON_SIZES: readonly number[];
50
56
  * @returns {string} For example `light-16.png`.
51
57
  */
52
58
  export declare function toolbarIconFile(scheme: 'light' | 'dark', size: number): string;
59
+ /** The sizes the manifest's `icons` lists: the toolbar and menus (16, 32), the extensions page (48), the store (128). */
60
+ export declare const ICON_SIZES: readonly number[];
53
61
  export type BrowserLauncher = {
54
62
  launch: () => Promise<{
55
63
  newPage(options: {
@@ -68,17 +76,9 @@ export type BrowserLauncher = {
68
76
  close(): Promise<void>;
69
77
  }>;
70
78
  };
79
+ export type IconBrowser = Awaited<ReturnType<BrowserLauncher['launch']>>;
71
80
  /**
72
- * The parts of a Playwright `BrowserType` (`chromium`) this uses.
73
- *
74
- * @typedef {object} BrowserLauncher
75
- * @property {() => Promise<{newPage(options: {viewport: {width: number, height: number}, deviceScaleFactor: number}): Promise<{
76
- * setContent(html: string): Promise<void>,
77
- * screenshot(options: {omitBackground: boolean}): Promise<Uint8Array>,
78
- * close(): Promise<void>}>, close(): Promise<void>}>} launch
79
- */
80
- /**
81
- * Render every scheme and size from `dir`'s sources into `dir`.
81
+ * Render every scheme and size of the toolbar glyph from `dir`'s sources into `dir`.
82
82
  *
83
83
  * @param {object} options - What to render with and where.
84
84
  * @param {BrowserLauncher} options.chromium - Playwright's `chromium`.
@@ -88,9 +88,27 @@ export type BrowserLauncher = {
88
88
  * @example
89
89
  * import { chromium } from 'playwright';
90
90
  * await renderToolbarIcons({ chromium, dir: 'extension/icons/toolbar' });
91
+ * // or, from an npm script: extension-kit toolbar-icons extension/icons/toolbar
91
92
  */
92
93
  export declare function renderToolbarIcons({ chromium, dir, sizes }: {
93
94
  chromium: BrowserLauncher;
94
95
  dir: string;
95
96
  sizes?: readonly number[];
96
97
  }): Promise<string[]>;
98
+ /**
99
+ * Render the extension's icon, `dir/icon.svg`, to `icon-<size>.png` beside it.
100
+ *
101
+ * @param {object} options - What to render with and where.
102
+ * @param {BrowserLauncher} options.chromium - Playwright's `chromium`.
103
+ * @param {string} options.dir - The folder with `icon.svg`.
104
+ * @param {readonly number[]} [options.sizes] - Default {@link ICON_SIZES}.
105
+ * @returns {Promise<string[]>} The files written.
106
+ * @example
107
+ * await renderIcons({ chromium, dir: 'extension/icons' });
108
+ * // or, from an npm script: extension-kit icons extension/icons
109
+ */
110
+ export declare function renderIcons({ chromium, dir, sizes }: {
111
+ chromium: BrowserLauncher;
112
+ dir: string;
113
+ sizes?: readonly number[];
114
+ }): Promise<string[]>;
@@ -1,13 +1,19 @@
1
1
  /**
2
- * Toolbar glyphs: render an extension's monochrome toolbar icon in Chrome's
3
- * own toolbar-icon colours, light and dark, at 16, 32 and 48px, for
4
- * toolbar/theme.js to switch between.
2
+ * An extension's icons, rendered from SVG in Playwright's Chromium.
5
3
  *
6
- * A folder holds the sources: `glyph.svg` (16×16 viewBox, drawn in
7
- * `currentColor`) and, optionally, `glyph-16.svg`, the same mark hand-fitted
8
- * to whole pixels for the 16px file (a 1.5px stroke smears at 1x). Out come
9
- * `light-16.png`, `dark-16.png`, … beside them. The caller passes its own
10
- * Playwright, so this package needs none.
4
+ * The toolbar glyphs: an extension's monochrome toolbar icon in Chrome's own
5
+ * toolbar-icon colours, light and dark, at 16, 32 and 48px, for
6
+ * toolbar/theme.js to switch between. A folder holds the sources:
7
+ * `glyph.svg` (16×16 viewBox, drawn in `currentColor`) and, optionally,
8
+ * `glyph-16.svg`, the same mark hand-fitted to whole pixels for the 16px file
9
+ * (a 1.5px stroke smears at 1x). Out come `light-16.png`, `dark-16.png`, …
10
+ * beside them.
11
+ *
12
+ * The extension's own icon (the manifest's `icons`, the Chrome Web Store's
13
+ * tile): `icon.svg`, in its own colours, rendered to `icon-16.png` … `icon-128.png`
14
+ * beside it.
15
+ *
16
+ * The caller passes its own Playwright, so this package needs none.
11
17
  *
12
18
  * @module build/toolbar-icons
13
19
  */
@@ -54,6 +60,9 @@ export function toolbarIconFile(scheme, size) {
54
60
  return `${scheme}-${size}.png`;
55
61
  }
56
62
 
63
+ /** The sizes the manifest's `icons` lists: the toolbar and menus (16, 32), the extensions page (48), the store (128). */
64
+ export const ICON_SIZES = Object.freeze([16, 32, 48, 128]);
65
+
57
66
  /**
58
67
  * The parts of a Playwright `BrowserType` (`chromium`) this uses.
59
68
  *
@@ -65,7 +74,51 @@ export function toolbarIconFile(scheme, size) {
65
74
  */
66
75
 
67
76
  /**
68
- * Render every scheme and size from `dir`'s sources into `dir`.
77
+ * The parts of a launched Playwright browser this uses.
78
+ *
79
+ * @typedef {Awaited<ReturnType<BrowserLauncher['launch']>>} IconBrowser
80
+ */
81
+
82
+ /**
83
+ * Render an SVG to a square PNG, transparent around the mark.
84
+ *
85
+ * @param {IconBrowser} browser - A launched Chromium.
86
+ * @param {string} svg - The SVG's markup.
87
+ * @param {number} size - Pixels, each way.
88
+ * @param {string} file - The PNG to write.
89
+ * @param {string} [color] - `currentColor`, for a monochrome glyph.
90
+ * @returns {Promise<string>} `file`.
91
+ */
92
+ async function renderSvg(browser, svg, size, file, color = 'inherit') {
93
+ const page = await browser.newPage({ viewport: { width: size, height: size }, deviceScaleFactor: 1 });
94
+ try {
95
+ await page.setContent(`<style>*{margin:0}svg{display:block;width:${size}px;height:${size}px;color:${color}}</style>${svg}`);
96
+ await writeFile(file, await page.screenshot({ omitBackground: true }));
97
+ } finally {
98
+ await page.close();
99
+ }
100
+ return file;
101
+ }
102
+
103
+ /**
104
+ * Run `work` with a Chromium launched for it, closed after.
105
+ *
106
+ * @template T
107
+ * @param {BrowserLauncher} chromium - Playwright's `chromium`.
108
+ * @param {(browser: IconBrowser) => Promise<T>} work - The rendering.
109
+ * @returns {Promise<T>} What `work` gives.
110
+ */
111
+ async function withBrowser(chromium, work) {
112
+ const browser = await chromium.launch();
113
+ try {
114
+ return await work(browser);
115
+ } finally {
116
+ await browser.close();
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Render every scheme and size of the toolbar glyph from `dir`'s sources into `dir`.
69
122
  *
70
123
  * @param {object} options - What to render with and where.
71
124
  * @param {BrowserLauncher} options.chromium - Playwright's `chromium`.
@@ -75,27 +128,41 @@ export function toolbarIconFile(scheme, size) {
75
128
  * @example
76
129
  * import { chromium } from 'playwright';
77
130
  * await renderToolbarIcons({ chromium, dir: 'extension/icons/toolbar' });
131
+ * // or, from an npm script: extension-kit toolbar-icons extension/icons/toolbar
78
132
  */
79
133
  export async function renderToolbarIcons({ chromium, dir, sizes = TOOLBAR_ICON_SIZES }) {
80
134
  const master = await readFile(path.join(dir, 'glyph.svg'), 'utf8');
81
135
  const small = await readFile(path.join(dir, 'glyph-16.svg'), 'utf8').catch(() => master);
82
- const browser = await chromium.launch();
83
- /** @type {string[]} */
84
- const written = [];
85
- try {
136
+ return withBrowser(chromium, async (browser) => {
137
+ /** @type {string[]} */
138
+ const written = [];
86
139
  for (const [scheme, color] of /** @type {['light' | 'dark', string][]} */ (Object.entries(TOOLBAR_ICON_COLORS))) {
87
140
  for (const size of sizes) {
88
- const page = await browser.newPage({ viewport: { width: size, height: size }, deviceScaleFactor: 1 });
89
- const svg = size === 16 ? small : master;
90
- await page.setContent(`<style>*{margin:0}svg{display:block;width:${size}px;height:${size}px;color:${color}}</style>${svg}`);
91
- const file = path.join(dir, toolbarIconFile(scheme, size));
92
- await writeFile(file, await page.screenshot({ omitBackground: true }));
93
- written.push(file);
94
- await page.close();
141
+ written.push(await renderSvg(browser, size === 16 ? small : master, size, path.join(dir, toolbarIconFile(scheme, size)), color));
95
142
  }
96
143
  }
97
- } finally {
98
- await browser.close();
99
- }
100
- return written;
144
+ return written;
145
+ });
146
+ }
147
+
148
+ /**
149
+ * Render the extension's icon, `dir/icon.svg`, to `icon-<size>.png` beside it.
150
+ *
151
+ * @param {object} options - What to render with and where.
152
+ * @param {BrowserLauncher} options.chromium - Playwright's `chromium`.
153
+ * @param {string} options.dir - The folder with `icon.svg`.
154
+ * @param {readonly number[]} [options.sizes] - Default {@link ICON_SIZES}.
155
+ * @returns {Promise<string[]>} The files written.
156
+ * @example
157
+ * await renderIcons({ chromium, dir: 'extension/icons' });
158
+ * // or, from an npm script: extension-kit icons extension/icons
159
+ */
160
+ export async function renderIcons({ chromium, dir, sizes = ICON_SIZES }) {
161
+ const svg = await readFile(path.join(dir, 'icon.svg'), 'utf8');
162
+ return withBrowser(chromium, async (browser) => {
163
+ /** @type {string[]} */
164
+ const written = [];
165
+ for (const size of sizes) written.push(await renderSvg(browser, svg, size, path.join(dir, `icon-${size}.png`)));
166
+ return written;
167
+ });
101
168
  }