shot

tcmts.cli.shot.shot · #cirl7bzg · function · runs on the server

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

used by