srcpack 0.2.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.
- package/README.md +55 -19
- package/dist/args.d.ts +26 -0
- package/dist/bundle.d.ts +26 -5
- package/dist/cli.js +5454 -15133
- package/dist/config.d.ts +92 -11
- package/dist/fs.d.ts +41 -0
- package/dist/index.js +448 -10447
- package/dist/linear.d.ts +17 -0
- package/dist/plan.d.ts +64 -0
- package/dist/screenshot.d.ts +113 -0
- package/package.json +13 -1
- package/src/args.ts +221 -0
- package/src/bundle.ts +219 -51
- package/src/cli.ts +304 -236
- package/src/config.ts +250 -37
- package/src/fs.ts +80 -0
- package/src/linear.ts +368 -0
- package/src/plan.ts +238 -0
- package/src/screenshot.ts +545 -0
|
@@ -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
|
+
}
|