storebound/cli/shots/browser.ts

254 lines
16 KiB
TypeScript

// Playwright is an optional peer dependency of storebound, resolved from the project (or the storebound folder), so a
// project that never renders screenshots never downloads a browser. The compiled desktop sidecar carries its own
// playwright-core (tools/build-sidecars.ts bundles it) and renders with the Chrome for Testing the desktop app manages
// (STOREBOUND_CHROME), so desktop users need neither playwright nor a browser in their project.
// Every browser storebound launches goes through ONE resolver (`launchCandidates` / `withCandidates`), which checks the
// exact executable each candidate would use before trying it:
// - headless renders and captures: STOREBOUND_CHROME, Playwright's headless shell, Playwright's full Chromium
// (channel "chromium"), then Google Chrome (channel "chrome").
// - visible / persistent windows (`console fill`): STOREBOUND_CHROME, Google Chrome (channel "chrome"), then
// Playwright's full Chromium (channel "chromium"). Never the headless shell: it has no window, and it drops session
// cookies (no --restore-last-session), so a sign-in would not survive.
// - "chrome" (console fill's opt-in copy of the owner's Chrome sign-in: only real Google Chrome can decrypt those
// cookies with its own Keychain key): Google Chrome only.
// `console fill` itself does not launch through Playwright: it takes the executable from `persistentExecutable` (same
// order) and starts Chrome on its own (cli/console/session.ts), so the window carries no automation switches.
// A candidate that is not installed is skipped; one that IS installed but fails to start stops the search with its own
// error (an earlier "Executable doesn't exist" never hides it). A persistent profile another browser still holds (its
// SingletonLock names a live process) is reported as such before anything is launched.
// Sources (checked October 2026): Playwright BrowserType.launch / launchPersistentContext `channel` ("chrome";
// "chromium" = the full Chromium build, new headless mode) https://playwright.dev/docs/browsers#chromium-new-headless-mode ;
// Google Chrome channel locations https://playwright.dev/docs/browsers#google-chrome--microsoft-edge
import { existsSync, readlinkSync } from 'node:fs';
import { hostname } from 'node:os';
import { join } from 'node:path';
import { compiled } from '../lib/runner';
import * as rt from '../lib/rt';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
/** A playwright module that resolved but does not have the API: named, so the error says which file it was. */
class PlaywrightShapeError extends Error {}
async function chromium(root: string): Promise<any> {
// why each place failed, for the error: a swallowed import error read as "not found" (a Mac run had it installed)
const tried: string[] = [];
for (const base of [root, rt.packageRoot()]) {
let p = '';
try {
p = rt.resolveFrom('playwright', base);
// only an installed copy (node_modules): outside a project Bun auto-installs the newest playwright into its global
// cache (~/.bun/install/cache), whose browser build was never downloaded ("headless shell: not installed" on
// Linux CI, where no Google Chrome hides it); storebound's own copy is next
if (!isInstalledPackage(p)) { tried.push(`${p}: not an installed copy (Bun's global cache)`); continue; }
// playwright's entry is CommonJS: depending on the runtime and version the ESM import puts chromium on the
// namespace or only under default (a project's playwright 1.57 under a global Bun install gave undefined)
const m = await rt.importPath(p);
const c = m?.chromium ?? m?.default?.chromium;
if (!c || typeof c.launch !== 'function') throw new PlaywrightShapeError(`playwright at ${p} has no chromium.launch (exports: ${Object.keys(m ?? {}).slice(0, 8).join(', ') || 'none'}${m?.default ? `; default: ${Object.keys(m.default).slice(0, 8).join(', ')}` : ''})`);
return c;
} catch (e) {
if (e instanceof PlaywrightShapeError) throw e;
tried.push(p ? `${p}: ${(e as Error).message.split('\n')[0]}` : `not resolvable from ${base}`);
}
}
// only reached in the compiled sidecar: the bundled playwright-core (not a runtime dependency of the npm package)
if (compiled()) return (await import('playwright-core')).chromium;
throw new Error(`playwright not found: in your project run \`bun add -d playwright\` (and \`bunx playwright install chromium\` if Google Chrome is not installed)\n ${tried.join('\n ')}`);
}
/** A resolved module path inside a node_modules folder (not one of Bun's auto-installs from its global cache). */
export const isInstalledPackage = (resolved: string): boolean => /[\\/]node_modules[\\/]/.test(resolved);
/** Playwright's chromium (same resolution as `launch`), for connectOverCDP. */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const chromiumFor = (root: string): Promise<any> => chromium(root);
/** What a launch is for: a headless render, a visible / persistent window, or real Google Chrome only. */
export type LaunchKind = 'headless' | 'visible' | 'chrome';
/** The executables on this machine (null: not there). */
export interface Installed {
/** STOREBOUND_CHROME when set and present. */
storeboundChrome: string | null;
/** Google Chrome where Playwright's channel "chrome" looks for it. */
chrome: string | null;
/** Playwright's full Chromium build (chromium.executablePath()) when present. */
chromium: string | null;
}
export interface Candidate {
label: string;
/** Merged into the launch options. */
opts: Record<string, unknown>;
}
/**
* Switches for a Chromium storebound starts itself (rt.spawn, not Playwright's launch, which already passes
* --no-sandbox unless chromiumSandbox is set): Chromium refuses to run as root on Linux without --no-sandbox
* ("Running as root without --no-sandbox is not supported", crbug.com/638180), and inside a Docker / Podman container
* its user-namespace sandbox is usually blocked by seccomp, so CI containers (the Forgejo runner runs jobs as root)
* need it too. Never on macOS or Windows, never for a normal user outside a container.
*/
export function sandboxArgs(platform: string = process.platform, uid: number | undefined = process.getuid?.(), inContainer: () => boolean = runsInContainer): string[] {
return platform === 'linux' && (uid === 0 || inContainer()) ? ['--no-sandbox'] : [];
}
const runsInContainer = (): boolean => existsSync('/.dockerenv') || existsSync('/run/.containerenv');
/** Where Playwright's channel "chrome" finds Google Chrome (stable). */
export function chromeChannelPaths(platform: string = process.platform, env: Record<string, string | undefined> = process.env): string[] {
if (platform === 'darwin') return ['/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'];
if (platform === 'win32') return [env.LOCALAPPDATA, env.PROGRAMFILES, env['PROGRAMFILES(X86)']].filter((d): d is string => !!d).map(d => join(d, 'Google', 'Chrome', 'Application', 'chrome.exe'));
return ['/opt/google/chrome/chrome'];
}
/** The executables present, as the resolver sees them. `pw`: Playwright's chromium. */
export function installedBrowsers(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pw: any, env: Record<string, string | undefined> = process.env, exists: (p: string) => boolean = existsSync, platform: string = process.platform,
): Installed {
let full: string | null = null;
try { const p = pw?.executablePath?.(); if (typeof p === 'string' && p && exists(p)) full = p; } catch { /* not resolvable */ }
return {
storeboundChrome: env.STOREBOUND_CHROME && exists(env.STOREBOUND_CHROME) ? env.STOREBOUND_CHROME : null,
chrome: chromeChannelPaths(platform, env).find(p => exists(p)) ?? null,
chromium: full,
};
}
/**
* The launch order for `kind` (see the header), only candidates whose executable is present. The headless shell has
* no public path: it is always tried first for headless renders (skipped quietly when it is not downloaded).
*/
export function launchCandidates(kind: LaunchKind, i: Installed): Candidate[] {
const out: Candidate[] = [];
if (i.storeboundChrome && kind !== 'chrome') out.push({ label: `STOREBOUND_CHROME (${i.storeboundChrome})`, opts: { executablePath: i.storeboundChrome } });
const chrome: Candidate | null = i.chrome ? { label: `Google Chrome (${i.chrome})`, opts: { channel: 'chrome' } } : null;
const full: Candidate | null = i.chromium ? { label: `Playwright Chromium (${i.chromium})`, opts: { channel: 'chromium' } } : null;
if (kind === 'headless') {
out.push({ label: 'Playwright headless shell', opts: {} });
if (full) out.push(full);
if (chrome) out.push(chrome);
} else if (kind === 'visible') {
if (chrome) out.push(chrome);
if (full) out.push(full);
} else if (chrome) out.push(chrome);
return out;
}
const LOOKED_FOR: Record<LaunchKind, string> = {
headless: 'STOREBOUND_CHROME, Playwright\'s headless shell or Chromium, Google Chrome',
visible: 'STOREBOUND_CHROME, Google Chrome, Playwright\'s full Chromium; never the headless shell (no window, no kept sign-in)',
chrome: 'Google Chrome',
};
/** Playwright's "no such browser" errors (build not downloaded, Chrome channel not installed). */
export const notInstalled = (e: unknown): boolean =>
/Executable doesn't exist|distribution '[^']+' is not found|Failed to launch .* because executable/i.test((e as Error | undefined)?.message ?? '');
/**
* Tries the candidates of `kind` in order with `start(opts)`. A missing browser moves on; an installed browser that
* fails stops with its own error (labelled). Nothing usable: an error naming what was looked for, and the fix.
*/
export async function withCandidates<T>(kind: LaunchKind, i: Installed, start: (opts: Record<string, unknown>) => Promise<T>, env: Record<string, string | undefined> = process.env): Promise<T> {
const skipped: string[] = [];
if (env.STOREBOUND_CHROME && !i.storeboundChrome && kind !== 'chrome') skipped.push(`STOREBOUND_CHROME ${env.STOREBOUND_CHROME}: no such file`);
for (const c of launchCandidates(kind, i)) {
try { return await start(c.opts); } catch (e) {
if (notInstalled(e)) { skipped.push(`${c.label}: not installed`); continue; }
throw new Error(`${c.label} did not start: ${(e as Error).message.split('\n')[0]}`, { cause: e });
}
}
throw new Error(`no usable browser (looked for ${LOOKED_FOR[kind]})${skipped.length ? `; ${skipped.join('; ')}` : ''}. `
+ (kind === 'chrome' ? 'Install Google Chrome.' : 'Install Google Chrome, or run `bunx playwright install chromium` in your project; `storebound doctor` lists what is missing.'));
}
/**
* The live process (pid) that still holds a Chromium profile: its SingletonLock ("<host>-<pid>") names a running
* process on this machine. Else null (also on Windows, which has no symlink lock).
*/
export function profileHolder(userDataDir: string, alive: (pid: number) => boolean = pidAlive, host: string = hostname()): number | null {
let target: string;
try { target = readlinkSync(join(userDataDir, 'SingletonLock')); } catch { return null; }
const m = target.match(/^(.*)-(\d+)$/);
if (!m || m[1] !== host) return null;
const pid = Number(m[2]);
return alive(pid) ? pid : null;
}
function pidAlive(pid: number): boolean {
try { process.kill(pid, 0); return true; } catch (e) { return (e as NodeJS.ErrnoException).code === 'EPERM'; }
}
/** Test seam: Playwright's chromium and the installed browsers (default: resolved from `root` and this machine). */
export interface LaunchDeps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
pw?: any;
installed?: Installed;
}
/**
* The executable `console fill` starts itself (no Playwright launch, so no automation switches): kind "visible" =
* STOREBOUND_CHROME, Google Chrome, then Playwright's full Chromium; kind "chrome" = Google Chrome only. Never the
* headless shell. Nothing installed: the same error as withCandidates.
*/
export function persistentExecutable(kind: 'visible' | 'chrome', i: Installed, env: Record<string, string | undefined> = process.env, o: { headless?: boolean; platform?: string } = {}): { label: string; path: string } {
const list: { label: string; path: string }[] = [];
if (kind === 'visible' && i.storeboundChrome) list.push({ label: `STOREBOUND_CHROME (${i.storeboundChrome})`, path: i.storeboundChrome });
// headless on macOS (tests): Playwright's Chromium first. LaunchServices counts a headless run of Google Chrome.app as
// the running "Google Chrome", so while it runs the Dock's Chrome only activates it and the owner's Chrome stays
// closed (October 2026). Chromium / Chrome for Testing has a bundle id of its own.
const chromiumFirst = kind === 'visible' && o.headless === true && (o.platform ?? process.platform) === 'darwin';
if (chromiumFirst && i.chromium) list.push({ label: `Playwright Chromium (${i.chromium})`, path: i.chromium });
if (i.chrome) list.push({ label: `Google Chrome (${i.chrome})`, path: i.chrome });
if (kind === 'visible' && !chromiumFirst && i.chromium) list.push({ label: `Playwright Chromium (${i.chromium})`, path: i.chromium });
if (list[0]) return list[0];
const skipped = env.STOREBOUND_CHROME && !i.storeboundChrome && kind !== 'chrome' ? `; STOREBOUND_CHROME ${env.STOREBOUND_CHROME}: no such file` : '';
throw new Error(`no usable browser (looked for ${LOOKED_FOR[kind]})${skipped}. `
+ (kind === 'chrome' ? 'Install Google Chrome.' : 'Install Google Chrome, or run `bunx playwright install chromium` in your project; `storebound doctor` lists what is missing.'));
}
/** A browser for renders and captures (headless unless `headless: false`). */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function launch(root: string, opts: Record<string, unknown>, deps: LaunchDeps = {}): Promise<any> {
const c = deps.pw ?? await chromium(root);
const kind: LaunchKind = opts.headless === false ? 'visible' : 'headless';
return withCandidates(kind, deps.installed ?? installedBrowsers(c), o => c.launch({ ...opts, ...o }));
}
/** How long a launched (throwaway profile) browser gets to close gracefully before it is killed. */
export const CLOSE_GRACE_MS = 3000;
/**
* Ends a browser from `launch` once the work is done: a graceful close capped at `ms`, then SIGKILL of its process.
* Google Chrome often hangs 10-30 s in its own shutdown after http pages (tests/helpers/browser.ts); a throwaway
* profile has nothing to flush, so waiting for it only costs time. The pid is read over CDP before the close. Never
* throws.
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function closeBrowser(browser: any, ms = CLOSE_GRACE_MS): Promise<void> {
let pid: number | undefined;
try {
const s = await browser.newBrowserCDPSession();
const info = await s.send('SystemInfo.getProcessInfo') as { processInfo: { type: string; id: number }[] };
pid = info.processInfo.find(p => p.type === 'browser')?.id;
} catch { /* not Chromium, or already gone */ }
let t: ReturnType<typeof setTimeout> | undefined;
const closed = await Promise.race([
Promise.resolve().then(() => browser.close()).then(() => true, () => true),
new Promise<boolean>(r => { t = setTimeout(() => r(false), ms); }),
]);
clearTimeout(t);
if (!closed && pid) try { process.kill(pid, 'SIGKILL'); } catch { /* exited meanwhile */ }
}
/**
* A persistent browser profile (cookies of a console sign-in the owner did by hand stay there between runs; storebound
* never reads them). Used by `storebound console fill`: always a real Chrome / Chromium (kind "visible", also when a
* test asks for headless), or Google Chrome only (kind "chrome", the copy of the owner's sign-in).
*/
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export async function launchPersistent(root: string, userDataDir: string, opts: Record<string, unknown>, deps: LaunchDeps & { kind?: 'visible' | 'chrome' } = {}): Promise<any> {
const holder = profileHolder(userDataDir);
if (holder) throw new Error(`the browser profile ${userDataDir} is still open in another browser (process ${holder}), probably an earlier storebound run that waits for its window to close. Close that window (or end that run) and start again`);
const c = deps.pw ?? await chromium(root);
return withCandidates(deps.kind ?? 'visible', deps.installed ?? installedBrowsers(c), o => c.launchPersistentContext(userDataDir, { ...opts, ...o }));
}