240 lines
15 KiB
TypeScript
240 lines
15 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
|
|
async function chromium(root: string): Promise<any> {
|
|
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;
|
|
return (await rt.importPath(p)).chromium;
|
|
} catch { /* 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<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 }));
|
|
}
|