//! Is there anything in this container worth watching, and can we serve a viewer //! for it? //! //! Playwright is **not** in the container image — it is installed by the user or //! by Claude, into whichever `node_modules` happens to be in scope. So detection //! has to be done inside the container, at the moment the pane is opened, and it //! has to produce an *actionable* answer when the pieces are missing: the pane's //! one unforgivable failure mode would be an unexplained spinner. //! //! Three things must line up: //! //! 1. **`playwright-core`** (directly, or via `playwright`, which re-exports it), //! 2. at a version whose `Browser` exposes **`bind()`** — the live-dashboard API //! that publishes a browser for a viewer to attach to, and //! 3. **`@playwright/cli`**, which ships the viewer UI itself. //! //! Discovery of published browsers is local-filesystem based (a cache directory //! plus a unix-socket singleton in the temp dir), which is exactly why the viewer //! has to run *in the container* next to the browsers rather than on the host. use serde::{Deserialize, Serialize}; use crate::docker::exec::exec_oneshot; /// Marks the JSON payload in the probe's stdout, so unrelated chatter on the /// same stream (npm notices, Node warnings) can't be mistaken for the result. const MARKER: &str = "__TRIPLE_C_BROWSER_VIEW__"; /// What the probe found. Serialised straight to the frontend so the pane can /// explain itself precisely rather than saying "not available". #[derive(Debug, Clone, Default, Serialize, Deserialize)] pub struct PlaywrightDetection { /// Node's own version, if `node` ran at all. #[serde(default)] pub node_version: Option, /// Resolved `playwright-core` (or `playwright`) version. #[serde(default)] pub playwright_version: Option, /// Absolute path of the resolved package manifest, for the diagnostics line. #[serde(default)] pub playwright_path: Option, /// Whether the resolved build's type definitions declare `Browser.bind()`. #[serde(default)] pub has_bind: bool, /// Resolved `@playwright/cli` version — the package that serves the viewer. #[serde(default)] pub cli_version: Option, /// Absolute path of `@playwright/cli`'s entry script. Invoked with `node` /// directly rather than through its bin shim, so the viewer's PID is the one /// we can signal. #[serde(default)] pub cli_entry: Option, /// Where the probe looked, echoed back for the "not found" message. #[serde(default)] pub searched: Vec, } impl PlaywrightDetection { /// Everything needed to actually serve the pane. pub fn is_usable(&self) -> bool { self.playwright_version.is_some() && self.has_bind && self.cli_entry.is_some() } /// A specific, actionable explanation of what is missing. `None` when the /// container is ready. pub fn blocker(&self) -> Option { if self.node_version.is_none() { return Some( "Node.js isn't runnable in this container, so Playwright can't be detected." .to_string(), ); } if self.playwright_version.is_none() { return Some(format!( "Playwright isn't installed in this container. Install it with \ `npm i -D playwright` (or `npm i -g playwright`), then have Claude call \ `await browser.bind('claude')` after launching a browser — or use \ `@playwright/mcp`, which binds automatically. Looked in: {}.", if self.searched.is_empty() { "the container's default module paths".to_string() } else { self.searched.join(", ") } )); } if !self.has_bind { return Some(format!( "Playwright {} is installed, but it predates the live-dashboard API \ (`browser.bind()`). Upgrade with `npm i -D playwright@latest` and restart \ the browser Claude is driving.", self.playwright_version.as_deref().unwrap_or("?") )); } if self.cli_entry.is_none() { return Some( "Playwright is installed, but the viewer UI package isn't. Install it with \ `npm i -D @playwright/cli`, then reopen this tab." .to_string(), ); } None } } /// One `node -e` probe, run as `claude` inside the container. /// /// No shell quoting is involved: the script is a single `argv` element. The /// script finds the global `node_modules` root itself, so a Playwright installed /// with `npm i -g` is found as readily as one in `/workspace/node_modules`. pub async fn detect(container_id: &str) -> Result { let output = exec_oneshot( container_id, vec!["node".to_string(), "-e".to_string(), PROBE.to_string()], ) .await?; parse_probe_output(&output) } /// Pull the marked JSON object out of the probe's combined output. /// /// `exec_oneshot` interleaves stdout and stderr, and Node happily writes /// deprecation warnings to the latter, so the payload is located by marker /// rather than by assuming it is the whole stream. pub(crate) fn parse_probe_output(output: &str) -> Result { let start = output.find(MARKER).ok_or_else(|| { let trimmed = output.trim(); if trimmed.is_empty() { "Playwright detection produced no output. Is Node.js present in the container?" .to_string() } else { format!( "Playwright detection failed: {}", trimmed.lines().next_back().unwrap_or(trimmed) ) } })? + MARKER.len(); // The payload runs to the end of that line; anything the probe's own // children wrote afterwards is not ours. let json = output[start..].lines().next().unwrap_or("").trim(); serde_json::from_str(json) .map_err(|e| format!("Could not read the Playwright detection result: {}", e)) } /// The probe. Kept as one string so the quoting story is "there isn't one". /// /// Deliberately tolerant: every lookup is individually guarded, because a /// half-installed `node_modules` must produce a *partial* answer that /// [`PlaywrightDetection::blocker`] can turn into advice, not an exception that /// produces "detection failed". const PROBE: &str = concat!( r#"const fs=require("fs"),path=require("path"),cp=require("child_process");"#, r#"const out={node_version:process.versions.node,searched:[],has_bind:false};"#, // `npm root -g` is the only reliable way to learn the global prefix, and it // is cheap enough to pay for once per pane open. r#"let g=null;try{g=cp.execSync("npm root -g",{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()||null;}catch(e){}"#, r#"const roots=[...new Set(["/workspace",process.cwd(),process.env.HOME?path.join(process.env.HOME,"node_modules"):null,g].filter(Boolean))];"#, r#"out.searched=roots;"#, r#"const res=(s)=>{for(const r of roots){try{return require.resolve(s,{paths:[r]});}catch(e){}}return null;};"#, r#"const core=res("playwright-core/package.json")||res("playwright/package.json");"#, r#"if(core){try{out.playwright_path=core;out.playwright_version=JSON.parse(fs.readFileSync(core,"utf8")).version;}catch(e){}"#, // `bind`/`unbind` are checked against the shipped type definitions rather // than by loading the module: it is a static read, needs no browser, and // cannot be tripped up by a package that fails to import. r#"try{const t=fs.readFileSync(path.join(path.dirname(core),"types","types.d.ts"),"utf8");"#, r#"out.has_bind=/\bunbind\s*\(\s*\)/.test(t)&&/\bbind\s*\(/.test(t);}catch(e){}}"#, r#"const cli=res("@playwright/cli/package.json");"#, r#"if(cli){try{const j=JSON.parse(fs.readFileSync(cli,"utf8"));out.cli_version=j.version;"#, r#"const b=typeof j.bin==="string"?{[j.name]:j.bin}:(j.bin||{});const k=Object.keys(b)[0];"#, r#"if(k)out.cli_entry=path.resolve(path.dirname(cli),b[k]);}catch(e){}}"#, r#"process.stdout.write("\n__TRIPLE_C_BROWSER_VIEW__"+JSON.stringify(out)+"\n");"#, ); #[cfg(test)] mod tests { use super::*; fn payload(json: &str) -> String { format!("some npm noise\n{}{}\n", MARKER, json) } #[test] fn a_complete_install_is_usable() { let d = parse_probe_output(&payload( r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"cli_version":"0.1.18","cli_entry":"/workspace/node_modules/@playwright/cli/playwright-cli.js","searched":["/workspace"]}"#, )) .unwrap(); assert!(d.is_usable()); assert_eq!(d.blocker(), None); } #[test] fn stderr_noise_before_and_after_the_payload_is_ignored() { let out = format!( "(node:41) Warning: something\n{}{}\nnpm notice trailing\n", MARKER, r#"{"node_version":"22.11.0","has_bind":false}"# ); let d = parse_probe_output(&out).unwrap(); assert_eq!(d.node_version.as_deref(), Some("22.11.0")); } #[test] fn a_missing_playwright_is_reported_with_where_we_looked() { let d = parse_probe_output(&payload( r#"{"node_version":"22.11.0","searched":["/workspace","/usr/lib/node_modules"]}"#, )) .unwrap(); assert!(!d.is_usable()); let msg = d.blocker().unwrap(); assert!(msg.contains("npm i -D playwright"), "{}", msg); assert!(msg.contains("browser.bind"), "{}", msg); assert!(msg.contains("/usr/lib/node_modules"), "{}", msg); } #[test] fn a_playwright_without_bind_asks_for_an_upgrade() { let d = parse_probe_output(&payload( r#"{"node_version":"22.11.0","playwright_version":"1.44.0","has_bind":false,"cli_entry":"/x/cli.js"}"#, )) .unwrap(); let msg = d.blocker().unwrap(); assert!(msg.contains("1.44.0"), "{}", msg); assert!(msg.contains("playwright@latest"), "{}", msg); } #[test] fn a_missing_viewer_package_is_reported_separately() { let d = parse_probe_output(&payload( r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true}"#, )) .unwrap(); assert!(!d.is_usable()); assert!(d.blocker().unwrap().contains("@playwright/cli")); } #[test] fn a_container_without_node_says_so() { let d = parse_probe_output(&payload(r#"{"has_bind":false}"#)).unwrap(); assert!(d.blocker().unwrap().contains("Node.js")); } #[test] fn an_unmarked_stream_surfaces_the_containers_own_error() { let err = parse_probe_output("sh: 1: node: not found\n").unwrap_err(); assert!(err.contains("node: not found"), "{}", err); } #[test] fn an_empty_stream_is_explained_rather_than_parsed() { let err = parse_probe_output(" \n").unwrap_err(); assert!(err.contains("no output"), "{}", err); } #[test] fn the_probe_is_a_single_argv_element_with_no_quoting_hazards() { // It is passed straight to `node -e`; a stray single quote would only // matter if someone later routed it through a shell, and a newline // would break the marker-line contract in `parse_probe_output`. assert!(!PROBE.contains('\n')); assert!(PROBE.contains(MARKER)); } }