mechanica 2.1.0 → 2.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -1
- package/dist/browser.js +2 -0
- package/dist/chunks/browser-BV2iB_xD.js +810 -0
- package/dist/cli.js +353 -460
- package/dist/types/cli/args.d.ts +12 -1
- package/dist/types/cli/browser.d.ts +130 -0
- package/dist/types/cli/dev-server.d.ts +13 -0
- package/dist/types/cli/headless.d.ts +69 -18
- package/dist/types/cli/section-query.d.ts +49 -0
- package/dist/types/cli/shot.d.ts +110 -8
- package/package.json +6 -2
|
@@ -0,0 +1,810 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import { join, win32 } from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { execFile, spawn } from "node:child_process";
|
|
5
|
+
//#region src/cli/headless.ts
|
|
6
|
+
/**
|
|
7
|
+
* Headless-browser plumbing shared by `mechanica shot`, `mechanica thumbs` and
|
|
8
|
+
* the public `mechanica/browser` helper: browser discovery/spawn, a minimal
|
|
9
|
+
* CDP client, page-session helpers, and probing for a running dev server.
|
|
10
|
+
*
|
|
11
|
+
* Talks raw CDP over the platform `WebSocket` instead of using an automation
|
|
12
|
+
* library: Playwright/Puppeteer launch handshakes rely on Node-only fd pipes
|
|
13
|
+
* that hang under Bun, and screenshots need only a handful of CDP calls.
|
|
14
|
+
*
|
|
15
|
+
* Never imports Vite: booting an ephemeral dev server lives in `dev-server.ts`,
|
|
16
|
+
* so `mechanica/browser` stays usable from plain Node scripts.
|
|
17
|
+
*/
|
|
18
|
+
/** Where `mechanica shot`/`thumbs` look for a running dev server first. */
|
|
19
|
+
var DEV_SERVER_ORIGINS = ["http://127.0.0.1:5173", "http://localhost:5173"];
|
|
20
|
+
/** The origin of a running Mechanica dev server on the default port, or null. */
|
|
21
|
+
async function findDevServer(origins = DEV_SERVER_ORIGINS) {
|
|
22
|
+
for (const origin of origins) if (await isMechanicaServer(origin)) return origin;
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
async function isMechanicaServer(origin) {
|
|
26
|
+
try {
|
|
27
|
+
return (await fetch(`${origin}/@mechanica/folders`, { signal: AbortSignal.timeout(1e3) })).ok;
|
|
28
|
+
} catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
async function setViewport(cdp, sessionId, width, height = 900) {
|
|
33
|
+
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
|
34
|
+
width,
|
|
35
|
+
height,
|
|
36
|
+
deviceScaleFactor: 1,
|
|
37
|
+
mobile: false
|
|
38
|
+
}, sessionId);
|
|
39
|
+
}
|
|
40
|
+
async function evaluateValue(cdp, sessionId, expression) {
|
|
41
|
+
return (await cdp.send("Runtime.evaluate", {
|
|
42
|
+
expression,
|
|
43
|
+
returnByValue: true
|
|
44
|
+
}, sessionId)).result?.value;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Poll an expression on the page until it is `true` or the timeout elapses.
|
|
48
|
+
* Evaluate errors (execution context destroyed mid-navigation) count as "not
|
|
49
|
+
* ready yet" rather than failing the wait.
|
|
50
|
+
*/
|
|
51
|
+
async function waitForCondition(cdp, sessionId, expression, timeout) {
|
|
52
|
+
const deadline = Date.now() + timeout;
|
|
53
|
+
while (Date.now() < deadline) {
|
|
54
|
+
try {
|
|
55
|
+
if (await evaluateValue(cdp, sessionId, expression)) return;
|
|
56
|
+
} catch {}
|
|
57
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`Timed out after ${timeout}ms waiting for the page to become ready`);
|
|
60
|
+
}
|
|
61
|
+
/** Upper bound for {@link settlePageMedia}: a stalled image must not hang a shot. */
|
|
62
|
+
var SETTLE_TIMEOUT = 1e4;
|
|
63
|
+
/**
|
|
64
|
+
* Wait for web fonts and all images — normal pages have no injected ready flag.
|
|
65
|
+
* Lazy images are switched to eager first: below the fold they would never
|
|
66
|
+
* start loading in a headless tab (the wait used to hang on them), and a
|
|
67
|
+
* full-page capture should show them anyway. Gives up after `timeout` ms.
|
|
68
|
+
*/
|
|
69
|
+
async function settlePageMedia(cdp, sessionId, timeout = SETTLE_TIMEOUT) {
|
|
70
|
+
await cdp.send("Runtime.evaluate", {
|
|
71
|
+
expression: `(() => {
|
|
72
|
+
for (const img of document.images) if (img.loading === 'lazy') img.loading = 'eager'
|
|
73
|
+
const media = Promise.all([
|
|
74
|
+
document.fonts ? document.fonts.ready : null,
|
|
75
|
+
...[...document.images].map((img) =>
|
|
76
|
+
img.complete ? null : new Promise((done) => {
|
|
77
|
+
img.addEventListener('load', done, { once: true })
|
|
78
|
+
img.addEventListener('error', done, { once: true })
|
|
79
|
+
})),
|
|
80
|
+
].filter(Boolean))
|
|
81
|
+
return Promise.race([media, new Promise((done) => setTimeout(done, ${timeout}))]).then(() => true)
|
|
82
|
+
})()`,
|
|
83
|
+
awaitPromise: true,
|
|
84
|
+
returnByValue: true
|
|
85
|
+
}, sessionId);
|
|
86
|
+
}
|
|
87
|
+
var FREEZE_STYLE_ID = "mechanica-freeze-animations";
|
|
88
|
+
/** Freeze animations/transitions so captures are deterministic across runs. Idempotent. */
|
|
89
|
+
async function freezeAnimations(cdp, sessionId) {
|
|
90
|
+
await cdp.send("Runtime.evaluate", { expression: `{
|
|
91
|
+
if (!document.getElementById('${FREEZE_STYLE_ID}')) {
|
|
92
|
+
const style = document.createElement('style')
|
|
93
|
+
style.id = '${FREEZE_STYLE_ID}'
|
|
94
|
+
style.textContent = '*, *::before, *::after { animation: none !important; transition: none !important; caret-color: transparent !important; }'
|
|
95
|
+
document.head.appendChild(style)
|
|
96
|
+
}
|
|
97
|
+
}` }, sessionId);
|
|
98
|
+
}
|
|
99
|
+
/** Undo {@link freezeAnimations}: transitions and animations run again. */
|
|
100
|
+
async function unfreezeAnimations(cdp, sessionId) {
|
|
101
|
+
await cdp.send("Runtime.evaluate", { expression: `document.getElementById('${FREEZE_STYLE_ID}')?.remove()` }, sessionId);
|
|
102
|
+
}
|
|
103
|
+
/** Wait two animation frames: a full style → layout → paint cycle has run. */
|
|
104
|
+
async function nextFrames(cdp, sessionId) {
|
|
105
|
+
await cdp.send("Runtime.evaluate", {
|
|
106
|
+
expression: "new Promise((done) => requestAnimationFrame(() => requestAnimationFrame(done)))",
|
|
107
|
+
awaitPromise: true
|
|
108
|
+
}, sessionId);
|
|
109
|
+
}
|
|
110
|
+
/** Chromium refuses to encode arbitrarily tall captures — cap and say so. */
|
|
111
|
+
var MAX_SHOT_HEIGHT = 12e3;
|
|
112
|
+
/** The full document size as a screenshot clip. */
|
|
113
|
+
async function fullPageClip(cdp, sessionId) {
|
|
114
|
+
const metrics = await cdp.send("Page.getLayoutMetrics", {}, sessionId);
|
|
115
|
+
const size = metrics.cssContentSize ?? metrics.contentSize;
|
|
116
|
+
const height = Math.min(size.height, MAX_SHOT_HEIGHT);
|
|
117
|
+
if (height < size.height) console.info(`(page is ${Math.round(size.height)}px tall — clipped to ${MAX_SHOT_HEIGHT}px)`);
|
|
118
|
+
return {
|
|
119
|
+
x: 0,
|
|
120
|
+
y: 0,
|
|
121
|
+
width: size.width,
|
|
122
|
+
height
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
/** What is on screen right now (the visual viewport at its scroll offset), as a clip. */
|
|
126
|
+
async function viewportClip(cdp, sessionId) {
|
|
127
|
+
const metrics = await cdp.send("Page.getLayoutMetrics", {}, sessionId);
|
|
128
|
+
const viewport = metrics.cssVisualViewport ?? metrics.visualViewport;
|
|
129
|
+
return {
|
|
130
|
+
x: viewport.pageX,
|
|
131
|
+
y: viewport.pageY,
|
|
132
|
+
width: viewport.clientWidth,
|
|
133
|
+
height: viewport.clientHeight
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** How long a spawned browser may take to print its DevTools endpoint. */
|
|
137
|
+
var STARTUP_TIMEOUT = 2e4;
|
|
138
|
+
/**
|
|
139
|
+
* Spawn a headless Chromium-based browser with a DevTools endpoint and connect
|
|
140
|
+
* to it. With `explicitPath` (`--browser`) only that executable is tried.
|
|
141
|
+
* Otherwise the installed browsers of {@link platformBrowserCandidates} are
|
|
142
|
+
* tried in order until one starts — a browser already on the machine, so
|
|
143
|
+
* nothing needs downloading — and the error names each one that failed.
|
|
144
|
+
*/
|
|
145
|
+
async function launchBrowser(explicitPath) {
|
|
146
|
+
if (explicitPath) {
|
|
147
|
+
if (!fs.existsSync(explicitPath)) throw new Error(`--browser not found: ${explicitPath}`);
|
|
148
|
+
const executable = {
|
|
149
|
+
label: "browser from --browser",
|
|
150
|
+
path: explicitPath
|
|
151
|
+
};
|
|
152
|
+
try {
|
|
153
|
+
return await startBrowser(executable);
|
|
154
|
+
} catch (error) {
|
|
155
|
+
throw new Error(`Failed to start ${executable.label} (${executable.path}): ${errorMessage(error)}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const candidates = platformBrowserCandidates();
|
|
159
|
+
const installed = candidates.filter((candidate, index) => candidates.findIndex((other) => other.path === candidate.path) === index && fs.existsSync(candidate.path));
|
|
160
|
+
if (!installed.length) {
|
|
161
|
+
const looked = candidates.map((c) => ` ${c.label}: ${c.path}`).join("\n");
|
|
162
|
+
throw new Error(`No Chromium-based browser found. Looked for:\n${looked}\nInstall Chrome or Edge, or pass --browser <path-to-executable>.`);
|
|
163
|
+
}
|
|
164
|
+
return startFirstBrowser(installed, startBrowser);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Try `start` on each candidate in order and return the first browser that
|
|
168
|
+
* comes up. When none does, the error lists every candidate and why it failed.
|
|
169
|
+
*/
|
|
170
|
+
async function startFirstBrowser(candidates, start) {
|
|
171
|
+
const failures = [];
|
|
172
|
+
for (const candidate of candidates) try {
|
|
173
|
+
return await start(candidate);
|
|
174
|
+
} catch (error) {
|
|
175
|
+
failures.push(` ${candidate.label} (${candidate.path}): ${errorMessage(error)}`);
|
|
176
|
+
}
|
|
177
|
+
throw new Error(`No browser could be started. Tried:\n${failures.join("\n")}\nPass --browser <path-to-executable> to use another Chromium-based browser.`);
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Spawn one browser on a fresh temporary profile and connect to it. When it
|
|
181
|
+
* does not come up (exits early, never prints its endpoint, refuses the
|
|
182
|
+
* connection), everything it started is ended and the profile removed before
|
|
183
|
+
* the error is rethrown.
|
|
184
|
+
*/
|
|
185
|
+
async function startBrowser(executable) {
|
|
186
|
+
const userDataDir = fs.mkdtempSync(join(os.tmpdir(), "mechanica-shot-"));
|
|
187
|
+
const child = spawn(executable.path, [
|
|
188
|
+
"--headless=new",
|
|
189
|
+
"--remote-debugging-port=0",
|
|
190
|
+
`--user-data-dir=${userDataDir}`,
|
|
191
|
+
"--no-first-run",
|
|
192
|
+
"--no-default-browser-check",
|
|
193
|
+
"--disable-extensions",
|
|
194
|
+
"--hide-scrollbars",
|
|
195
|
+
"about:blank"
|
|
196
|
+
], {
|
|
197
|
+
stdio: [
|
|
198
|
+
"ignore",
|
|
199
|
+
"ignore",
|
|
200
|
+
"pipe"
|
|
201
|
+
],
|
|
202
|
+
env: browserEnv()
|
|
203
|
+
});
|
|
204
|
+
let client;
|
|
205
|
+
try {
|
|
206
|
+
const endpoint = await waitForDevtoolsEndpoint(child, STARTUP_TIMEOUT);
|
|
207
|
+
client = await CdpClient.connect(endpoint);
|
|
208
|
+
} catch (error) {
|
|
209
|
+
await killBrowserTree(child, userDataDir);
|
|
210
|
+
await removeProfile(userDataDir);
|
|
211
|
+
throw error;
|
|
212
|
+
}
|
|
213
|
+
return {
|
|
214
|
+
client,
|
|
215
|
+
close: async () => {
|
|
216
|
+
await client.send("Browser.close").catch(() => {});
|
|
217
|
+
client.dispose();
|
|
218
|
+
await stopProcess(child);
|
|
219
|
+
await removeProfile(userDataDir);
|
|
220
|
+
}
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
/**
|
|
224
|
+
* The environment a browser starts with. On Windows it leaves out
|
|
225
|
+
* `__COMPAT_LAYER` (Git Bash and some tool runners set it to `RunAsInvoker`):
|
|
226
|
+
* under a compatibility layer Edge relaunches itself without it and exits at
|
|
227
|
+
* once, so the process we spawned never prints its endpoint while the
|
|
228
|
+
* relaunched one keeps running.
|
|
229
|
+
*/
|
|
230
|
+
function browserEnv(env = process.env, platform = process.platform) {
|
|
231
|
+
if (platform !== "win32") return env;
|
|
232
|
+
const result = { ...env };
|
|
233
|
+
for (const key of Object.keys(result)) if (key.toUpperCase() === "__COMPAT_LAYER") delete result[key];
|
|
234
|
+
return result;
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* The Windows commands that end a browser which failed to start, best effort:
|
|
238
|
+
* `taskkill /T /F` on its process tree while the spawned process still runs
|
|
239
|
+
* (`pid`), then — because a browser that relaunched itself has outlived the
|
|
240
|
+
* process we spawned — every process still running on its temporary profile,
|
|
241
|
+
* found by the profile's folder name in the command line.
|
|
242
|
+
*/
|
|
243
|
+
function windowsKillCommands(pid, userDataDir) {
|
|
244
|
+
const commands = [];
|
|
245
|
+
if (pid) commands.push(["taskkill.exe", [
|
|
246
|
+
"/T",
|
|
247
|
+
"/F",
|
|
248
|
+
"/PID",
|
|
249
|
+
String(pid)
|
|
250
|
+
]]);
|
|
251
|
+
const profile = win32.basename(userDataDir);
|
|
252
|
+
if (/^[\w.-]+$/.test(profile)) {
|
|
253
|
+
const script = `Get-CimInstance Win32_Process -Filter "CommandLine LIKE '%${profile}%'" | Where-Object { \$_.ProcessId -ne \$PID } | ForEach-Object { Stop-Process -Id \$_.ProcessId -Force -ErrorAction SilentlyContinue }`;
|
|
254
|
+
const encoded = Buffer.from(script, "utf16le").toString("base64");
|
|
255
|
+
commands.push(["powershell.exe", [
|
|
256
|
+
"-NoProfile",
|
|
257
|
+
"-NonInteractive",
|
|
258
|
+
"-EncodedCommand",
|
|
259
|
+
encoded
|
|
260
|
+
]]);
|
|
261
|
+
}
|
|
262
|
+
return commands;
|
|
263
|
+
}
|
|
264
|
+
/** End a browser that failed to start, with every process it spawned (best effort). */
|
|
265
|
+
async function killBrowserTree(child, userDataDir) {
|
|
266
|
+
if (process.platform === "win32") for (const [command, args] of windowsKillCommands(isRunning(child) ? child.pid : void 0, userDataDir)) await runQuietly(command, args);
|
|
267
|
+
await stopProcess(child);
|
|
268
|
+
}
|
|
269
|
+
function isRunning(child) {
|
|
270
|
+
return child.exitCode === null && child.signalCode === null;
|
|
271
|
+
}
|
|
272
|
+
/** Kill the spawned process (if it still runs) and wait up to 3 s for it to exit. */
|
|
273
|
+
async function stopProcess(child) {
|
|
274
|
+
if (!isRunning(child)) return;
|
|
275
|
+
const exited = new Promise((resolve) => {
|
|
276
|
+
child.once("exit", () => resolve());
|
|
277
|
+
setTimeout(resolve, 3e3);
|
|
278
|
+
});
|
|
279
|
+
child.kill();
|
|
280
|
+
await exited;
|
|
281
|
+
}
|
|
282
|
+
/** Remove a temporary profile; it can stay locked for a moment after exit on Windows. */
|
|
283
|
+
async function removeProfile(userDataDir) {
|
|
284
|
+
for (let attempt = 0; attempt < 5; attempt++) try {
|
|
285
|
+
fs.rmSync(userDataDir, {
|
|
286
|
+
recursive: true,
|
|
287
|
+
force: true
|
|
288
|
+
});
|
|
289
|
+
return;
|
|
290
|
+
} catch {
|
|
291
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
/** Run a command, ignoring its output and any failure. */
|
|
295
|
+
function runQuietly(command, args) {
|
|
296
|
+
return new Promise((resolve) => {
|
|
297
|
+
execFile(command, args, {
|
|
298
|
+
timeout: 15e3,
|
|
299
|
+
windowsHide: true
|
|
300
|
+
}, () => resolve());
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
function errorMessage(error) {
|
|
304
|
+
return error instanceof Error ? error.message : String(error);
|
|
305
|
+
}
|
|
306
|
+
/** Read the spawned browser's stderr until it prints its DevTools WebSocket URL. */
|
|
307
|
+
function waitForDevtoolsEndpoint(child, timeout) {
|
|
308
|
+
return new Promise((resolve, reject) => {
|
|
309
|
+
let buffer = "";
|
|
310
|
+
const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`no DevTools endpoint within ${timeout}ms`)), timeout);
|
|
311
|
+
let found = false;
|
|
312
|
+
child.stderr.on("data", (chunk) => {
|
|
313
|
+
if (found) return;
|
|
314
|
+
buffer += chunk.toString();
|
|
315
|
+
const match = buffer.match(/DevTools listening on (ws:\/\/\S+)/);
|
|
316
|
+
if (match) {
|
|
317
|
+
found = true;
|
|
318
|
+
clearTimeout(timer);
|
|
319
|
+
resolve(match[1]);
|
|
320
|
+
}
|
|
321
|
+
});
|
|
322
|
+
child.on("error", (error) => {
|
|
323
|
+
clearTimeout(timer);
|
|
324
|
+
reject(error);
|
|
325
|
+
});
|
|
326
|
+
child.on("exit", (code, signal) => {
|
|
327
|
+
clearTimeout(timer);
|
|
328
|
+
const said = buffer.trim().split(/\r?\n/).pop()?.trim().slice(0, 200);
|
|
329
|
+
reject(/* @__PURE__ */ new Error(`browser exited early (${code !== null ? `code ${code}` : signal})${said ? `: ${said}` : ""}`));
|
|
330
|
+
});
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
/**
|
|
334
|
+
* Known install locations of Chromium-based browsers, in the order they are
|
|
335
|
+
* tried. On Windows Chrome comes first: Edge ships with Windows, so it is the
|
|
336
|
+
* fallback, and it relaunches itself under a compatibility layer (see
|
|
337
|
+
* {@link browserEnv}).
|
|
338
|
+
*/
|
|
339
|
+
function platformBrowserCandidates(platform = process.platform, env = process.env, home = os.homedir()) {
|
|
340
|
+
if (platform === "win32") {
|
|
341
|
+
const programFiles = env["ProgramFiles"] ?? "C:\\Program Files";
|
|
342
|
+
const programFilesX86 = env["ProgramFiles(x86)"] ?? "C:\\Program Files (x86)";
|
|
343
|
+
const localAppData = env["LOCALAPPDATA"] ?? win32.join(home, "AppData", "Local");
|
|
344
|
+
const chrome = [
|
|
345
|
+
"Google",
|
|
346
|
+
"Chrome",
|
|
347
|
+
"Application",
|
|
348
|
+
"chrome.exe"
|
|
349
|
+
];
|
|
350
|
+
const edge = [
|
|
351
|
+
"Microsoft",
|
|
352
|
+
"Edge",
|
|
353
|
+
"Application",
|
|
354
|
+
"msedge.exe"
|
|
355
|
+
];
|
|
356
|
+
return [
|
|
357
|
+
{
|
|
358
|
+
label: "Google Chrome",
|
|
359
|
+
path: win32.join(programFiles, ...chrome)
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
label: "Google Chrome",
|
|
363
|
+
path: win32.join(programFilesX86, ...chrome)
|
|
364
|
+
},
|
|
365
|
+
{
|
|
366
|
+
label: "Google Chrome",
|
|
367
|
+
path: win32.join(localAppData, ...chrome)
|
|
368
|
+
},
|
|
369
|
+
{
|
|
370
|
+
label: "Microsoft Edge",
|
|
371
|
+
path: win32.join(programFilesX86, ...edge)
|
|
372
|
+
},
|
|
373
|
+
{
|
|
374
|
+
label: "Microsoft Edge",
|
|
375
|
+
path: win32.join(programFiles, ...edge)
|
|
376
|
+
}
|
|
377
|
+
];
|
|
378
|
+
}
|
|
379
|
+
if (platform === "darwin") return [
|
|
380
|
+
{
|
|
381
|
+
label: "Google Chrome",
|
|
382
|
+
path: "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
|
|
383
|
+
},
|
|
384
|
+
{
|
|
385
|
+
label: "Microsoft Edge",
|
|
386
|
+
path: "/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge"
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
label: "Chromium",
|
|
390
|
+
path: "/Applications/Chromium.app/Contents/MacOS/Chromium"
|
|
391
|
+
}
|
|
392
|
+
];
|
|
393
|
+
return [
|
|
394
|
+
{
|
|
395
|
+
label: "Google Chrome",
|
|
396
|
+
path: "/usr/bin/google-chrome"
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
label: "Chromium",
|
|
400
|
+
path: "/usr/bin/chromium"
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
label: "Chromium",
|
|
404
|
+
path: "/usr/bin/chromium-browser"
|
|
405
|
+
},
|
|
406
|
+
{
|
|
407
|
+
label: "Microsoft Edge",
|
|
408
|
+
path: "/usr/bin/microsoft-edge"
|
|
409
|
+
}
|
|
410
|
+
];
|
|
411
|
+
}
|
|
412
|
+
/**
|
|
413
|
+
* A minimal Chrome DevTools Protocol client over the platform `WebSocket`
|
|
414
|
+
* (native in Bun and Node ≥ 22): request/response matching by id, plus event
|
|
415
|
+
* fan-out. Flat session mode — pass a `sessionId` to address a page target.
|
|
416
|
+
*/
|
|
417
|
+
var CdpClient = class CdpClient {
|
|
418
|
+
ws;
|
|
419
|
+
nextId = 1;
|
|
420
|
+
pending = /* @__PURE__ */ new Map();
|
|
421
|
+
listeners = [];
|
|
422
|
+
constructor(ws) {
|
|
423
|
+
this.ws = ws;
|
|
424
|
+
ws.onmessage = (event) => this.onMessage(String(event.data));
|
|
425
|
+
ws.onclose = () => {
|
|
426
|
+
for (const { reject } of this.pending.values()) reject(/* @__PURE__ */ new Error("CDP connection closed"));
|
|
427
|
+
this.pending.clear();
|
|
428
|
+
};
|
|
429
|
+
}
|
|
430
|
+
static connect(url) {
|
|
431
|
+
return new Promise((resolve, reject) => {
|
|
432
|
+
const ws = new WebSocket(url);
|
|
433
|
+
const timer = setTimeout(() => reject(/* @__PURE__ */ new Error(`CDP connect timeout: ${url}`)), 1e4);
|
|
434
|
+
ws.onopen = () => {
|
|
435
|
+
clearTimeout(timer);
|
|
436
|
+
resolve(new CdpClient(ws));
|
|
437
|
+
};
|
|
438
|
+
ws.onerror = () => {
|
|
439
|
+
clearTimeout(timer);
|
|
440
|
+
reject(/* @__PURE__ */ new Error(`CDP connection failed: ${url}`));
|
|
441
|
+
};
|
|
442
|
+
});
|
|
443
|
+
}
|
|
444
|
+
send(method, params = {}, sessionId) {
|
|
445
|
+
const id = this.nextId++;
|
|
446
|
+
this.ws.send(JSON.stringify({
|
|
447
|
+
id,
|
|
448
|
+
method,
|
|
449
|
+
params,
|
|
450
|
+
...sessionId ? { sessionId } : {}
|
|
451
|
+
}));
|
|
452
|
+
return new Promise((resolve, reject) => this.pending.set(id, {
|
|
453
|
+
resolve,
|
|
454
|
+
reject
|
|
455
|
+
}));
|
|
456
|
+
}
|
|
457
|
+
onEvent(listener) {
|
|
458
|
+
this.listeners.push(listener);
|
|
459
|
+
}
|
|
460
|
+
dispose() {
|
|
461
|
+
try {
|
|
462
|
+
this.ws.close();
|
|
463
|
+
} catch {}
|
|
464
|
+
}
|
|
465
|
+
onMessage(raw) {
|
|
466
|
+
const message = JSON.parse(raw);
|
|
467
|
+
if (typeof message.id === "number") {
|
|
468
|
+
const pending = this.pending.get(message.id);
|
|
469
|
+
if (!pending) return;
|
|
470
|
+
this.pending.delete(message.id);
|
|
471
|
+
if (message.error) pending.reject(/* @__PURE__ */ new Error(`${message.error.message ?? "CDP error"}`));
|
|
472
|
+
else pending.resolve(message.result ?? {});
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
if (typeof message.method === "string") for (const listener of this.listeners) listener(message.method, message.params ?? {}, message.sessionId);
|
|
476
|
+
}
|
|
477
|
+
};
|
|
478
|
+
/**
|
|
479
|
+
* Split a selector at `>>>` into trimmed parts (one part when there is none).
|
|
480
|
+
* A `>>>` inside quotes, brackets or parentheses, or after a backslash, is
|
|
481
|
+
* part of the selector. Throws when a part is empty (`>>> .a`, `.a >>>`).
|
|
482
|
+
*/
|
|
483
|
+
function splitShadowSelector(selector) {
|
|
484
|
+
const parts = [];
|
|
485
|
+
let start = 0;
|
|
486
|
+
let depth = 0;
|
|
487
|
+
let quote = "";
|
|
488
|
+
for (let i = 0; i < selector.length; i++) {
|
|
489
|
+
const char = selector[i];
|
|
490
|
+
if (char === "\\") {
|
|
491
|
+
i++;
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
if (quote) {
|
|
495
|
+
if (char === quote) quote = "";
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
if (char === "\"" || char === "'") quote = char;
|
|
499
|
+
else if (char === "[" || char === "(") depth++;
|
|
500
|
+
else if ((char === "]" || char === ")") && depth > 0) depth--;
|
|
501
|
+
else if (depth === 0 && selector.startsWith(">>>", i)) {
|
|
502
|
+
parts.push(selector.slice(start, i).trim());
|
|
503
|
+
i += 2;
|
|
504
|
+
start = i + 1;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
parts.push(selector.slice(start).trim());
|
|
508
|
+
if (parts.some((part) => !part)) throw new Error(`empty selector around ">>>"`);
|
|
509
|
+
return parts;
|
|
510
|
+
}
|
|
511
|
+
/**
|
|
512
|
+
* The block-id selector to try when a bare `#name` or `name` matches nothing:
|
|
513
|
+
* `[data-block-id="name"]`. Null for any other selector. A name that is not a
|
|
514
|
+
* valid CSS id (`#1hero`) still gets one, so block ids starting with a digit
|
|
515
|
+
* are reachable too.
|
|
516
|
+
*/
|
|
517
|
+
function blockIdFallback(selector) {
|
|
518
|
+
const match = /^#?([\p{L}\p{N}_-]+)$/u.exec(selector.trim());
|
|
519
|
+
return match ? `[data-block-id="${match[1]}"]` : null;
|
|
520
|
+
}
|
|
521
|
+
/**
|
|
522
|
+
* In-page source of `(parts, fallback) => result`, where `parts` is a selector
|
|
523
|
+
* split by {@link splitShadowSelector} and `fallback` is
|
|
524
|
+
* {@link blockIdFallback}'s result (or null). It finds the first element
|
|
525
|
+
* matching `parts`, else the first matching `fallback`, in the document and
|
|
526
|
+
* in every open shadow root, and returns:
|
|
527
|
+
*
|
|
528
|
+
* - `{ element }`;
|
|
529
|
+
* - `{ error: 'invalid', message }` when the selector does not parse (and the
|
|
530
|
+
* fallback finds nothing);
|
|
531
|
+
* - `{ error: 'missing', ids, blockIds, fallback }`: the readable ids
|
|
532
|
+
* (`#name`) and the `data-block-id` values on the page, shadow roots
|
|
533
|
+
* included, for the error message.
|
|
534
|
+
*
|
|
535
|
+
* Plain JS with no outside references: it is sent to the page as text.
|
|
536
|
+
*/
|
|
537
|
+
var FIND_SECTION_SOURCE = String.raw`(parts, fallback) => {
|
|
538
|
+
// Every open shadow root under scope, depth first in tree order.
|
|
539
|
+
const shadowRoots = (scope) => {
|
|
540
|
+
const roots = []
|
|
541
|
+
const visit = (root) => {
|
|
542
|
+
for (const el of root.querySelectorAll('*')) {
|
|
543
|
+
if (el.shadowRoot) {
|
|
544
|
+
roots.push(el.shadowRoot)
|
|
545
|
+
visit(el.shadowRoot)
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
visit(scope)
|
|
550
|
+
return roots
|
|
551
|
+
}
|
|
552
|
+
// The first match in scope's own tree, else in the shadow roots under it.
|
|
553
|
+
const first = (scope, selector) => {
|
|
554
|
+
const own = scope.querySelector(selector)
|
|
555
|
+
if (own) return own
|
|
556
|
+
for (const root of shadowRoots(scope)) {
|
|
557
|
+
const hit = root.querySelector(selector)
|
|
558
|
+
if (hit) return hit
|
|
559
|
+
}
|
|
560
|
+
return null
|
|
561
|
+
}
|
|
562
|
+
const all = (scope, selector) =>
|
|
563
|
+
[scope, ...shadowRoots(scope)].flatMap((root) => [...root.querySelectorAll(selector)])
|
|
564
|
+
const query = () => {
|
|
565
|
+
let scopes = [document]
|
|
566
|
+
for (const part of parts.slice(0, -1)) {
|
|
567
|
+
const hosts = new Set(scopes.flatMap((scope) => all(scope, part)))
|
|
568
|
+
scopes = [...hosts].map((host) => host.shadowRoot || host)
|
|
569
|
+
}
|
|
570
|
+
for (const scope of scopes) {
|
|
571
|
+
const hit = first(scope, parts[parts.length - 1])
|
|
572
|
+
if (hit) return hit
|
|
573
|
+
}
|
|
574
|
+
return null
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
let element = null
|
|
578
|
+
let invalid = null
|
|
579
|
+
try {
|
|
580
|
+
element = query()
|
|
581
|
+
} catch (error) {
|
|
582
|
+
invalid = String((error && error.message) || error)
|
|
583
|
+
}
|
|
584
|
+
if (!element && fallback) element = first(document, fallback)
|
|
585
|
+
if (element) return { element }
|
|
586
|
+
if (invalid) return { error: 'invalid', message: invalid }
|
|
587
|
+
|
|
588
|
+
// A hint for the next try: readable ids (not SVG internals or generated
|
|
589
|
+
// ids) and block ids, in the document and its shadow roots.
|
|
590
|
+
const ids = new Set()
|
|
591
|
+
const blockIds = new Set()
|
|
592
|
+
for (const root of [document, ...shadowRoots(document)]) {
|
|
593
|
+
for (const el of root.querySelectorAll(root === document ? 'body [id]' : '[id]')) {
|
|
594
|
+
const id = el.id
|
|
595
|
+
if (el.closest('svg') || !/^[A-Za-z][\w-]{0,39}$/.test(id) || /^w-node-|[0-9a-f]{8}/i.test(id)) continue
|
|
596
|
+
ids.add('#' + id)
|
|
597
|
+
}
|
|
598
|
+
for (const el of root.querySelectorAll('[data-block-id]')) {
|
|
599
|
+
const id = el.getAttribute('data-block-id')
|
|
600
|
+
if (id) blockIds.add(id)
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
error: 'missing',
|
|
605
|
+
ids: [...ids].slice(0, ${20}),
|
|
606
|
+
blockIds: [...blockIds].slice(0, ${20}),
|
|
607
|
+
fallback,
|
|
608
|
+
}
|
|
609
|
+
}`;
|
|
610
|
+
//#endregion
|
|
611
|
+
//#region src/cli/browser.ts
|
|
612
|
+
/**
|
|
613
|
+
* `mechanica/browser` — the headless browser behind `mechanica shot` and
|
|
614
|
+
* `mechanica thumbs`, for a site's own scripts: open a page (a dev-server URL,
|
|
615
|
+
* a design handoff's `file://` URL, any site), resize it, scroll it, measure it
|
|
616
|
+
* with `evaluate`, and screenshot it or one section of it.
|
|
617
|
+
*
|
|
618
|
+
* Drives a Chrome/Edge already installed on the machine over raw CDP
|
|
619
|
+
* (`headless.ts`), so there is nothing to download. Needs a global `WebSocket`
|
|
620
|
+
* (Node 22+ or Bun) and never imports Vite.
|
|
621
|
+
*/
|
|
622
|
+
/** Readiness of an ordinary page: the load event has fired. */
|
|
623
|
+
var PAGE_LOADED = "document.readyState === 'complete'";
|
|
624
|
+
var DEFAULT_WIDTH = 1440;
|
|
625
|
+
var DEFAULT_HEIGHT = 900;
|
|
626
|
+
var DEFAULT_TIMEOUT = 15e3;
|
|
627
|
+
/** `goto` could not open the URL at all (DNS, refused connection, missing file…). */
|
|
628
|
+
var NavigationError = class extends Error {
|
|
629
|
+
url;
|
|
630
|
+
reason;
|
|
631
|
+
constructor(url, reason) {
|
|
632
|
+
super(`Failed to open ${url}: ${reason}`);
|
|
633
|
+
this.url = url;
|
|
634
|
+
this.reason = reason;
|
|
635
|
+
this.name = "NavigationError";
|
|
636
|
+
}
|
|
637
|
+
};
|
|
638
|
+
/**
|
|
639
|
+
* Launch a headless browser with one tab, sized and media-emulated like
|
|
640
|
+
* `mechanica shot`, and open `url` in it (omit `url` for a blank tab to
|
|
641
|
+
* `goto` later). Always `close()` the page — it owns the browser process.
|
|
642
|
+
*
|
|
643
|
+
* ```ts
|
|
644
|
+
* import { openPage } from 'mechanica/browser'
|
|
645
|
+
*
|
|
646
|
+
* const page = await openPage('file:///…/design/index.html', { width: 390 })
|
|
647
|
+
* try {
|
|
648
|
+
* const size = await page.evaluate<string>(`getComputedStyle(document.querySelector('h1')).fontSize`)
|
|
649
|
+
* fs.writeFileSync('hero.png', await page.screenshot({ section: '#hero' }))
|
|
650
|
+
* } finally {
|
|
651
|
+
* await page.close()
|
|
652
|
+
* }
|
|
653
|
+
* ```
|
|
654
|
+
*/
|
|
655
|
+
async function openPage(url, options = {}) {
|
|
656
|
+
const browser = await launchBrowser(options.browser);
|
|
657
|
+
try {
|
|
658
|
+
const page = await HeadlessPage.create(browser, options);
|
|
659
|
+
if (url) await page.goto(url, options);
|
|
660
|
+
return page;
|
|
661
|
+
} catch (error) {
|
|
662
|
+
await browser.close();
|
|
663
|
+
throw error;
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
/** The message for a `sectionClip` that found nothing usable. */
|
|
667
|
+
function describeSectionError(selector, box) {
|
|
668
|
+
switch (box.error) {
|
|
669
|
+
case "invalid": return `"${selector}" is not a valid CSS selector (${box.message})`;
|
|
670
|
+
case "missing": return `No element matches "${selector}"` + (box.fallback ? ` (nor ${box.fallback})` : "") + (box.ids.length ? `. Ids on the page: ${box.ids.join(", ")}` : "") + (box.blockIds?.length ? `. Block ids (data-block-id): ${box.blockIds.join(", ")}` : "");
|
|
671
|
+
case "empty": return `"${selector}" matched an element with an empty box (${Math.round(box.width)}×${Math.round(box.height)}) — is it hidden at this width?`;
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
var HeadlessPage = class HeadlessPage {
|
|
675
|
+
browser;
|
|
676
|
+
sessionId;
|
|
677
|
+
messages = [];
|
|
678
|
+
height;
|
|
679
|
+
constructor(browser, sessionId, height) {
|
|
680
|
+
this.browser = browser;
|
|
681
|
+
this.sessionId = sessionId;
|
|
682
|
+
this.height = height;
|
|
683
|
+
}
|
|
684
|
+
static async create(browser, options) {
|
|
685
|
+
const cdp = browser.client;
|
|
686
|
+
const { targetId } = await cdp.send("Target.createTarget", { url: "about:blank" });
|
|
687
|
+
const { sessionId } = await cdp.send("Target.attachToTarget", {
|
|
688
|
+
targetId,
|
|
689
|
+
flatten: true
|
|
690
|
+
});
|
|
691
|
+
const page = new HeadlessPage(browser, sessionId, options.height ?? DEFAULT_HEIGHT);
|
|
692
|
+
cdp.onEvent((method, params, eventSession) => {
|
|
693
|
+
if (eventSession !== sessionId) return;
|
|
694
|
+
if (method === "Runtime.consoleAPICalled" && (params.type === "error" || params.type === "warning")) {
|
|
695
|
+
const text = (params.args ?? []).map((arg) => arg.value ?? arg.description ?? "").join(" ");
|
|
696
|
+
page.messages.push(`[console.${params.type}] ${text}`);
|
|
697
|
+
}
|
|
698
|
+
if (method === "Runtime.exceptionThrown") {
|
|
699
|
+
const details = params.exceptionDetails;
|
|
700
|
+
page.messages.push(`[pageerror] ${details?.exception?.description ?? details?.text ?? "unknown error"}`);
|
|
701
|
+
}
|
|
702
|
+
});
|
|
703
|
+
await cdp.send("Runtime.enable", {}, sessionId);
|
|
704
|
+
await cdp.send("Page.enable", {}, sessionId);
|
|
705
|
+
await setViewport(cdp, sessionId, options.width ?? DEFAULT_WIDTH, page.height);
|
|
706
|
+
if (options.reducedMotion !== false) await cdp.send("Emulation.setEmulatedMedia", { features: [{
|
|
707
|
+
name: "prefers-reduced-motion",
|
|
708
|
+
value: "reduce"
|
|
709
|
+
}] }, sessionId);
|
|
710
|
+
return page;
|
|
711
|
+
}
|
|
712
|
+
send(method, params = {}) {
|
|
713
|
+
return this.browser.client.send(method, params, this.sessionId);
|
|
714
|
+
}
|
|
715
|
+
async goto(url, options = {}) {
|
|
716
|
+
const navigation = await this.send("Page.navigate", { url });
|
|
717
|
+
if (navigation.errorText) throw new NavigationError(url, navigation.errorText);
|
|
718
|
+
await this.waitFor(options.ready ?? "document.readyState === 'complete'", options.timeout ?? DEFAULT_TIMEOUT);
|
|
719
|
+
if (options.settle !== false) await this.settle();
|
|
720
|
+
}
|
|
721
|
+
async evaluate(expression) {
|
|
722
|
+
const reply = await this.send("Runtime.evaluate", {
|
|
723
|
+
expression,
|
|
724
|
+
returnByValue: true,
|
|
725
|
+
awaitPromise: true
|
|
726
|
+
});
|
|
727
|
+
if (reply.exceptionDetails) {
|
|
728
|
+
const details = reply.exceptionDetails;
|
|
729
|
+
throw new Error(`Evaluation failed: ${details.exception?.description ?? details.text ?? "unknown error"}`);
|
|
730
|
+
}
|
|
731
|
+
return reply.result?.value;
|
|
732
|
+
}
|
|
733
|
+
waitFor(expression, timeout = DEFAULT_TIMEOUT) {
|
|
734
|
+
return waitForCondition(this.browser.client, this.sessionId, expression, timeout);
|
|
735
|
+
}
|
|
736
|
+
async setViewport(width, height = this.height) {
|
|
737
|
+
this.height = height;
|
|
738
|
+
await setViewport(this.browser.client, this.sessionId, width, height);
|
|
739
|
+
}
|
|
740
|
+
async scrollTo(y) {
|
|
741
|
+
await this.evaluate(`window.scrollTo({ top: ${Number(y)}, left: 0, behavior: 'instant' })`);
|
|
742
|
+
await this.nextFrame();
|
|
743
|
+
return this.evaluate("scrollY");
|
|
744
|
+
}
|
|
745
|
+
nextFrame() {
|
|
746
|
+
return nextFrames(this.browser.client, this.sessionId);
|
|
747
|
+
}
|
|
748
|
+
settle() {
|
|
749
|
+
return settlePageMedia(this.browser.client, this.sessionId, SETTLE_TIMEOUT);
|
|
750
|
+
}
|
|
751
|
+
freezeAnimations() {
|
|
752
|
+
return freezeAnimations(this.browser.client, this.sessionId);
|
|
753
|
+
}
|
|
754
|
+
unfreezeAnimations() {
|
|
755
|
+
return unfreezeAnimations(this.browser.client, this.sessionId);
|
|
756
|
+
}
|
|
757
|
+
async sectionClip(selector) {
|
|
758
|
+
let parts;
|
|
759
|
+
try {
|
|
760
|
+
parts = splitShadowSelector(selector);
|
|
761
|
+
} catch (error) {
|
|
762
|
+
throw new Error(describeSectionError(selector, {
|
|
763
|
+
error: "invalid",
|
|
764
|
+
message: error.message
|
|
765
|
+
}));
|
|
766
|
+
}
|
|
767
|
+
const fallback = parts.length === 1 ? blockIdFallback(selector) : null;
|
|
768
|
+
const box = await this.evaluate(`((find, parts, fallback) => {
|
|
769
|
+
const found = find(parts, fallback)
|
|
770
|
+
if (!found.element) return found
|
|
771
|
+
const rect = found.element.getBoundingClientRect()
|
|
772
|
+
if (rect.width < 1 || rect.height < 1) return { error: 'empty', width: rect.width, height: rect.height }
|
|
773
|
+
return { x: rect.x + scrollX, y: rect.y + scrollY, width: rect.width, height: rect.height }
|
|
774
|
+
})(${FIND_SECTION_SOURCE}, ${JSON.stringify(parts)}, ${JSON.stringify(fallback)})`);
|
|
775
|
+
if ("error" in box) throw new Error(describeSectionError(selector, box));
|
|
776
|
+
return box;
|
|
777
|
+
}
|
|
778
|
+
async screenshot(options = {}) {
|
|
779
|
+
const cdp = this.browser.client;
|
|
780
|
+
let clip;
|
|
781
|
+
let beyondViewport = true;
|
|
782
|
+
if (options.clip) clip = options.clip;
|
|
783
|
+
else if (options.section) {
|
|
784
|
+
const box = await this.sectionClip(options.section);
|
|
785
|
+
clip = {
|
|
786
|
+
...box,
|
|
787
|
+
height: Math.min(box.height, MAX_SHOT_HEIGHT)
|
|
788
|
+
};
|
|
789
|
+
if (clip.height < box.height) console.info(`("${options.section}" is ${Math.round(box.height)}px tall — clipped to ${MAX_SHOT_HEIGHT}px)`);
|
|
790
|
+
} else if (options.full) clip = await fullPageClip(cdp, this.sessionId);
|
|
791
|
+
else {
|
|
792
|
+
clip = await viewportClip(cdp, this.sessionId);
|
|
793
|
+
beyondViewport = false;
|
|
794
|
+
}
|
|
795
|
+
const shot = await this.send("Page.captureScreenshot", {
|
|
796
|
+
format: "png",
|
|
797
|
+
captureBeyondViewport: beyondViewport,
|
|
798
|
+
clip: {
|
|
799
|
+
...clip,
|
|
800
|
+
scale: options.scale ?? 1
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
return Buffer.from(shot.data, "base64");
|
|
804
|
+
}
|
|
805
|
+
close() {
|
|
806
|
+
return this.browser.close();
|
|
807
|
+
}
|
|
808
|
+
};
|
|
809
|
+
//#endregion
|
|
810
|
+
export { splitShadowSelector as a, openPage as i, PAGE_LOADED as n, MAX_SHOT_HEIGHT as o, describeSectionError as r, findDevServer as s, NavigationError as t };
|