// 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 { for (const base of [root, rt.packageRoot()]) { try { const 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)) 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; /* next */ } } // 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)'); } /** 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 => 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; } /** * 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 = 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 = 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 = { 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(kind: LaunchKind, i: Installed, start: (opts: Record) => Promise, env: Record = process.env): Promise { 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 ("-") 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 = 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, deps: LaunchDeps = {}): Promise { 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 { 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 | undefined; const closed = await Promise.race([ Promise.resolve().then(() => browser.close()).then(() => true, () => true), new Promise(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, deps: LaunchDeps & { kind?: 'visible' | 'chrome' } = {}): Promise { 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 })); }