withPage
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
- tcmts.cli.test.browser.pageText
- tcmts.cli.test.e2e.contributions.testWebUi
- tcmts.cli.test.e2e.examples.testNotesExample
- tcmts.cli.test.e2e.examples.testRemindersExample
- tcmts.cli.test.e2e.examples.testUserinputExample
- tcmts.cli.test.e2e.hub.testHubSignInAndStars
- tcmts.cli.test.e2e.prod.testAppUnderABase
- tcmts.cli.test.e2e.prod.testDeployedChunksArriveAsDifferences
- tcmts.cli.test.e2e.prod.testPagesShellsAreKept
- tcmts.cli.test.e2e.prod.testServerComponentsKeptAndRenderedAhead
- tcmts.cli.test.e2e.prod.testStaticSitesRenderEveryPage
- tcmts.cli.test.e2e.react.testComponentsAreCompiled
- tcmts.cli.test.e2e.react.testLazyChunksLoadWithThePage
- tcmts.cli.test.e2e.react.testPackageComponentsRenderInTheBrowser
- tcmts.cli.test.e2e.react.testPagesKeepAndRenderAhead
- tcmts.cli.test.e2e.react.testReactPlacementStreamingHydration
- tcmts.cli.test.e2e.react.testServerComponentsAreDebuggable
- tcmts.cli.test.e2e.react.testServerFunctionsAsProps