// playwright driver — launches Playwright's Google Chrome for Testing via the // `playwright` package. availability depends on whether the package is // resolvable from the current working directory OR from the CLI's own // install tree (repo-local install is typical; the latter covers a global // CLI invoked from a directory with no node_modules). // // the browser runs in a *detached* child process — this CLI re-invoked as // its own `__internal playwright-host` (see cli/internal-child.ts) — rather // than in this CLI process. that is load-bearing: `rnx open` returns the // moment the sim connects, and playwright tears its browser down when the // launching process exits. an in-process launch would mean the sim vanished // the instant `open` finished. the detached host keeps the test browser alive // until the sim's page closes (e.g. via `rnx close`). import { spawn } from 'child_process' import { closeSync, mkdtempSync, openSync, readFileSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' import { playwrightHostLogName } from '../../src/playwright-host-log' import { ensureProfile, playwrightProfileUserDataDir } from '../../src/profiles' import { RNX_INTERNAL_CHILDREN, RNX_INTERNAL_COMMAND } from '../internal-child' import { getProcessComm } from '../parent-pid' import { rnxSelfInvocation } from '../self-invocation' import { ensurePlaywrightBrowser, resolvePlaywright } from './playwright-provisioning' import type { Driver, DriverAvailability, DriverLaunchOptions, DriverLaunchResult, } from './types' function probe(): DriverAvailability { const resolved = resolvePlaywright() if (!resolved) { return { available: false, reason: 'playwright not installed in the current workspace', } } return { available: true, reason: null, detail: `resolved via ${resolved.spec}@${resolved.version}`, } } // when a headed launch fails on a Linux box with no display, chrome's own // error ("Missing X server", "cannot open display") tells the user *what* // is wrong but not *what to do*. surface the actionable flag. function headlessHintForLinuxFailure( opts: DriverLaunchOptions, detail: string, ): string | null { if (process.platform !== 'linux') return null if (opts.headless) return null if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) return null if (!/missing x server|cannot open display|x server|x11/i.test(detail)) { return null } return ' hint: linux without DISPLAY/WAYLAND_DISPLAY — rerun without --headed (or add --headless) to run playwright headless.' } // resolve the Chrome user-data dir for a profiled launch. a non-empty // return means the host should use a persistent context; '' means a plain // (in-memory) browser launch. function resolveUserDataDir(opts: DriverLaunchOptions): string { if (opts.profileId) { return playwrightProfileUserDataDir(ensureProfile(opts.profileId).id) } if (opts.ephemeralProfile) { return mkdtempSync(join(tmpdir(), 'rnx-playwright-profile-')) } return '' } async function launch(opts: DriverLaunchOptions): Promise { const resolved = resolvePlaywright() if (!resolved) { return { launched: false, message: 'playwright not installed — run `bun add -D playwright` first', } } if (!opts.url) { return { launched: false, message: 'playwright driver requires a target url' } } const ownerPid = opts.ownerPid if (ownerPid === undefined || !Number.isInteger(ownerPid) || ownerPid <= 1) { return { launched: false, message: 'playwright driver requires a valid owning session process', } } // keep browser discovery, installation, and the detached host on one exact // cache contract even if another caller mutates process.env during launch. const playwrightEnv = { ...process.env } const provisioning = ensurePlaywrightBrowser(resolved, playwrightEnv) if (!provisioning.ok) { return { launched: false, message: `playwright browser provisioning failed: ${provisioning.message}`, } } const errLog = join(tmpdir(), playwrightHostLogName(Date.now(), process.pid)) const connectAckFile = `${errLog}.connected` const errFd = openSync(errLog, 'a') try { // spawn the browser host detached so Chrome outlives `rnx // open`. stdout is dropped; stderr is captured to errLog so an early // crash (bad profile dir, missing browser binary) produces a real // message instead of a silent "timed out waiting for sim". // resolve the owning session's pid *before* we detach — after the spawn, // the host's own ancestry is just launchd. the host polls this pid and // tears Chrome down when the session that launched it exits. const self = rnxSelfInvocation() const child = spawn( self.executable, [...self.prefixArgs, RNX_INTERNAL_COMMAND, RNX_INTERNAL_CHILDREN.playwrightHost], { detached: true, stdio: ['ignore', 'ignore', errFd], env: { ...playwrightEnv, SOOTSIM_PW_MODULE: resolved.modulePath, SOOTSIM_PW_URL: opts.url, SOOTSIM_PW_BRIDGE_PORT: opts.bridgePort ? String(opts.bridgePort) : '', SOOTSIM_PW_HEADLESS: (opts.headless ?? true) ? '1' : '0', SOOTSIM_PW_USERDATADIR: resolveUserDataDir(opts), SOOTSIM_PW_CDP_PORT: opts.cdpPort ? String(opts.cdpPort) : '', // when the caller passed no viewport, leave the inherited // SOOTSIM_PW_VIEWPORT (from playwrightEnv) alone instead of // clobbering it with '' — the driver reads it as its own default. ...(opts.viewport ? { SOOTSIM_PW_VIEWPORT: `${opts.viewport.width}x${opts.viewport.height}` } : {}), SOOTSIM_PW_CONNECTED_ACK_FILE: connectAckFile, SOOTSIM_PW_CONNECT_TIMEOUT_MS: String(opts.connectTimeoutMs ?? 120_000), SOOTSIM_PW_OWNER_PID: String(ownerPid), }, }, ) child.unref() // give the host a short window to fail fast (resolve errors, browser // binary missing). if it is still alive after that, the sim is on its // way and the caller's own `waitForSimMatch` takes over. const earlyExit = await new Promise((resolve) => { const timer = setTimeout(() => resolve(null), 4000) child.once('exit', (code) => { clearTimeout(timer) resolve(code ?? 0) }) }) if (earlyExit !== null) { // ANY exit inside the 4s startup window is a failure: a healthy host // stays alive until the sim disconnects (much longer than 4s). a code-0 // early exit can happen when the inline script's top-level catch fires // before the launch even reaches a thrown error path, or when chromium // is killed externally — both still mean "no sim is coming". emit the // host's FULL stderr — its first line is the error message, and the // prior `slice(-3)` kept only trailing stack frames, dropping the // message and making CI failures undiagnosable. leave errLog on disk // (path included) so it can be inspected after the fact. const detail = readFileSync(errLog, 'utf8').trim() const hint = headlessHintForLinuxFailure(opts, detail) return { launched: false, message: `playwright host exited early (code ${earlyExit}) — host log ${errLog}${ detail ? `:\n${detail}` : '' }${hint ? `\n${hint}` : ''}`, } } // name the lifetime tie: the host watchdog closes chrome ~5s after the // owning session pid exits, which surprises one-shot/detached shells // (their topmost non-init ancestor is the shell itself, so the sim dies // the moment the command finishes). const ownerComm = getProcessComm(ownerPid) const ownerNote = ` — browser closes when pid ${ownerPid}${ownerComm ? ` (${ownerComm})` : ''} exits` return { launched: true, message: `playwright chrome launched → ${opts.url}${ownerNote}`, pid: child.pid, target: resolved.spec, attachUrl: opts.url, connectAckFile, diagnosticLogPath: errLog, } } catch (err) { return { launched: false, message: `playwright launch failed: ${err instanceof Error ? err.message : String(err)}`, } } finally { // parent's copy of the fd; the detached host keeps its own inherited // dup, so closing here does not cut off the child's stderr. closeSync(errFd) } } export const playwrightDriver: Driver = { id: 'playwright', name: 'playwright', description: 'programmatic chromium via the playwright package — headless default', kind: 'automation', availability: probe, launch, }