shot
uses node:path
tcm shot: open a page in headless Chromium at each width, wait for it to settle (loaded, the network quiet, its
fonts in, then wait ms more), and save a full-page PNG of it per width into out (default .tcm/shots/ in a
codebase, else the temp directory's tcm-shots/). Prints the page's title, each file's path, and what went wrong
on the page: console errors, uncaught errors, and requests that failed or answered 400 or more.
import { Codebase } from "#codebase/tcmts/cli/codebase"; import { launchChromium, shotTarget } from "#codebase/tcmts/cli/shot"; import { dim, red } from "#codebase/tcmts/cli/style"; import { UsageError } from "#codebase/tcmts/cli/ui"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; export async function shot(o: { cwd: string; url: string; widths: number[]; out: string | undefined; wait: number; }): Promise<number> { let root: string | undefined; try { root = Codebase.findRoot(o.cwd); } catch { root = undefined; } const { url, slug } = shotTarget(o.url, root); const dir = o.out ? path.resolve(o.cwd, o.out) : root ? path.join(root, ".tcm", "shots") : path.join(os.tmpdir(), "tcm-shots"); fs.mkdirSync(dir, { recursive: true }); // (screenshots aren't for committing) if (!o.out && root && !fs.existsSync(path.join(dir, ".gitignore"))) fs.writeFileSync(path.join(dir, ".gitignore"), "*\n"); const errors: string[] = []; const said = (e: string) => { if (!errors.includes(e)) errors.push(e); }; const browser = await launchChromium(); let title = ""; const files: string[] = []; try { for (const width of o.widths) { const context = await browser.newContext({ viewport: { width, height: width < 700 ? 844 : 800 }, }); try { const page = await context.newPage(); page.on("console", (m: { type(): string; text(): string }) => { // (Chrome's own line for a failed request: the response says it) if (m.type() === "error" && !m.text().startsWith("Failed to load resource")) said(`console: ${m.text()}`); }); page.on("pageerror", (e: Error) => said(`uncaught: ${e.message}`)); page.on("requestfailed", (r: { url(): string; failure(): { errorText: string } | null }) => said(`failed: ${r.url()} ${r.failure()?.errorText ?? ""}`.trim()), ); page.on("response", (r: { status(): number; url(): string }) => { if (r.status() >= 400) said(`${r.status()}: ${r.url()}`); }); try { await page.goto(url, { waitUntil: "load", timeout: 30_000 }); } catch (e) { throw new UsageError(`couldn't open ${url}: ${(e as Error).message.split("\n")[0]}`); } await page.waitForLoadState("networkidle", { timeout: 5_000 }).catch(() => {}); // (a string: the page runs it, not tcm) await page.evaluate("document.fonts.ready.then(() => 0)").catch(() => {}); if (o.wait) await page.waitForTimeout(o.wait); title ||= await page.title(); const file = path.join(dir, `${slug}-${width}.png`); await page.screenshot({ path: file, fullPage: true }); files.push(file); } finally { await context.close(); } } } finally { await browser.close(); } console.log(`${title ? `title ${title}` : dim("no title")} ${dim(url)}`); for (const f of files) console.log(f); if (errors.length) { console.log(red(`${errors.length} error(s) on the page:`)); for (const e of errors) console.log(` ${e}`); } else console.log(dim("no errors on the page")); console.log(dim(`to click through it: tcm browser open ${o.url}, then tcm browser snapshot -i`)); return 0; }
uses
- tcmts.cli.codebase.Codebase
- tcmts.cli.shot.launchChromium
- tcmts.cli.shot.shotTarget
- tcmts.cli.style.dim
- tcmts.cli.style.red
- tcmts.cli.ui.UsageError