srcpack 0.3.0 → 1.0.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,545 @@
1
+ // SPDX-License-Identifier: MIT
2
+
3
+ import { createRequire } from "node:module";
4
+ import { join } from "node:path";
5
+ import { setTimeout as delay } from "node:timers/promises";
6
+ import type * as PlaywrightModule from "playwright";
7
+ import type { ScreenshotSource } from "./config.ts";
8
+
9
+ /** A capture failure worth a clean message: no Playwright, no page, no browser. */
10
+ export class ScreenshotError extends Error {
11
+ /** Nothing answered at the URL — most often a dev server that isn't running. */
12
+ readonly unreachable: boolean;
13
+
14
+ constructor(message: string, options: { unreachable?: boolean } = {}) {
15
+ super(message);
16
+ this.name = "ScreenshotError";
17
+ this.unreachable = options.unreachable ?? false;
18
+ }
19
+ }
20
+
21
+ export type Viewport = "desktop" | "mobile";
22
+
23
+ /** A screenshot source with its defaults applied. */
24
+ export interface ScreenshotTarget {
25
+ url: string;
26
+ viewport: Viewport;
27
+ hide: string[];
28
+ }
29
+
30
+ export function toScreenshotTarget(source: ScreenshotSource): ScreenshotTarget {
31
+ return typeof source === "string"
32
+ ? { url: source, viewport: "desktop", hide: [] }
33
+ : {
34
+ url: source.url,
35
+ viewport: source.viewport ?? "desktop",
36
+ hide: source.hide ?? [],
37
+ };
38
+ }
39
+
40
+ // Vision models downscale each image to a fixed pixel budget, so a tall page
41
+ // captured whole arrives as a thumbnail. Detail slices at most 2,200 device px
42
+ // tall stay legible after that downscaling. Overlap preserves text across slice
43
+ // boundaries. Device px keep the image height consistent across DPRs: at DPR 2,
44
+ // each slice covers half as many CSS pixels.
45
+ const SLICE_HEIGHT = 2200;
46
+ const SLICE_OVERLAP = 160;
47
+
48
+ /** A vertical region of the page, in CSS px. */
49
+ export interface Slice {
50
+ y: number;
51
+ height: number;
52
+ }
53
+
54
+ /**
55
+ * Split a page into the fewest overlapping slices that cover it top to bottom,
56
+ * each overlapping the next by at least `SLICE_OVERLAP` device px.
57
+ *
58
+ * Slices then shrink to share the page evenly rather than staying at the
59
+ * maximum: a page one pixel taller than a slice becomes two half-page slices,
60
+ * not two near-identical full ones that spend a model's attention twice.
61
+ */
62
+ export function planSlices(cssHeight: number, dpr: number): Slice[] {
63
+ const maxHeight = SLICE_HEIGHT / dpr;
64
+ const overlap = SLICE_OVERLAP / dpr;
65
+ if (cssHeight <= maxHeight) return [{ y: 0, height: cssHeight }];
66
+
67
+ const count = Math.ceil((cssHeight - overlap) / (maxHeight - overlap));
68
+ // Exactly `overlap` between neighbours at this height; rounded up to whole
69
+ // CSS px, which only adds overlap, and never past `maxHeight` given `count`
70
+ const height = Math.ceil((cssHeight + (count - 1) * overlap) / count);
71
+ const step = (cssHeight - height) / (count - 1);
72
+ // Starts are floored, which never widens a gap past `height - overlap`. The
73
+ // last start is set, not computed: `i * step` can round below the exact
74
+ // value and leave the bottom row uncovered.
75
+ return Array.from({ length: count }, (_, i) => ({
76
+ y: i === count - 1 ? cssHeight - height : Math.floor(i * step),
77
+ height,
78
+ }));
79
+ }
80
+
81
+ /**
82
+ * `home-00.png`, `home-01.png`, … Index 0 is the whole page, so filename
83
+ * order is review order. Padding grows with the highest index so the order
84
+ * holds past 99.
85
+ */
86
+ export function imageFileName(
87
+ name: string,
88
+ index: number,
89
+ highestIndex: number,
90
+ ): string {
91
+ const width = Math.max(2, String(highestIndex).length);
92
+ return `${name}-${String(index).padStart(width, "0")}.png`;
93
+ }
94
+
95
+ /**
96
+ * Whether `file` is one of bundle `name`'s numbered images. The suffix is
97
+ * digits only, so `home-01-02.png` (bundle `home-01`) is never one of `home`'s.
98
+ *
99
+ * An exact match, because it decides what stale-image cleanup deletes and
100
+ * folding could only widen that (ADR 004). To ask whether two spellings
101
+ * collide, pass `pathKey`s.
102
+ */
103
+ export function isImageOf(name: string, file: string): boolean {
104
+ return (
105
+ file.startsWith(`${name}-`) &&
106
+ /^\d{2,}\.png$/.test(file.slice(name.length + 1))
107
+ );
108
+ }
109
+
110
+ export type Playwright = Pick<typeof PlaywrightModule, "chromium" | "devices">;
111
+
112
+ /** Loads a module by specifier, throwing `MODULE_NOT_FOUND` when absent. */
113
+ export type Require = (id: string) => unknown;
114
+
115
+ /**
116
+ * Where Playwright may be found, most specific first. The project comes before
117
+ * srcpack: under `npx srcpack`, srcpack runs from the npm cache, where a bare
118
+ * `import("playwright")` never sees the project's copy. `@playwright/test`
119
+ * re-exports `chromium` and `devices`, so a project with Playwright Test needs
120
+ * nothing new.
121
+ */
122
+ function candidates(root: string): [Require, string][] {
123
+ const project = createRequire(join(root, "package.json"));
124
+ const own = createRequire(import.meta.url);
125
+ return [
126
+ [project, "playwright"],
127
+ [project, "@playwright/test"],
128
+ [own, "playwright"],
129
+ ];
130
+ }
131
+
132
+ // 1.41 added the screenshot `style` option, which hides elements without
133
+ // mutating the page. Checked here rather than in the peer range, which is `*`
134
+ // so installs that never capture aren't judged by their Playwright version.
135
+ const MIN_MINOR = 41;
136
+
137
+ /**
138
+ * Load Playwright without bundling it. `require` through `createRequire` keeps
139
+ * `bun build` from inlining an optional peer that most users never install.
140
+ */
141
+ export function loadPlaywright(
142
+ root: string,
143
+ from: [Require, string][] = candidates(root),
144
+ userAgent = process.env.npm_config_user_agent,
145
+ ): Playwright {
146
+ for (const [load, id] of from) {
147
+ let version: string;
148
+ try {
149
+ ({ version } = load(`${id}/package.json`) as { version: string });
150
+ } catch (error) {
151
+ if ((error as NodeJS.ErrnoException).code === "MODULE_NOT_FOUND") {
152
+ continue;
153
+ }
154
+ throw error;
155
+ }
156
+ const [major, minor] = version.split(".").map(Number);
157
+ if (major !== 1 || minor! < MIN_MINOR) {
158
+ throw new ScreenshotError(
159
+ major === 1
160
+ ? `Playwright ${version} is too old; srcpack needs 1.${MIN_MINOR} or newer.`
161
+ : `Playwright ${version} is not supported; srcpack needs 1.${MIN_MINOR} or a later 1.x.`,
162
+ );
163
+ }
164
+ return load(id) as Playwright;
165
+ }
166
+ const pm = packageManager(userAgent);
167
+ throw new ScreenshotError(
168
+ `screenshots need Playwright. Install it with:\n ${pm.add} playwright && ${pm.exec} playwright install chromium`,
169
+ );
170
+ }
171
+
172
+ /**
173
+ * Launch Playwright's Chromium, falling back to system Chrome only when the
174
+ * executable is missing: the browser download is where this flow loses people,
175
+ * and most developers already have Chrome. Other launch errors stay visible.
176
+ */
177
+ export async function launchBrowser(
178
+ playwright: Playwright,
179
+ userAgent = process.env.npm_config_user_agent,
180
+ ): Promise<PlaywrightModule.Browser> {
181
+ try {
182
+ return await playwright.chromium.launch();
183
+ } catch (error) {
184
+ if (!isMissingBrowser(error)) throw error;
185
+ }
186
+ try {
187
+ return await playwright.chromium.launch({ channel: "chrome" });
188
+ } catch (error) {
189
+ // Chrome that exists but fails to start has a real reason worth showing
190
+ if (!isMissingBrowser(error)) throw error;
191
+ const pm = packageManager(userAgent);
192
+ throw new ScreenshotError(
193
+ `screenshots need a browser. Install Chromium with:\n ${pm.exec} playwright install chromium`,
194
+ );
195
+ }
196
+ }
197
+
198
+ /** Playwright's wording for a bundled build, then for a system channel. */
199
+ function isMissingBrowser(error: unknown): boolean {
200
+ return /Executable doesn't exist|distribution '[^']+' is not found/.test(
201
+ (error as Error).message,
202
+ );
203
+ }
204
+
205
+ /** What a capture produced. Dimensions are the page's, in CSS px. */
206
+ export interface CapturedPage {
207
+ width: number;
208
+ height: number;
209
+ /**
210
+ * Index 0 is the whole page, 1… the detail slices top to bottom. An index is
211
+ * carried rather than implied by position, so an omitted overview leaves
212
+ * `01…` in place instead of renumbering a detail slice into `00`.
213
+ */
214
+ images: { index: number; data: Uint8Array }[];
215
+ }
216
+
217
+ /** One browser for a whole run, launched when the first capture needs it. */
218
+ export interface Capturer {
219
+ capture(
220
+ target: ScreenshotTarget,
221
+ warn: (message: string) => void,
222
+ ): Promise<CapturedPage>;
223
+ /** Close the browser, if one was launched. Never throws. */
224
+ close(): Promise<void>;
225
+ }
226
+
227
+ // Standalone Playwright has no test deadline to fall back on, so every
228
+ // navigation and capture gets an explicit one.
229
+ const TIMEOUT = 30_000;
230
+
231
+ // Settling scrolls one viewport per step, dwelling so observers fire and
232
+ // images start loading at each position, and waits for the network at each
233
+ // apparent bottom. The step cap bounds infinite scroll; the deadline bounds
234
+ // the whole walk, so a page that keeps appending can't turn every step into a
235
+ // network wait.
236
+ const SETTLE_STEPS = 50;
237
+ const SETTLE_DWELL = 100;
238
+ const SETTLE_DEADLINE = 15_000;
239
+
240
+ // The network counts as idle after 500 ms without a request in flight — the
241
+ // same window as Playwright's `networkidle`. Capped, since uncapped it hangs on
242
+ // beacons and long polling.
243
+ const NETWORK_QUIET = 500;
244
+ const NETWORK_IDLE = 5_000;
245
+
246
+ // OpenAI's documented per-image upload limit
247
+ const CHATGPT_MAX_IMAGE_BYTES = 20 * 1024 * 1024;
248
+
249
+ // Framework dev chrome that would otherwise be reviewed as part of the design.
250
+ // Not `nextjs-portal`: it also hosts Next's build and runtime error overlay,
251
+ // and a clean page over a broken app is worse evidence than a noisy one.
252
+ const DEV_OVERLAYS = ["astro-dev-toolbar", "nuxt-devtools-container"];
253
+
254
+ /** `net::` codes that mean nothing is listening, not that the page is broken. */
255
+ const UNREACHABLE: Record<string, string> = {
256
+ ERR_CONNECTION_REFUSED: "connection refused",
257
+ ERR_CONNECTION_RESET: "connection reset",
258
+ ERR_CONNECTION_TIMED_OUT: "connection timed out",
259
+ ERR_ADDRESS_UNREACHABLE: "address unreachable",
260
+ ERR_NAME_NOT_RESOLVED: "host not found",
261
+ };
262
+
263
+ export function createCapturer(
264
+ root: string,
265
+ load: () => Playwright = () => loadPlaywright(root),
266
+ ): Capturer {
267
+ let session:
268
+ | Promise<{ playwright: Playwright; browser: PlaywrightModule.Browser }>
269
+ | undefined;
270
+
271
+ return {
272
+ async capture(target, warn) {
273
+ session ??= (async () => {
274
+ const playwright = load();
275
+ return { playwright, browser: await launchBrowser(playwright) };
276
+ })();
277
+ const { playwright, browser } = await session;
278
+ const context = await browser.newContext(
279
+ contextOptions(playwright, target.viewport),
280
+ );
281
+ try {
282
+ return await capturePage(await context.newPage(), target, warn);
283
+ } finally {
284
+ await context.close();
285
+ }
286
+ },
287
+ async close() {
288
+ // A launch that failed has nothing to close, and its error is reported
289
+ const opened = await session?.catch(() => undefined);
290
+ await opened?.browser.close().catch(() => {});
291
+ },
292
+ };
293
+ }
294
+
295
+ /**
296
+ * Desktop is 1440×900 at DPR 1. Mobile starts from Playwright's `Pixel 7`
297
+ * profile — a mobile user agent matters, since server-rendered responsive
298
+ * sites send desktop markup to a desktop one — with DPR normalized from 2.625
299
+ * to 2, so detail slices are 824 px wide: a text budget comparable to desktop,
300
+ * deliberately not exact emulation.
301
+ */
302
+ function contextOptions(
303
+ playwright: Playwright,
304
+ viewport: Viewport,
305
+ ): PlaywrightModule.BrowserContextOptions {
306
+ return viewport === "mobile"
307
+ ? { ...playwright.devices["Pixel 7"], deviceScaleFactor: 2 }
308
+ : { viewport: { width: 1440, height: 900 }, deviceScaleFactor: 1 };
309
+ }
310
+
311
+ async function capturePage(
312
+ page: PlaywrightModule.Page,
313
+ target: ScreenshotTarget,
314
+ warn: (message: string) => void,
315
+ ): Promise<CapturedPage> {
316
+ page.setDefaultTimeout(TIMEOUT);
317
+ const network = watchNetwork(page);
318
+ await open(page, target.url);
319
+ await settle(page, network, warn);
320
+
321
+ // In-page code is passed as strings: srcpack is typed without DOM globals.
322
+ //
323
+ // Width is the layout viewport — 980 px for a page without
324
+ // `<meta name="viewport">` under mobile emulation, as on a real phone — not
325
+ // the scroll width: accidental horizontal overflow would otherwise widen
326
+ // every slice past the text budget. Height is what window scrolling reaches,
327
+ // the same element `settle` scrolled; an app that scrolls inside its own
328
+ // container has nothing further down to slice.
329
+ const { width, height, dpr } = await page.evaluate<{
330
+ width: number;
331
+ height: number;
332
+ dpr: number;
333
+ }>(`({
334
+ width: document.documentElement.clientWidth,
335
+ height: (document.scrollingElement ?? document.documentElement).scrollHeight,
336
+ dpr: devicePixelRatio,
337
+ })`);
338
+
339
+ // Hidden through the capture's own stylesheet, so the page isn't mutated
340
+ const options = {
341
+ animations: "disabled",
342
+ style: [...DEV_OVERLAYS, ...target.hide]
343
+ .map((selector) => `${selector} { visibility: hidden !important; }`)
344
+ .join("\n"),
345
+ } as const;
346
+
347
+ const slices = planSlices(height, dpr);
348
+ const images: CapturedPage["images"] = [];
349
+ for (const [i, { y, height: sliceHeight }] of slices.entries()) {
350
+ const data = await page.screenshot({
351
+ ...options,
352
+ fullPage: true,
353
+ clip: { x: 0, y, width, height: sliceHeight },
354
+ scale: "device",
355
+ });
356
+ // A page that fits in one slice is its own overview
357
+ images.push({ index: slices.length === 1 ? 0 : i + 1, data });
358
+ }
359
+
360
+ if (slices.length > 1) {
361
+ const overview = await captureOverview(page, options, warn);
362
+ if (overview) images.unshift({ index: 0, data: overview });
363
+ }
364
+
365
+ return { width, height, images };
366
+ }
367
+
368
+ /**
369
+ * Navigate, failing on anything but a successful page. A screenshot of a 404
370
+ * is wrong output that looks right. No separate `fetch` first: it doubles the
371
+ * request and can disagree with the browser about redirects and headers.
372
+ */
373
+ async function open(page: PlaywrightModule.Page, url: string): Promise<void> {
374
+ let response;
375
+ try {
376
+ response = await page.goto(url, { waitUntil: "load" });
377
+ } catch (error) {
378
+ const { name, message } = error as Error;
379
+ const code = /net::(ERR_[A-Z_]+)/.exec(message)?.[1];
380
+ if (code && Object.hasOwn(UNREACHABLE, code)) {
381
+ throw new ScreenshotError(
382
+ `${url} is not reachable (${UNREACHABLE[code]}). Is your dev server running?`,
383
+ { unreachable: true },
384
+ );
385
+ }
386
+ throw new ScreenshotError(
387
+ name === "TimeoutError"
388
+ ? `${url} did not finish loading within ${TIMEOUT / 1000} s.`
389
+ : `${url} failed to load (${code ?? message.split("\n")[0]}).`,
390
+ );
391
+ }
392
+ if (response && !response.ok()) {
393
+ const status = [response.status(), response.statusText()].join(" ");
394
+ throw new ScreenshotError(`${url} returned ${status.trim()}.`);
395
+ }
396
+ }
397
+
398
+ /**
399
+ * Let a page finish rendering before capture. A full-page screenshot doesn't
400
+ * move the viewport, so lazy images and IntersectionObserver content would
401
+ * stay blank.
402
+ *
403
+ * Scrolls until the viewport can go no further, re-reading the page each step
404
+ * so sections that load taller than their placeholders are followed. At that
405
+ * apparent bottom it waits for the network — covering late page data and what
406
+ * scrolling started — then tries again: a response that appends content has
407
+ * to be scrolled through too, or its own lazy content is sliced but blank.
408
+ * The bottom is stable once a wait there brings no growth; only then does it
409
+ * return to the top. No explicit font wait: Playwright's screenshot already
410
+ * awaits `document.fonts.ready`.
411
+ */
412
+ async function settle(
413
+ page: PlaywrightModule.Page,
414
+ network: NetworkWatch,
415
+ warn: (message: string) => void,
416
+ ): Promise<void> {
417
+ const deadline = Date.now() + SETTLE_DEADLINE;
418
+ let steps = 0;
419
+ // Whether the network has gone quiet since the last scroll
420
+ let waitedHere = false;
421
+ for (;;) {
422
+ // `instant` overrides CSS `scroll-behavior: smooth`, which would leave
423
+ // `scrollY` unchanged when read back and end the walk early
424
+ const moved = await page.evaluate<boolean>(`(() => {
425
+ const before = scrollY;
426
+ scrollTo({ top: before + innerHeight, behavior: "instant" });
427
+ return scrollY !== before;
428
+ })()`);
429
+ if (moved) {
430
+ waitedHere = false;
431
+ if (++steps >= SETTLE_STEPS || Date.now() >= deadline) {
432
+ const reached = await page.evaluate<number>("scrollY + innerHeight");
433
+ warn(
434
+ `page kept growing while scrolling; content below ${reached.toLocaleString("en-US")} px may not have loaded.`,
435
+ );
436
+ break;
437
+ }
438
+ await delay(SETTLE_DWELL);
439
+ } else if (waitedHere) {
440
+ break;
441
+ } else {
442
+ await network.idle(deadline - Date.now());
443
+ waitedHere = true;
444
+ }
445
+ }
446
+
447
+ await page.evaluate(`scrollTo({ top: 0, behavior: "instant" })`);
448
+ }
449
+
450
+ export interface NetworkWatch {
451
+ /**
452
+ * Resolves after the quiet window with nothing in flight, or after `limit`
453
+ * ms — never longer than the cap.
454
+ */
455
+ idle(limit?: number): Promise<void>;
456
+ }
457
+
458
+ /** The part of a Playwright page that reports requests. */
459
+ export interface RequestEvents {
460
+ on(
461
+ event: "request" | "requestfinished" | "requestfailed",
462
+ listener: (request: unknown) => void,
463
+ ): unknown;
464
+ }
465
+
466
+ /**
467
+ * Track requests from before navigation onward. Not Playwright's `networkidle`
468
+ * load state: once reached it resolves immediately, so waiting for it after
469
+ * scrolling misses exactly the requests scrolling started.
470
+ *
471
+ * Quiet is measured from the last request event, not sampled: a request that
472
+ * starts and finishes between two polls still restarts the window.
473
+ */
474
+ export function watchNetwork(
475
+ page: RequestEvents,
476
+ { quiet = NETWORK_QUIET, cap = NETWORK_IDLE } = {},
477
+ ): NetworkWatch {
478
+ const inflight = new Set<unknown>();
479
+ let lastActivity = Date.now();
480
+ page.on("request", (request) => {
481
+ inflight.add(request);
482
+ lastActivity = Date.now();
483
+ });
484
+ const settled = (request: unknown) => {
485
+ inflight.delete(request);
486
+ lastActivity = Date.now();
487
+ };
488
+ page.on("requestfinished", settled);
489
+ page.on("requestfailed", settled);
490
+
491
+ return {
492
+ async idle(limit = cap) {
493
+ const deadline = Date.now() + Math.min(limit, cap);
494
+ while (Date.now() < deadline) {
495
+ if (!inflight.size && Date.now() - lastActivity >= quiet) return;
496
+ await delay(Math.min(50, deadline - Date.now()));
497
+ }
498
+ },
499
+ };
500
+ }
501
+
502
+ /**
503
+ * The whole page at CSS scale, for layout. Best-effort: very tall pages exceed
504
+ * Chromium's texture limits, and an image past the upload limit can't be
505
+ * attached. Detail slices cover the page either way.
506
+ */
507
+ async function captureOverview(
508
+ page: PlaywrightModule.Page,
509
+ options: PlaywrightModule.PageScreenshotOptions,
510
+ warn: (message: string) => void,
511
+ ): Promise<Uint8Array | undefined> {
512
+ let data: Uint8Array;
513
+ try {
514
+ data = await page.screenshot({ ...options, fullPage: true, scale: "css" });
515
+ } catch (error) {
516
+ warn(
517
+ `skipped the whole-page overview: ${(error as Error).message.split("\n")[0]}. Detail slices are complete.`,
518
+ );
519
+ return undefined;
520
+ }
521
+ if (data.byteLength > CHATGPT_MAX_IMAGE_BYTES) {
522
+ warn(
523
+ `skipped the whole-page overview: ${Math.ceil(data.byteLength / 1024 / 1024)} MB is over ChatGPT's 20 MB per-image limit. Detail slices are complete.`,
524
+ );
525
+ return undefined;
526
+ }
527
+ return data;
528
+ }
529
+
530
+ /**
531
+ * Match install commands to `npm_config_user_agent`, defaulting to npm when
532
+ * the invoking package manager is unknown.
533
+ */
534
+ export function packageManager(userAgent = ""): { add: string; exec: string } {
535
+ switch (userAgent.split("/")[0]) {
536
+ case "bun":
537
+ return { add: "bun add -d", exec: "bunx" };
538
+ case "pnpm":
539
+ return { add: "pnpm add -D", exec: "pnpm exec" };
540
+ case "yarn":
541
+ return { add: "yarn add -D", exec: "yarn" };
542
+ default:
543
+ return { add: "npm install -D", exec: "npx" };
544
+ }
545
+ }