withPage

tcmts.cli.test.browser.withPage · #akhxw76l · term · runs on the server

uses node:fs

Open url in a fresh headless Chrome, run fn against it, then close Chrome.

import { CHROME } from "#codebase/tcmts/cli/test/browser";
import type { Page } from "#codebase/tcmts/cli/test/browser";
import { spawn } from "node:child_process";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";

export async function withPage<R>(url: string, fn: (page: Page) => Promise<R>): Promise<R> {
  if (!CHROME) throw new Error("no Chrome installed");
  const profile = fs.mkdtempSync(path.join(os.tmpdir(), "tcm-chrome-"));
  const chrome = spawn(CHROME, [
    "--headless=new",
    "--disable-gpu",
    "--no-first-run",
    "--no-default-browser-check",
    `--user-data-dir=${profile}`,
    "--remote-debugging-port=0",
    "about:blank",
  ]);
  try {
    const ws = await new Promise<string>((resolve, reject) => {
      let err = "";
      chrome.stderr.setEncoding("utf8").on("data", (d: string) => {
        err += d;
        const m = /DevTools listening on (ws:\/\/\S+)/.exec(err);
        if (m) resolve(m[1]!);
      });
      chrome.on("exit", () => reject(new Error(`chrome exited: ${err}`)));
    });
    const sock = new WebSocket(ws);
    await new Promise((r, j) => ((sock.onopen = r), (sock.onerror = j)));
    let id = 0;
    const pending = new Map<number, (v: Record<string, unknown>) => void>();
    const errors: string[] = [];
    const logs: string[] = [];
    sock.onmessage = (e) => {
      const msg = JSON.parse(String(e.data)) as {
        id?: number;
        method?: string;
        result?: Record<string, unknown>;
        params?: Record<string, any>;
      };
      if (msg.id !== undefined) pending.get(msg.id)?.(msg.result ?? {});
      if (msg.method === "Runtime.consoleAPICalled") {
        const said = msg.params?.args
          .map((a: { value?: unknown; description?: string }) => a.value ?? a.description)
          .join(" ");
        logs.push(said);
        if (msg.params?.type === "error") errors.push(said);
      }
      if (msg.method === "Runtime.exceptionThrown")
        errors.push(
          msg.params?.exceptionDetails?.exception?.description ??
            msg.params?.exceptionDetails?.text,
        );
    };
    const send = (method: string, params: Record<string, unknown> = {}, sessionId?: string) =>
      new Promise<Record<string, unknown>>((resolve) => {
        const n = ++id;
        pending.set(n, resolve);
        sock.send(JSON.stringify({ id: n, method, params, ...(sessionId ? { sessionId } : {}) }));
      });
    // attach before loading, so the page's errors from the first moment are seen
    const { targetId } = (await send("Target.createTarget", { url: "about:blank" })) as {
      targetId: string;
    };
    const { sessionId } = (await send("Target.attachToTarget", { targetId, flatten: true })) as {
      sessionId: string;
    };
    await send("Runtime.enable", {}, sessionId);
    await send("Page.enable", {}, sessionId);
    await send("Page.navigate", { url }, sessionId);
    const evaluate = async <T>(expression: string): Promise<T> => {
      const r = (await send(
        "Runtime.evaluate",
        { expression, returnByValue: true, awaitPromise: true },
        sessionId,
      )) as { result?: { value?: T } };
      return r.result?.value as T;
    };
    const page: Page = {
      errors,
      logs,
      evaluate,
      async waitFor<T>(expression: string, until: (v: T) => boolean, timeoutMs = 15000) {
        let v = undefined as T;
        for (
          const end = Date.now() + timeoutMs;
          Date.now() < end;
          await new Promise((r) => setTimeout(r, 100))
        ) {
          v = await evaluate<T>(expression);
          if (until(v)) break;
        }
        return v;
      },
    };
    try {
      return await fn(page);
    } finally {
      sock.close();
    }
  } finally {
    // Chrome writes to its profile while shutting down: wait for it to exit before removing it
    const exited = new Promise((r) => chrome.once("exit", r));
    chrome.kill();
    await Promise.race([exited, new Promise((r) => setTimeout(r, 3000))]);
    // Chrome's helper processes can hold its pipes open after it exits; they mustn't keep this process alive
    for (const pipe of [chrome.stdin, chrome.stdout, chrome.stderr]) pipe?.destroy();
    fs.rmSync(profile, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
  }
}

uses

used by