import type { ChildProcess } from 'child_process'; import { existsSync, readdirSync } from 'fs'; /** * Default startup timeout for Chrome's CDP listener. Overridable via the * `SLICC_CDP_LAUNCH_TIMEOUT_MS` environment variable so cold/contended CI * runners can give Chrome a longer cold-start window without code changes. */ export declare const DEFAULT_CDP_LAUNCH_TIMEOUT_MS = 15000; export declare function getDefaultCdpLaunchTimeoutMs(env?: NodeJS.ProcessEnv): number; export declare const CLI_PROFILE_NAMES: readonly ['leader', 'follower', 'extension']; export type CliProfileName = (typeof CLI_PROFILE_NAMES)[number]; export interface ChromeLaunchProfile { id: CliProfileName | null; displayName: string; userDataDir: string; extensionPath: string | null; } interface FindChromeExecutableOptions { env?: NodeJS.ProcessEnv; platform?: NodeJS.Platform; homeDir?: string; existsSyncImpl?: typeof existsSync; readdirSyncImpl?: typeof readdirSync; executablePreference?: 'chrome-for-testing' | 'installed'; } export declare function isCliProfileName(value: string | null | undefined): value is CliProfileName; export declare function resolveQaProfilesRoot(projectRoot: string): string; export declare function resolveProfilesDir(platform?: NodeJS.Platform, homeDir?: string, env?: NodeJS.ProcessEnv): string; export declare function resolveDefaultChromeUserDataDir(profilesDir?: string, servePort?: number, env?: NodeJS.ProcessEnv): string; /** * Builds the ordered list of legacy candidate paths to check during migration. * Checks $TMPDIR first (the macOS per-user temp dir used by terminal sessions), * then /tmp (the fallback used by GUI apps that don't inherit TMPDIR). * Also includes the previous ~/.slicc/profiles location so existing installs * are migrated to the platform-appropriate directory on first run. */ export declare function legacyChromeCandidates(profileDirName: string, env?: NodeJS.ProcessEnv): string[]; /** * One-time migration: if `newDir` doesn't exist yet, copies the first matching * candidate (legacy profile from $TMPDIR or /tmp) to the stable new location. * Non-destructive — the old profile is left in place. * * Runs at most once ever, gated by a marker file in the profiles directory * (the parent of `newDir`). Because the marker lives beside the profile rather * than inside it, deleting the profile for a clean state does NOT re-trigger * migration — only deleting the whole profiles directory re-arms it. */ export declare function migrateLegacyDefaultChromeProfile(newDir: string, candidates: string[]): Promise; export declare function resolveChromeLaunchProfile(options: { projectRoot: string; tmpDir?: string | null; profile?: string | null; servePort?: number; }): ChromeLaunchProfile; export declare function buildChromeLaunchArgs(options: { cdpPort: number; launchUrl: string; profile: ChromeLaunchProfile; hosted?: boolean; }): string[]; /** * Walk up from a Chrome executable path * (`…/Foo.app/Contents/MacOS/Foo`) to its enclosing `.app` bundle so we * can hand it to `/usr/bin/open -a`. Returns `null` on non-darwin * platforms or bare-binary paths so the caller falls back to a direct * exec (Linux/Windows have no LaunchServices equivalent). */ export declare function resolveChromeAppBundle(executablePath: string, platform?: NodeJS.Platform): string | null; export interface ChromeSpawnPlan { command: string; args: string[]; /** * `true` when the spawn is routed through `/usr/bin/open` so * LaunchServices owns the new Chrome process. The caller should rely * on `DevToolsActivePort` for CDP port discovery in this mode because * `open`'s stderr never carries Chrome's `DevTools listening on …` * banner. */ usesLaunchServices: boolean; } /** * Decide how to spawn Chrome so macOS TCC attributes camera/microphone * requests to Chrome itself rather than to whatever terminal launched * `node`. On darwin, when we can resolve an enclosing `.app` bundle for * the Chrome executable, route the spawn through * `/usr/bin/open -n -a -W --args …` so LaunchServices becomes * Chrome's parent and TCC responsible process. Without this hop, * `getUserMedia()` calls in Google Meet, Zoom, etc. hang forever on * machines where the terminal app has never been granted camera/mic * access (or has no `NS{Camera,Microphone}UsageDescription`). * * On Linux / Windows, fall back to a direct exec — neither platform has * a LaunchServices equivalent, and they don't suffer the same TCC * inheritance problem. */ export declare function planChromeSpawn(options: { executablePath: string; chromeArgs: string[]; platform?: NodeJS.Platform; }): ChromeSpawnPlan; export declare function findChromeExecutable(options?: FindChromeExecutableOptions): string | null; export declare function ensureQaProfileScaffold(projectRoot: string): Promise; /** * Parse the CDP port from a Chrome stderr line. * Chrome prints `DevTools listening on ws://HOST:PORT/devtools/browser/ID` * to stderr when it starts. Returns the port number, or null if the line * doesn't match. */ export declare function parseCdpPortFromStderr(line: string): number | null; /** * Watch a Chrome child process's stderr for the `DevTools listening on` line * and resolve with the actual CDP port. Rejects after `timeoutMs` if the line * never appears (e.g. Chrome failed to start). * * Buffers across chunk boundaries: stderr data events split on arbitrary * byte boundaries (not on newlines), so the original "split each chunk by * \n and regex each line" approach silently dropped the DevTools line * whenever it spanned two chunks. We accumulate a rolling buffer and only * parse complete lines (everything before the last `\n`); the trailing * partial line is carried forward to the next chunk. */ export declare function waitForCdpPortFromStderr(child: ChildProcess, timeoutMs?: number): Promise; /** * Delete a stale `/DevToolsActivePort` left behind by a * previous Chrome run before we spawn a new one. Otherwise * `waitForCdpPortFromActivePortFile` can win the race instantly with the * old port from a crashed / SIGKILL'd previous launch — Chrome only * writes the file when its listener comes up, and never proactively * clears it on shutdown. The file lives inside a profile directory that * is reused across runs (both the dev `/tmp/browser-coding-agent-chrome` * profile and the persistent `.qa/chrome/` QA profiles), so the * stale-port window is real. * * ENOENT is fine (no previous run); other errors are swallowed too * because a failure to unlink shouldn't block a launch — the worst case * is the pre-existing stale-port behavior, which is what we want to * avoid but is not worth crashing over. */ export declare function clearStaleDevToolsActivePort(userDataDir: string): Promise; /** * Clear Chrome's "did not exit cleanly" flags so the next launch does NOT * restore the previous session's tabs (or pop the crash-restore bubble). * * `npm run dev` is almost always stopped with Ctrl-C, which terminates the * launched Chrome without a graceful shutdown. Chrome then records * `profile.exit_type: "Crashed"` in `Default/Preferences` and, on the next * launch against the same persistent profile, reopens the prior session's * tabs. With the standalone single-client CDP proxy that is actively * harmful: a *restored* duplicate webapp tab and the freshly-launched tab * both dial `ws:///cdp`, and because the proxy keeps only one client * they evict each other in an endless ~5s reconnect war (each evicted page * re-dials on the next leader-target refresh). * * Rewriting `exit_type` to `"Normal"` (and `exited_cleanly` to `true`) — the * same trick ChromeDriver uses — before spawn makes Chrome believe the last * session ended cleanly, so it starts fresh with a single tab. * * Best-effort and idempotent: a missing Preferences file (first run) is left * absent — there is nothing to restore — and an unparseable one is left as-is * for Chrome to regenerate. Never throws; a failure here must not block a * launch. */ export declare function clearChromeRestoreState(userDataDir: string): Promise; /** * Sites whose tabs Chrome must never discard or freeze: the hosted leader * UI (www + apex) and local bridge/dev origins. Entries are Chrome * "tab discarding exception" site patterns (the same strings * chrome://settings/performance stores). */ export declare const TAB_LIFECYCLE_EXEMPT_SITES: string[]; /** * Seed the profile's `Default/Preferences` with tab-lifecycle opt-outs before * every launch. The `--disable-features` list in {@link buildChromeLaunchArgs} * is the primary defense against Chrome freezing/discarding the backgrounded * leader tab, but feature names churn between Chrome versions (Chrome 151 * renamed the whole pipeline, silently reviving the freeze); these prefs are * the version-stable belt: * * - `tab_freezing_enabled: false` — master pref gating tab freezing; * - `performance_tuning.high_efficiency_mode.state: 0` — Memory Saver off; * - `performance_tuning.tab_discarding.exceptions` — per-site exemption list * covering the leader origins, honored by both discard and (via Chrome's * FreezingFollowsDiscardOptOut) freeze policy. * * Merges into existing prefs (the profile persists logins across runs) and * never throws — a failure here must not block a launch. */ export declare function seedChromeProfilePreferences(userDataDir: string): Promise; /** * Remove Chrome's session-restore snapshot so a relaunch opens ONLY the * command-line tab instead of also reopening the previous window's tabs. * * The dev launcher passes the UI URL on the command line, but Chrome ALSO * restores `Default/Sessions/` from the prior run — so every `npm run dev` was * adding a tab. `exit_type` only governs the *crash* bubble, not this; the fix * is to drop the snapshot. Run before every spawn (like * {@link clearStaleDevToolsActivePort}). Cookies / localStorage / IndexedDB * live elsewhere and are untouched. Best-effort. * * Modern Chrome keeps the whole snapshot under `Default/Sessions/`; `Last * Session` / `Last Tabs` are belt-and-suspenders for older Chromium builds that * still wrote those two as top-level files under `Default/`. */ export declare function clearChromeSessionRestore(userDataDir: string): Promise; /** Injectable seams for {@link terminateExistingProfileChrome} (tests). */ export interface ProfileChromeTerminationDeps { readlinkImpl?: (path: string) => Promise; isAlive?: (pid: number) => boolean; kill?: (pid: number, signal: NodeJS.Signals) => void; sleep?: (ms: number) => Promise; } /** * Terminate a Chrome instance still holding this `user-data-dir`, then clear * the stale Singleton lock files, so a fresh launch isn't refused. * * Chrome is single-instance per profile: a second `open -n` against a locked * profile either exits non-zero ("Chrome exited … before reporting CDP port") * or silently adds a tab to the running instance. On macOS the launcher starts * Chrome via LaunchServices, so it isn't our child and Ctrl-C never reaps it — * a leftover Chrome lingers across `npm run dev` runs. Chrome records the * owning PID in the `SingletonLock` symlink (`-`); if that process * is alive we stop it (SIGTERM, then SIGKILL), then unlink the Singleton lock * files. Best-effort and idempotent; a missing lock is a no-op. */ export declare function terminateExistingProfileChrome(userDataDir: string, deps?: ProfileChromeTerminationDeps): Promise; export interface ProbeCdpAliveOptions { /** Per-probe HTTP timeout in milliseconds. Default 500 ms. */ timeoutMs?: number; /** * When set, additionally require the returned * `webSocketDebuggerUrl`'s pathname to equal this value. Used by the * `DevToolsActivePort` poller to bind the probe to the *specific* CDP * endpoint Chrome wrote into the file (line 2 of the file) so a port * later reused by an unrelated Chrome/CDP instance can't be mistaken * for ours. */ expectedWebSocketPath?: string | null; } /** * Single-shot HTTP probe of Chrome's `/json/version` endpoint. Resolves * `true` only when the port answers with a 2xx response whose body is * valid JSON with a non-empty `webSocketDebuggerUrl` (the CDP fingerprint * — won't false-positive on some other HTTP service squatting on the * port). When `expectedWebSocketPath` is supplied, the probe additionally * requires the URL's pathname to match, so the launcher can't attach to * an unrelated live CDP server that just happens to be on the same port * a stale `DevToolsActivePort` file pointed at. * * Contract: **every** failure mode collapses to `Promise`. Out- * of-range ports, synchronous `httpRequest` throws (`ERR_SOCKET_BAD_PORT`, * `ERR_INVALID_ARG_TYPE`), connection refused, timeouts, oversized * bodies, malformed JSON, missing `webSocketDebuggerUrl`, mismatched * websocket paths, errors on the response stream — all return `false`. * Callers can retry without exception plumbing. * * Kept small and stdlib-only (`node:http`) to avoid pulling another * fetch implementation into the launcher hot path. */ export declare function probeCdpAlive(port: number, options?: ProbeCdpAliveOptions): Promise; /** * Poll `/DevToolsActivePort` for the CDP port. Chrome writes * this file as soon as the DevTools listener is up — its first line is * the port, the second is the websocket path. This is the canonical way * Chromium itself recommends discovering the chosen port and is far more * reliable than scraping stderr. * * Validation: before resolving, probe `/json/version` on the discovered * port and require the response's `webSocketDebuggerUrl` pathname to * match the path Chrome wrote into the file's second line. The file is * written by Chrome but reused across runs in the same profile * directory, and `clearStaleDevToolsActivePort` is a best-effort unlink * that races our spawn. If the probe fails (port refused, wrong CDP * instance, anything) we treat the file content as stale and keep * polling for either an updated file (the freshly-spawned Chrome about * to overwrite it) or the eventual timeout. The pathname comparison * specifically guards against the port-reuse case where a stale file * points at a port that's now serving an *unrelated* Chrome/CDP * instance (different `browser/` path). * * Resolves with the parsed port once both the file content and the * live CDP probe succeed. Rejects on timeout or process exit, with * the timeout message distinguishing "file never appeared" from "file * appeared but its port never answered CDP". * * @param userDataDir absolute path Chrome was launched with via `--user-data-dir=` * @param child the Chrome child process (used to bail out on early exit) * @param timeoutMs total budget before giving up * @param pollMs polling cadence (default 50ms) * @param options test seam — inject a custom `verifyPort` to avoid * real network probes in unit tests. The verifier * receives both the parsed port and the websocket * path Chrome wrote on line 2 (or `null` if absent). */ export declare function waitForCdpPortFromActivePortFile(userDataDir: string, child: ChildProcess, timeoutMs?: number, pollMs?: number, options?: { verifyPort?: (port: number, expectedWebSocketPath: string | null) => Promise; }): Promise; /** * Race the stderr scraper and the `DevToolsActivePort` poller. Whichever * resolves first wins; the loser is silently ignored. This is the * recommended entry point for callers who already have a `--user-data-dir` * on hand (which is the usual case in tests and CLI launches). */ export declare function waitForCdpPort(child: ChildProcess, options?: { userDataDir?: string; timeoutMs?: number; /** * Test seam — forwarded to `waitForCdpPortFromActivePortFile` so unit * tests can simulate the "file says port X, but X isn't actually * answering CDP" stale-port race without binding a real socket. The * verifier receives both the parsed port and the websocket path * Chrome wrote on the file's second line (or `null` when the file * was read mid-write and only the port is available). */ verifyPort?: (port: number, expectedWebSocketPath: string | null) => Promise; }): Promise; export {};