terminalhire 0.42.29 → 0.42.30

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,420 @@
1
+ /**
2
+ * captureEntry.ts — screenshots of a web app, as the capture container's main process
3
+ * (TERM-1258).
4
+ *
5
+ * Runs inside our own image (`capture.ts`, `CAPTURE_DOCKERFILE`), never inside the
6
+ * repository's. The repository's code has already run: its build wrote files into the
7
+ * clone, or its serve script is listening in a container this one shares a network
8
+ * namespace with. This process serves or reaches that app over loopback, drives a
9
+ * headless Chromium through each route × viewport × colour scheme, and writes PNGs
10
+ * plus `manifest.json` to /out.
11
+ *
12
+ * No network in either mode: static capture runs under `--network=none`, and server
13
+ * capture joins an app container that itself has `--network=none`. Whatever the page
14
+ * requests from the internet fails, so what is shown is what the repository renders
15
+ * on its own, which is also why the gallery says "without a backend".
16
+ *
17
+ * Self-contained on purpose: this file is copied alone into the container, so it
18
+ * imports node builtins and `playwright-core` (installed in the image) and nothing
19
+ * from this package.
20
+ */
21
+ import { createHash } from 'node:crypto';
22
+ import { existsSync, readdirSync, readFileSync, realpathSync, statSync, writeFileSync, } from 'node:fs';
23
+ import { createServer } from 'node:http';
24
+ import { createRequire } from 'node:module';
25
+ import { extname, isAbsolute, join, relative, sep } from 'node:path';
26
+ export const VIEWPORTS = {
27
+ desktop: { width: 1280, height: 800, isMobile: false },
28
+ mobile: { width: 390, height: 844, isMobile: true },
29
+ };
30
+ const SCHEMES = ['light', 'dark'];
31
+ // ---- which directory is the site -------------------------------------------
32
+ /**
33
+ * When the build started, read off the marker the build step wrote. Taken from the
34
+ * file's own mtime rather than the host's clock, because the build ran in a VM on
35
+ * Docker Desktop whose clock can drift from the host's by seconds — enough to
36
+ * mistake a source index.html cloned just before for build output.
37
+ */
38
+ export function buildStartedAt(siteRoot, sinceFile) {
39
+ if (sinceFile === undefined)
40
+ return Number.POSITIVE_INFINITY;
41
+ try {
42
+ return statSync(join(siteRoot, sinceFile)).mtimeMs;
43
+ }
44
+ catch {
45
+ return Number.POSITIVE_INFINITY;
46
+ }
47
+ }
48
+ const NEVER_SITE = new Set(['node_modules', '.git']);
49
+ const MAX_DEPTH = 6;
50
+ /**
51
+ * The directory to serve, or null when there is nothing static to serve.
52
+ *
53
+ * A build's output is recognised by what it did, not by what it is called: any
54
+ * `index.html` written after the build started. Output directories are named by
55
+ * each toolchain differently and are configurable in all of them, so a list of
56
+ * names would be a list of the toolchains someone thought of. The shallowest wins,
57
+ * because nested ones are pages of the site, not the site.
58
+ */
59
+ export function findStaticSite(root, sinceMs, rootHtml) {
60
+ const fresh = [];
61
+ const walk = (dir, depth) => {
62
+ if (depth > MAX_DEPTH)
63
+ return;
64
+ let names;
65
+ try {
66
+ names = readdirSync(dir).sort();
67
+ }
68
+ catch {
69
+ return;
70
+ }
71
+ for (const name of names) {
72
+ if (NEVER_SITE.has(name))
73
+ continue;
74
+ const path = join(dir, name);
75
+ let st;
76
+ try {
77
+ st = statSync(path);
78
+ }
79
+ catch {
80
+ continue;
81
+ }
82
+ if (st.isDirectory())
83
+ walk(path, depth + 1);
84
+ else if (name === 'index.html' && st.mtimeMs >= sinceMs)
85
+ fresh.push(dir);
86
+ }
87
+ };
88
+ walk(root, 0);
89
+ fresh.sort((a, b) => a.split(sep).length - b.split(sep).length || a.localeCompare(b));
90
+ const dir = fresh[0] ?? (rootHtml && existsSync(join(root, 'index.html')) ? root : null);
91
+ if (dir === null)
92
+ return null;
93
+ return { dir, basePath: inferBasePath(readFileSync(join(dir, 'index.html'), 'utf8'), dir) };
94
+ }
95
+ /**
96
+ * The URL prefix the site was built for. A site built for `/app/` asks for
97
+ * `/app/assets/x.js` while the file sits at `assets/x.js`. If most absolute asset
98
+ * URLs resolve only after dropping their first segment, that segment is the base.
99
+ */
100
+ export function inferBasePath(html, siteDir) {
101
+ const urls = [...html.matchAll(/\b(?:src|href)\s*=\s*["'](\/[^"'#?]*)/g)]
102
+ .map((m) => m[1] ?? '')
103
+ .filter((u) => !u.startsWith('//'));
104
+ const votes = new Map();
105
+ let asIs = 0;
106
+ for (const url of urls) {
107
+ if (isFile(join(siteDir, url))) {
108
+ asIs++;
109
+ continue;
110
+ }
111
+ const [, first, ...rest] = url.split('/');
112
+ if (first && rest.length > 0 && isFile(join(siteDir, ...rest))) {
113
+ votes.set(first, (votes.get(first) ?? 0) + 1);
114
+ }
115
+ }
116
+ let best = null;
117
+ for (const entry of votes)
118
+ if (best === null || entry[1] > best[1])
119
+ best = entry;
120
+ return best !== null && best[1] > asIs ? `/${best[0]}/` : '/';
121
+ }
122
+ // ---- serving ---------------------------------------------------------------
123
+ /**
124
+ * Map a request path to a file under the site, the way static hosts do: the file
125
+ * itself, then `dir/index.html`, then `name.html`, then — for a path with no
126
+ * extension — the root `index.html`, which is how a client-side router gets its
127
+ * shell. A missing asset is a 404 rather than the shell, so a broken asset shows
128
+ * up broken instead of as HTML parsed as script.
129
+ */
130
+ export function resolveRequestPath(siteDir, basePath, urlPath) {
131
+ let decoded;
132
+ try {
133
+ decoded = decodeURIComponent(urlPath);
134
+ }
135
+ catch {
136
+ return { status: 404 };
137
+ }
138
+ const prefix = basePath.replace(/\/$/, '');
139
+ if (prefix !== '' && decoded !== prefix && !decoded.startsWith(`${prefix}/`)) {
140
+ return { status: 404 };
141
+ }
142
+ const rel = decoded.slice(prefix.length).replace(/^\/+/, '');
143
+ // Lexically AND after following links: the build wrote this tree, and a
144
+ // `dist/leak -> /etc/passwd` passes any check on the path as written.
145
+ const within = (base, p) => {
146
+ const r = relative(base, p);
147
+ return r === '' || (r !== '..' && !r.startsWith(`..${sep}`) && !isAbsolute(r));
148
+ };
149
+ let realSite;
150
+ try {
151
+ realSite = realpathSync(siteDir);
152
+ }
153
+ catch {
154
+ return { status: 404 };
155
+ }
156
+ const inside = (p) => {
157
+ if (!within(siteDir, p))
158
+ return false;
159
+ try {
160
+ return within(realSite, realpathSync(p));
161
+ }
162
+ catch {
163
+ return true; // does not exist; isFile() below says no
164
+ }
165
+ };
166
+ const candidate = join(siteDir, rel);
167
+ if (!inside(candidate))
168
+ return { status: 404 };
169
+ const tries = [candidate, join(candidate, 'index.html'), `${candidate.replace(/\/$/, '')}.html`];
170
+ for (const t of tries)
171
+ if (inside(t) && isFile(t))
172
+ return { file: t };
173
+ if (extname(rel) !== '')
174
+ return { status: 404 };
175
+ const shell = join(siteDir, 'index.html');
176
+ return inside(shell) && isFile(shell) ? { file: shell } : { status: 404 };
177
+ }
178
+ const CONTENT_TYPES = {
179
+ '.html': 'text/html; charset=utf-8',
180
+ '.js': 'text/javascript; charset=utf-8',
181
+ '.mjs': 'text/javascript; charset=utf-8',
182
+ '.css': 'text/css; charset=utf-8',
183
+ '.json': 'application/json',
184
+ '.svg': 'image/svg+xml',
185
+ '.png': 'image/png',
186
+ '.jpg': 'image/jpeg',
187
+ '.jpeg': 'image/jpeg',
188
+ '.gif': 'image/gif',
189
+ '.webp': 'image/webp',
190
+ '.avif': 'image/avif',
191
+ '.ico': 'image/x-icon',
192
+ '.woff': 'font/woff',
193
+ '.woff2': 'font/woff2',
194
+ '.ttf': 'font/ttf',
195
+ '.otf': 'font/otf',
196
+ '.wasm': 'application/wasm',
197
+ '.txt': 'text/plain; charset=utf-8',
198
+ '.xml': 'application/xml',
199
+ '.webmanifest': 'application/manifest+json',
200
+ };
201
+ function serveStatic(siteDir, basePath) {
202
+ const server = createServer((req, res) => {
203
+ const path = new URL(req.url ?? '/', 'http://site').pathname;
204
+ const found = resolveRequestPath(siteDir, basePath, path);
205
+ if ('status' in found) {
206
+ res.writeHead(404).end();
207
+ return;
208
+ }
209
+ res.writeHead(200, {
210
+ 'content-type': CONTENT_TYPES[extname(found.file).toLowerCase()] ?? 'application/octet-stream',
211
+ });
212
+ res.end(readFileSync(found.file));
213
+ });
214
+ return new Promise((resolve, reject) => {
215
+ server.once('error', reject);
216
+ server.listen(0, '127.0.0.1', () => {
217
+ const address = server.address();
218
+ resolve({ server, port: typeof address === 'object' && address ? address.port : 0 });
219
+ });
220
+ });
221
+ }
222
+ // ---- server strategy: which port the app opened ----------------------------
223
+ /**
224
+ * Ports in LISTEN state (st 0A), from /proc/net/tcp or tcp6. Sharing the app
225
+ * container's network namespace makes its sockets ours to read, so the app's port
226
+ * is observed rather than assumed: no framework's default port, and no reliance on
227
+ * the app honouring $PORT.
228
+ */
229
+ export function listeningPorts(procNetTcp) {
230
+ const ports = new Set();
231
+ for (const line of procNetTcp.split('\n').slice(1)) {
232
+ const cols = line.trim().split(/\s+/);
233
+ if (cols[3] !== '0A')
234
+ continue;
235
+ const port = Number.parseInt((cols[1] ?? '').split(':')[1] ?? '', 16);
236
+ if (Number.isInteger(port) && port > 0)
237
+ ports.add(port);
238
+ }
239
+ return [...ports].sort((a, b) => a - b);
240
+ }
241
+ // ---- naming and flags ------------------------------------------------------
242
+ /**
243
+ * The slug is for people; the hash is what keeps routes apart. `/a/b` and `/a-b`
244
+ * slug the same, and a later shot overwriting an earlier one would leave the
245
+ * manifest describing a file that is no longer there. `/` alone keeps the bare name.
246
+ */
247
+ export function shotName(route, viewport, scheme) {
248
+ const slug = route
249
+ .replace(/^\/+|\/+$/g, '')
250
+ .replace(/[^A-Za-z0-9]+/g, '-')
251
+ .replace(/^-+|-+$/g, '')
252
+ .slice(0, 60) || 'root';
253
+ const id = route === '/' ? '' : `-${createHash('sha256').update(route).digest('hex').slice(0, 8)}`;
254
+ return `${slug}${id}--${viewport}--${scheme}.png`;
255
+ }
256
+ /** Route and viewport pairs whose light and dark shots are byte-identical. */
257
+ export function sameAcrossSchemes(items) {
258
+ const out = [];
259
+ for (const light of items) {
260
+ if (light.scheme !== 'light')
261
+ continue;
262
+ const dark = items.find((i) => i.scheme === 'dark' && i.route === light.route && i.viewport === light.viewport);
263
+ if (dark && dark.sha256 === light.sha256) {
264
+ out.push({ route: light.route, viewport: light.viewport });
265
+ }
266
+ }
267
+ return out;
268
+ }
269
+ function isFile(path) {
270
+ try {
271
+ return statSync(path).isFile();
272
+ }
273
+ catch {
274
+ return false;
275
+ }
276
+ }
277
+ const PNG_SIZE = (png) => ({
278
+ width: png.readUInt32BE(16),
279
+ height: png.readUInt32BE(20),
280
+ });
281
+ async function shoot(origin, basePath, req, out) {
282
+ const load = createRequire(join(process.env.NODE_PATH ?? '/usr/local/lib/node_modules', '_'));
283
+ const { chromium } = load('playwright-core');
284
+ // The container is the sandbox: no capabilities, no network, a throwaway
285
+ // filesystem. Chromium's own sandbox needs privileges this container refuses.
286
+ const browser = await chromium.launch({ args: ['--no-sandbox', '--disable-dev-shm-usage'] });
287
+ const items = [];
288
+ const failures = [];
289
+ try {
290
+ for (const route of req.routes) {
291
+ const url = `${origin}${basePath.replace(/\/$/, '')}${route.startsWith('/') ? route : `/${route}`}`;
292
+ for (const viewport of Object.keys(VIEWPORTS)) {
293
+ for (const scheme of SCHEMES) {
294
+ const v = VIEWPORTS[viewport];
295
+ const context = await browser.newContext({
296
+ viewport: { width: v.width, height: v.height },
297
+ isMobile: v.isMobile,
298
+ hasTouch: v.isMobile,
299
+ colorScheme: scheme,
300
+ deviceScaleFactor: 1,
301
+ });
302
+ try {
303
+ const page = await context.newPage();
304
+ await page.goto(url, { waitUntil: 'load', timeout: 20_000 });
305
+ // A page with no backend may keep retrying forever, so idle is
306
+ // waited for briefly and never required.
307
+ await page.waitForLoadState('networkidle', { timeout: 5_000 }).catch(() => undefined);
308
+ await page.waitForTimeout(300);
309
+ // Frozen, so two shots of the same page are the same bytes: a spinner
310
+ // caught on different frames would otherwise hide that light and dark
311
+ // render identically, which is the one comparison the notes rely on.
312
+ const png = await page.screenshot({ animations: 'disabled', caret: 'hide' });
313
+ const file = shotName(route, viewport, scheme);
314
+ writeFileSync(join(out, file), png);
315
+ items.push({
316
+ route,
317
+ viewport,
318
+ scheme,
319
+ file,
320
+ sha256: createHash('sha256').update(png).digest('hex'),
321
+ bytes: png.length,
322
+ ...PNG_SIZE(png),
323
+ });
324
+ }
325
+ catch (err) {
326
+ failures.push({
327
+ route,
328
+ viewport,
329
+ scheme,
330
+ error: String(err?.message ?? err).slice(0, 300),
331
+ });
332
+ }
333
+ finally {
334
+ await context.close();
335
+ }
336
+ }
337
+ }
338
+ }
339
+ }
340
+ finally {
341
+ await browser.close();
342
+ }
343
+ return { items, failures };
344
+ }
345
+ async function waitForPort(ms) {
346
+ const deadline = Date.now() + ms;
347
+ while (Date.now() < deadline) {
348
+ const text = ['/proc/net/tcp', '/proc/net/tcp6']
349
+ .map((p) => (existsSync(p) ? readFileSync(p, 'utf8') : ''))
350
+ .join('\n');
351
+ const ports = listeningPorts(text);
352
+ if (ports.length > 0)
353
+ return ports[0] ?? null;
354
+ await new Promise((r) => setTimeout(r, 500));
355
+ }
356
+ return null;
357
+ }
358
+ async function main() {
359
+ const req = JSON.parse(process.argv[2] ?? '{}');
360
+ const out = '/out';
361
+ const write = (m) => writeFileSync(join(out, 'manifest.json'), `${JSON.stringify(m, null, 2)}\n`);
362
+ const empty = { items: [], failures: [], notes: [] };
363
+ let origin;
364
+ let basePath = '/';
365
+ let server = null;
366
+ if (req.mode === 'static') {
367
+ const root = realpathSync('/site');
368
+ const site = findStaticSite(root, buildStartedAt(root, req.sinceFile), req.rootHtml === true);
369
+ if (site === null) {
370
+ write({
371
+ status: 'no-site',
372
+ reason: 'the build wrote no index.html, and there is none at the repository root',
373
+ mode: 'static',
374
+ basePath: null,
375
+ ...empty,
376
+ });
377
+ return;
378
+ }
379
+ basePath = site.basePath;
380
+ const served = await serveStatic(site.dir, basePath);
381
+ server = served.server;
382
+ origin = `http://127.0.0.1:${served.port}`;
383
+ }
384
+ else {
385
+ const port = await waitForPort(req.waitForPortMs ?? 60_000);
386
+ if (port === null) {
387
+ write({
388
+ status: 'failed',
389
+ reason: `the serve script opened no port within ${Math.round((req.waitForPortMs ?? 60_000) / 1000)}s`,
390
+ mode: 'server',
391
+ basePath: null,
392
+ ...empty,
393
+ });
394
+ return;
395
+ }
396
+ origin = `http://127.0.0.1:${port}`;
397
+ }
398
+ try {
399
+ const { items, failures } = await shoot(origin, basePath, req, out);
400
+ const notes = sameAcrossSchemes(items).map((p) => `${p.route} (${p.viewport}) looks the same in light and dark: the page does not respond to prefers-color-scheme`);
401
+ write({
402
+ status: items.length > 0 ? 'captured' : 'failed',
403
+ reason: items.length > 0 ? null : (failures[0]?.error ?? 'no screenshot was taken'),
404
+ mode: req.mode,
405
+ basePath,
406
+ items,
407
+ failures,
408
+ notes,
409
+ });
410
+ }
411
+ finally {
412
+ server?.close();
413
+ }
414
+ }
415
+ if (process.argv[1]?.endsWith('captureEntry.js')) {
416
+ main().catch((err) => {
417
+ console.error(`captureEntry: ${String(err?.stack ?? err)}`);
418
+ process.exit(1);
419
+ });
420
+ }