import { spawnSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { RNX_INTERNAL_CHILDREN, RNX_INTERNAL_COMMAND } from '../internal-child' import { rnxSelfInvocation } from '../self-invocation' export interface ResolvedPlaywright { // package name that resolved (for human-facing messaging) spec: string // absolute package entry loaded by the detached browser host modulePath: string // absolute package-owned CLI entry used to install this package's browser cliPath: string // package version shown in availability and provisioning failures version: string } export type PlaywrightBrowserProvisioningResult = | { ok: true executablePath: string installed: boolean } | { ok: false message: string } function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } export function resolvePlaywright(): ResolvedPlaywright | null { // resolve from several roots, not just cwd. a user can invoke a global CLI // from an arbitrary directory while Playwright is installed beside the CLI. const roots = [`${process.cwd()}/`] if (process.argv[1]) roots.push(process.argv[1]) for (const root of roots) { try { const requireFromRoot = createRequire(root) for (const spec of ['playwright', 'playwright-chromium', 'playwright-core']) { try { const modulePath = requireFromRoot.resolve(spec) const packageJsonPath = requireFromRoot.resolve(`${spec}/package.json`) const parsed: unknown = JSON.parse(readFileSync(packageJsonPath, 'utf8')) if (!isRecord(parsed) || typeof parsed.version !== 'string') continue const bin = parsed.bin let binPath: string | null = null if (typeof bin === 'string') { binPath = bin } else if (isRecord(bin)) { const named = bin.playwright ?? bin['playwright-core'] const first = Object.values(bin).find((value) => typeof value === 'string') binPath = typeof named === 'string' ? named : typeof first === 'string' ? first : null } if (!binPath) continue const cliPath = join(dirname(packageJsonPath), binPath) if (!existsSync(cliPath)) continue return { spec, modulePath, cliPath, version: parsed.version, } } catch {} } } catch {} } return null } type BrowserProbeResult = | { ok: true; executablePath: string; exists: boolean } | { ok: false; message: string } function probeBrowser( resolved: ResolvedPlaywright, env: NodeJS.ProcessEnv, ): BrowserProbeResult { const self = rnxSelfInvocation() const probe = spawnSync( self.executable, [...self.prefixArgs, RNX_INTERNAL_COMMAND, RNX_INTERNAL_CHILDREN.playwrightProbe], { env: { ...env, SOOTSIM_PW_MODULE: resolved.modulePath, }, encoding: 'utf8', timeout: 30_000, }, ) if (probe.status !== 0) { const detail = (probe.stderr || probe.error?.message || 'browser probe failed').trim() return { ok: false, message: `could not inspect ${resolved.spec}@${resolved.version} chromium: ${detail}`, } } try { const parsed: unknown = JSON.parse(probe.stdout) if ( !isRecord(parsed) || typeof parsed.executablePath !== 'string' || typeof parsed.exists !== 'boolean' ) { throw new Error('probe returned an invalid result') } return { ok: true, executablePath: parsed.executablePath, exists: parsed.exists && existsSync(parsed.executablePath), } } catch (error) { return { ok: false, message: `could not inspect ${resolved.spec}@${resolved.version} chromium: ${ error instanceof Error ? error.message : String(error) }`, } } } export function ensurePlaywrightBrowser( resolved: ResolvedPlaywright, env: NodeJS.ProcessEnv = process.env, ): PlaywrightBrowserProvisioningResult { const before = probeBrowser(resolved, env) if (!before.ok) return before if (before.exists) { return { ok: true, executablePath: before.executablePath, installed: false } } // the resolved package is the authority for both the required executable // path and the installer. never scan a shared cache or select a neighboring // revision: Playwright's own CLI installs the exact revision its API named. // that CLI is a javascript file, so this CLI runs it as itself. const installer = rnxSelfInvocation() const install = spawnSync( installer.executable, [ ...installer.prefixArgs, RNX_INTERNAL_COMMAND, RNX_INTERNAL_CHILDREN.playwrightInstall, resolved.cliPath, 'install', 'chromium', ], { env, encoding: 'utf8', timeout: 10 * 60_000, }, ) if (install.status !== 0) { const rawDetail = ( install.stderr || install.stdout || install.error?.message || 'browser install failed' ).trim() const detail = rawDetail.length <= 4_000 ? rawDetail : rawDetail.slice(-4_000) return { ok: false, message: `${resolved.spec}@${resolved.version} requires chromium at ` + `${before.executablePath}, but its browser install failed${detail ? `:\n${detail}` : ''}`, } } const after = probeBrowser(resolved, env) if (!after.ok) return after if (!after.exists) { return { ok: false, message: `${resolved.spec}@${resolved.version} browser install completed, but its required ` + `chromium executable is still missing at ${after.executablePath}`, } } return { ok: true, executablePath: after.executablePath, installed: true } }