//! One-action setup for the browser view. //! //! The pane used to answer "Playwright isn't installed" with a sentence of npm //! commands and leave the user to it. That went badly, and every part of how it //! went badly is a constraint on this module: //! //! * `claude mcp add … npx @playwright/mcp@latest` looks like installing //! Playwright and is not — see [`super::detect`] — and it can never satisfy //! this pane on its own, because the viewer lives in `@playwright/cli`. //! * `sudo npm i -g playwright` is what people try next. It works, but the //! image leaves npm's prefix at `/usr`, so the unprivileged form fails with //! `EACCES` first. //! * `playwright install chromium` downloads a browser that then cannot start, //! because the image shipped **none** of Chromium's shared libraries //! (`libnss3`, `libgbm1`, `libatk*`, `libasound2`, `libcups2`, …). The //! download succeeds, the launch fails, and the error reads like a Playwright //! bug. Current base images bake those libraries in (see `container/ //! Dockerfile`), so this step is now usually a no-op — but a project stays on //! the base image it was first built from until someone migrates it, so the //! old case is the *normal* case and has to keep working. Hence: check, then //! install only if needed, and say which happened. //! * Getting from there to a working pane took a long tail of further commands. //! //! ## Where the packages go, and why it is `/workspace` //! //! `playwright` + `@playwright/cli`, installed **locally into `/workspace`** as //! `claude`, with `--no-save`. //! //! `/workspace` is *not* the user's repository. Project directories are //! bind-mounted one level down, at `/workspace/{mount_name}` (see //! `container_config`), so `/workspace` itself is ordinary container storage. //! That single fact settles the choice: //! //! * **Nothing of the user's is mutated.** `/workspace/node_modules` is not //! inside any bind mount, so no host file, no `package.json` and no lockfile //! of theirs is touched. `--no-save` is belt and braces for the case where //! someone has put a `package.json` at `/workspace` themselves — verified //! that an install into a directory without one writes `node_modules` and //! nothing else. //! * **No sudo.** `/usr/lib/node_modules` is root-owned; `/workspace` is the //! `claude` user's own working directory. A setup action that needs no //! privilege escalation is a setup action with one fewer way to fail. //! * **Node can actually find it.** A global install is *not* on the module //! resolution path — `require('playwright')` from a script in //! `/workspace/my-project` does not see `/usr/lib/node_modules`, but it does //! walk up to `/workspace/node_modules`. Since the whole point is for Claude //! to drive a browser from a script in the project, this is the difference //! between setup that works and setup that merely reports as complete. //! * **It hoists.** A local install puts `playwright-core` at the top of the //! tree where the probe finds it directly; `npm i -g` leaves it nested (see //! the note in [`super::detect`]). //! * **It persists.** `/workspace` outside the bind mounts rides the project's //! snapshot image across container recreation, and migration copies the //! non-bind-mounted parts of `/workspace` forward. //! //! Browsers are the exception and are downloaded as `claude` into //! `~/.cache/ms-playwright`, inside the home volume — so they survive //! recreation *and* base-image migration, and are lost only on a project Reset. //! That is worth saying in the UI: it is the difference between a //! several-hundred-megabyte download once and one every time. use std::collections::VecDeque; use std::time::Duration; use futures_util::StreamExt; use serde::Serialize; use tauri::AppHandle; use crate::commands::project_commands::emit_progress; use crate::docker::exec::{ create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec, }; use super::detect::{self, PlaywrightDetection}; /// The viewer package — installed **first**, and it decides the version of /// `playwright` installed after it. /// /// `@playwright/mcp` is deliberately not part of the set: it is Claude's MCP /// configuration to make, and it contributes nothing to serving a viewer. /// /// **Order matters here, and `playwright` is deliberately not `@latest`.** /// /// Installing both at `@latest` produces a tree that looks right and is broken. /// Verified on a real container: `@playwright/cli@0.1.18` pins /// `playwright-core@1.63.0-alpha`, npm hoists that to the root, and /// `playwright@latest` (1.62.1) then nests its own `playwright-core@1.62.1` /// beside it. The two cores want *different browser revisions*. The browser /// step runs the resolved — hoisted — CLI, so it downloads 1237; every script /// Claude writes says `require("playwright")`, gets the nested 1.62.1, and dies /// with "Executable doesn't exist … chromium_headless_shell-1234". The pane /// meanwhile reports a browser installed, because one is. /// /// So the viewer package goes first and its own pinned `playwright` version is /// what gets installed second — one core, one browser revision, both halves /// agreeing. See [`pinned_playwright_spec`]. pub const VIEWER_PACKAGE: &str = "@playwright/cli@latest"; /// Fallback when the viewer's manifest can't be read: better a possibly-skewed /// tree than no Playwright at all, and [`detect`](super::detect) reports the /// skew either way. pub const PLAYWRIGHT_FALLBACK: &str = "playwright@latest"; /// Where the packages are installed. Container storage, not a bind mount — see /// the module docs. pub const INSTALL_DIR: &str = "/workspace"; /// npm reaching the registry and unpacking two packages. Minutes, not hours. const NPM_TIMEOUT: Duration = Duration::from_secs(10 * 60); /// `apt-get update` plus a dozen library packages, or Google's apt repository. const DEPS_TIMEOUT: Duration = Duration::from_secs(20 * 60); /// `apt-get install -s` over ~100 already-installed packages. Local work; a /// container that cannot answer this in two minutes gets the libraries /// installed rather than a hang. const DEPS_CHECK_TIMEOUT: Duration = Duration::from_secs(2 * 60); /// The browser download itself, on a bad connection. const BROWSER_TIMEOUT: Duration = Duration::from_secs(45 * 60); /// Starting a headless browser, and one page load. const VERIFY_TIMEOUT: Duration = Duration::from_secs(120); /// Lines of command output kept for the result. Enough to carry npm's actual /// error, bounded so a chatty download can't grow without limit. const LOG_LINES: usize = 200; /// Longest run of bytes without a line break still treated as one progress /// line. npm's progress output is `\r`-driven and can go a long way. const MAX_PARTIAL: usize = 4 * 1024; /// Marks the verdict in the launch check's output, for the same reason /// [`super::detect`] marks its payload: the stream also carries whatever /// Chromium felt like writing to stderr. const LAUNCH_MARKER: &str = "__TRIPLE_C_BROWSER_LAUNCH__"; /// URL the launch check navigates to once a browser is up. The registry is the /// one host we know the container just reached, during the npm step — so a /// failure here is informative rather than ambient. const REACHABILITY_URL: &str = "https://registry.npmjs.org/-/ping"; /// Which browser to install. Both are legitimate; they serve different callers. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BrowserTarget { /// Playwright's own build. What `chromium.launch()` uses with no `channel`, /// i.e. the default for scripts the user or Claude writes. Chromium, /// Google Chrome from Google's apt repository. **`@playwright/mcp` asks for /// the `chrome` channel specifically**, so anyone driving the browser /// through the MCP plugin needs this one rather than (or as well as) the /// bundled build. Chrome, } impl BrowserTarget { pub fn parse(s: &str) -> Result { match s { "chromium" => Ok(Self::Chromium), "chrome" => Ok(Self::Chrome), other => Err(format!( "Unknown browser '{}'. This pane installs 'chromium' (Playwright's own build) \ or 'chrome' (the Google Chrome channel that @playwright/mcp asks for).", other )), } } /// The name Playwright's CLI knows it by. pub fn cli_name(self) -> &'static str { match self { Self::Chromium => "chromium", Self::Chrome => "chrome", } } /// Shown *before* the click. Size first, because the honest failure mode /// here is a user who did not know they were starting a large download. pub fn download_note(self) -> &'static str { match self { Self::Chromium => { "Playwright's Chromium build — a few hundred MB, a few minutes on a normal \ connection. The system libraries it needs are already in current base images; \ on an older container they are installed first" } Self::Chrome => { "Google Chrome from Google's apt repository, with its dependencies — roughly \ 150 MB" } } } /// Who needs it, in the user's terms. pub fn needed_for(self) -> &'static str { match self { Self::Chromium => "Playwright scripts that call `chromium.launch()` with no channel", Self::Chrome => "`@playwright/mcp`, which asks for the `chrome` channel", } } /// The `channel` a launch check must pass. `None` means the bundled build. fn channel(self) -> Option<&'static str> { match self { Self::Chromium => None, Self::Chrome => Some("chrome"), } } } /// What a setup step did, handed back to the pane so it can update itself /// without the user reopening the tab. #[derive(Debug, Clone, Serialize)] pub struct BrowserSetupOutcome { /// A fresh probe, run after the step. This is what makes the pane /// self-updating. pub detection: PlaywrightDetection, /// Tail of the actual command output — always populated, success or not. pub log: String, /// Verdict of a real headless launch. `None` when the step didn't try one /// (the npm step doesn't), `Some(false)` when a browser is installed and /// still would not start. pub browser_launched: Option, /// A step that failed, or succeeded suspiciously, without failing the whole /// action — the user is told rather than left to find out at launch time. pub warning: Option, } /// Install `playwright` and `@playwright/cli` into `/workspace`. Deliberately /// does *not* fetch browsers: that is the second step, and its size is stated /// before it is offered. pub async fn install_packages( app: &AppHandle, project_id: &str, container_id: &str, ) -> Result { emit_progress( app, project_id, &format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR), ); let mut step = npm_install(app, project_id, container_id, VIEWER_PACKAGE).await?; if step.exit_code != 0 { return Err(format!( "npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}", step.exit_code, step.log_or("it produced no output at all") )); } // Second, `playwright` at the version the viewer package pins — see // `VIEWER_PACKAGE`. Installing it as `@latest` is what splits the tree. let spec = pinned_playwright_spec(container_id).await; emit_progress(app, project_id, &format!("Installing {}…", spec)); let second = npm_install(app, project_id, container_id, &spec).await?; if second.exit_code != 0 { return Err(format!( "npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}", spec, second.exit_code, second.log_or("it produced no output at all") )); } step.log = merge_logs(step.log, second.log); emit_progress(app, project_id, "Re-checking what the container has…"); let detection = detect::detect(container_id).await?; // A probe that still finds something missing after a successful npm run is // the interesting case, so it is surfaced rather than swallowed. And a // container with the packages but no browser is *not* finished setup — // saying so here is what stops someone walking away from a pane that will // never show them anything. let mut warning = detection.blocker(); // Skew outranks "no browser": a container in that state *has* browsers, and // telling someone to install one they can see already installed is how a // real user ends up doing it three times. if let Some(skew) = detection.skew_message() { warning = merge(warning, skew); } else if detection.needs_browser() { warning = merge( warning, "Playwright is installed, but this container has no browser to drive yet. Install \ one below — that is the large download, and it is a separate step on purpose." .to_string(), ); } Ok(BrowserSetupOutcome { warning, detection, log: step.log, browser_launched: None, }) } /// One `npm install` of one spec, into [`INSTALL_DIR`], as `claude`. /// /// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in /// `docker/exec.rs` untouched, and `env` is a real binary so no shell is /// involved. The guard matters because these are `@latest`: current Playwright /// has no postinstall (verified — `playwright@1.62.1` declares no `scripts` at /// all), but if a future release brings the browser download back, this step /// must stay small and the download must stay the step the user asked for. async fn npm_install( app: &AppHandle, project_id: &str, container_id: &str, spec: &str, ) -> Result { let cmd = vec![ "env".to_string(), "PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(), "npm".to_string(), "install".to_string(), // Leaves any package.json and lockfile at /workspace untouched. "--no-save".to_string(), "--no-fund".to_string(), "--no-audit".to_string(), spec.to_string(), ]; run_step( app, project_id, container_id, "claude", INSTALL_DIR, cmd, NPM_TIMEOUT, ) .await } /// The `playwright` spec to install: the exact version `@playwright/cli` /// depends on, so both halves share one `playwright-core`. /// /// Read from the manifest npm just wrote rather than guessed, and falling back /// to `@latest` when it can't be read — an unreadable manifest is a reason to /// install something, not nothing. async fn pinned_playwright_spec(container_id: &str) -> String { let script = format!( "try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\ process.stdout.write(d.playwright||'');}}catch(e){{}}", INSTALL_DIR ); let (out, _code) = exec_oneshot_as( container_id, "claude", vec!["node".to_string(), "-e".to_string(), script], Vec::new(), ) .await .unwrap_or_default(); let version: &str = out.trim(); // A version, not a range or a URL: anything else goes to the fallback // rather than into an npm command line. if !version.is_empty() && version .chars() .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+')) { format!("playwright@{}", version) } else { PLAYWRIGHT_FALLBACK.to_string() } } /// Keep both npm runs' output, so a failure in either is diagnosable. fn merge_logs(first: String, second: String) -> String { match (first.trim().is_empty(), second.trim().is_empty()) { (true, _) => second, (_, true) => first, _ => format!("{}\n{}", first.trim_end(), second), } } /// Install a browser: its system libraries first, then the browser, then prove /// one actually starts. /// /// The order is the whole point. `playwright install chromium` on this image /// downloads a browser that cannot launch, because the libraries it links /// against are absent — which is why installing Chrome through apt looked like /// the fix: apt pulls those libraries in as dependencies. pub async fn install_browser( app: &AppHandle, project_id: &str, container_id: &str, target: BrowserTarget, ) -> Result { let before = detect::detect(container_id).await?; let Some(cli) = before.playwright_cli.clone() else { return Err( "Playwright isn't installed in this container yet, so there is nothing to install a \ browser for. Run “Set up Playwright” first." .to_string(), ); }; let mut log = String::new(); let mut warning: Option = None; // Step 1 — system libraries. Slow, previously invisible, and the actual // cause of the "Chromium downloads and then dies" reports, so it gets its // own progress line rather than being folded into the download. // // Current base images bake these in, so on an up-to-date container there is // nothing to do here. That is *not* a reason to drop the step: a project // keeps the base image it was first built from until it is migrated, so // containers without the libraries are the common case for a long while // yet. So: ask first, skip loudly, install only when the answer is no. // // The question is put to Playwright rather than answered by probing for // library names ourselves. `install-deps --dry-run` simulates the very // `apt-get install` that `install-deps` would run and exits non-zero if // anything is missing, which means the check and the fix can never disagree // about what "the libraries" means — including after a Playwright release // adds one. // // Installing runs `install-deps` directly *as root*: that is what `playwright // install --with-deps` does internally, minus Playwright's own privilege // escalation (read from the shipped source, it shells out to // `sudo -- sh -c "apt-get update && apt-get install …"` when it is not root, // which would work here — `claude` has passwordless sudo — but puts an extra // failure mode between the user and the answer). // // For the Chrome channel apt installs `google-chrome-stable`, whose own // dependencies cover the same libraries — but going through the same check // first makes the two paths behave identically. emit_progress( app, project_id, "Step 1/3 — checking whether this container already has the browser system libraries…", ); let state = check_libraries(container_id, &cli, target).await; match &state { LibraryState::Present => { emit_progress( app, project_id, "Step 1/3 — already there: this image ships the browser system libraries. \ Skipping the apt install.", ); push_section(&mut log, "Browser system libraries: already installed, apt skipped."); } LibraryState::Missing(_) | LibraryState::Unknown(_) => { emit_progress( app, project_id, &format!( "Step 1/3 — {} Installing browser system libraries with apt (needs root; a \ minute or two)…", state.detail() ), ); let deps = run_step( app, project_id, container_id, "root", "/tmp", vec![ "node".to_string(), cli.clone(), "install-deps".to_string(), target.cli_name().to_string(), ], DEPS_TIMEOUT, ) .await?; push_section(&mut log, &deps.log); if deps.exit_code != 0 { // Not fatal on its own — some of the libraries may already be // present — but it must never pass silently, because the failure // it causes surfaces much later and looks like something else. warning = Some(format!( "Installing the browser's system libraries failed (exit {}). The browser may \ install and then refuse to start. apt said:\n{}", deps.exit_code, deps.log_or("nothing") )); } } } // Step 2 — the download the user was warned about. Chromium is fetched as // `claude` so the bundle lands in the home volume's `~/.cache/ms-playwright` // where the viewer looks for it; the Chrome channel is an apt install and // has to be root. emit_progress( app, project_id, &format!( "Step 2/3 — installing {}, needed for {} — {}…", target.cli_name(), target.needed_for(), target.download_note() ), ); let (user, workdir) = match target { BrowserTarget::Chromium => ("claude", INSTALL_DIR), BrowserTarget::Chrome => ("root", "/tmp"), }; let dl = run_step( app, project_id, container_id, user, workdir, vec![ "node".to_string(), cli, "install".to_string(), target.cli_name().to_string(), ], BROWSER_TIMEOUT, ) .await?; push_section(&mut log, &dl.log); if dl.exit_code != 0 { return Err(format!( "{} didn't install (exit {}).\n\nPlaywright said:\n{}", target.cli_name(), dl.exit_code, dl.log_or("nothing") )); } // Step 3 — "installed" is not "works". The absence of this check is what // turned a missing library into a pile of confusing errors. emit_progress( app, project_id, &format!("Step 3/3 — checking that {} actually launches…", target.cli_name()), ); let verdict = verify_launch(container_id, &before, target).await; push_section(&mut log, &verdict.detail); if !verdict.ok { warning = merge( warning, format!( "{} is installed but would not start: {}", target.cli_name(), verdict.detail.trim() ), ); } if let Some(cert) = verdict.cert_error { // Distinct on purpose. A TLS-intercepting proxy is a container-wide // trust-store gap — it breaks npm, git, curl and Claude Code the same // way — and telling someone behind one that Playwright is broken sends // them to fix the wrong thing. This pane does not install CAs. warning = merge( warning, format!( "The browser starts, but HTTPS pages fail certificate validation ({}). That is \ this container not trusting your network's certificate authority — a TLS-\ intercepting proxy — and it affects everything in the container, not just the \ browser. Installing the CA into the container's trust store is the fix; this \ pane doesn't do that.", cert.trim() ), ); } emit_progress(app, project_id, "Re-checking what the container has…"); let detection = detect::detect(container_id).await?; Ok(BrowserSetupOutcome { detection, log: log.trim().to_string(), browser_launched: Some(verdict.ok), warning, }) } /// Phrases `install-deps --dry-run` prints. Matching on Playwright's own words /// is load-bearing: the exit code alone cannot tell "everything is installed" /// (0) apart from "this platform isn't in my table, so I did nothing" (also 0). const DEPS_OK_MARKER: &str = "All system dependencies are installed"; const DEPS_MISSING_MARKER: &str = "Missing system dependencies"; const DEPS_UNKNOWN_PLATFORM_MARKER: &str = "Cannot install dependencies for"; /// Whether this container already has the libraries a browser links against. #[derive(Debug, Clone, PartialEq, Eq)] enum LibraryState { /// Playwright confirms every package it would install is present. Current /// base images bake them, so this is the answer on an up-to-date container. Present, /// Playwright named packages that are absent. Missing(String), /// The check could not answer. Always installs — an unnecessary apt run /// costs a minute, a skipped one costs a browser that will not start. Unknown(String), } impl LibraryState { /// What the user sees on the progress line, and why. fn detail(&self) -> &str { match self { Self::Present => "", Self::Missing(d) | Self::Unknown(d) => d, } } } /// Ask Playwright whether the libraries are already installed. /// /// `--dry-run` simulates the same `apt-get install` that `install-deps` would /// perform and exits non-zero if any package is missing, so the check can never /// disagree with the fix about what the dependency set is — including after a /// Playwright release changes it. /// /// Note that the simulation works on an image whose `/var/lib/apt/lists` has /// been cleaned (every base image's has): apt knows installed packages from /// dpkg's status file. A *missing* package on such an image is simply not in /// any index, so apt fails, Playwright reports the failure, and this returns /// [`LibraryState::Unknown`] — which installs, which is the right answer. async fn check_libraries(container_id: &str, cli: &str, target: BrowserTarget) -> LibraryState { let run = exec_oneshot_as( container_id, "root", vec![ "node".to_string(), cli.to_string(), "install-deps".to_string(), "--dry-run".to_string(), target.cli_name().to_string(), ], vec![], ); match tokio::time::timeout(DEPS_CHECK_TIMEOUT, run).await { Ok(Ok((output, code))) => classify_library_check(&output, code), Ok(Err(e)) => LibraryState::Unknown(format!( "Couldn't ask Playwright whether they're already there ({}), so installing them to be \ sure.", e )), Err(_) => LibraryState::Unknown( "The check for them didn't finish in time, so installing them to be sure.".to_string(), ), } } /// Read the verdict out of `install-deps --dry-run`'s output. fn classify_library_check(output: &str, exit_code: i64) -> LibraryState { // Checked before the success marker, not after: this branch also exits 0. if output.contains(DEPS_UNKNOWN_PLATFORM_MARKER) { return LibraryState::Unknown( "Playwright doesn't have a dependency list for this container's platform, so it \ can't say — installing them to be sure." .to_string(), ); } if exit_code == 0 && output.contains(DEPS_OK_MARKER) { return LibraryState::Present; } if let Some(idx) = output.find(DEPS_MISSING_MARKER) { let summary = output[idx..] .lines() .next() .unwrap_or(DEPS_MISSING_MARKER) .trim(); return LibraryState::Missing(format!("Playwright reports {}", summary.to_lowercase())); } LibraryState::Unknown(format!( "Couldn't tell whether they're already there (the check exited {}), so installing them to \ be sure.", exit_code )) } /// The result of actually starting a browser. #[derive(Debug, Clone)] struct LaunchVerdict { ok: bool, detail: String, /// Set when a page load failed specifically on certificate trust. cert_error: Option, } /// Launch the browser headless, load one page, and close it. Seconds, and it is /// the only thing that distinguishes "downloaded" from "usable". async fn verify_launch( container_id: &str, detection: &PlaywrightDetection, target: BrowserTarget, ) -> LaunchVerdict { // The module directory of whatever Playwright the probe resolved, passed in // the environment rather than interpolated into the script, so no path can // ever be read as JavaScript. let Some(manifest) = detection.playwright_path.as_deref() else { return LaunchVerdict { ok: false, detail: "Playwright could not be located to test with.".to_string(), cert_error: None, }; }; let dir = manifest.trim_end_matches("package.json").trim_end_matches('/'); let run = exec_oneshot_as( container_id, "claude", vec![ "node".to_string(), "-e".to_string(), LAUNCH_PROBE.to_string(), ], vec![ format!("TRIPLE_C_PW_DIR={}", dir), format!("TRIPLE_C_PW_CHANNEL={}", target.channel().unwrap_or("")), format!("TRIPLE_C_PW_URL={}", REACHABILITY_URL), ], ); match tokio::time::timeout(VERIFY_TIMEOUT, run).await { Ok(Ok((output, _))) => parse_launch_output(&output), Ok(Err(e)) => LaunchVerdict { ok: false, detail: e, cert_error: None, }, Err(_) => LaunchVerdict { ok: false, detail: "the launch check didn't finish in time — treat the browser as unproven" .to_string(), cert_error: None, }, } } /// Pull the verdict out of the launch check's combined output. fn parse_launch_output(output: &str) -> LaunchVerdict { let Some(idx) = output.find(LAUNCH_MARKER) else { let trimmed = output.trim(); return LaunchVerdict { ok: false, detail: if trimmed.is_empty() { "the launch check produced no output".to_string() } else { trimmed.lines().next_back().unwrap_or(trimmed).trim().to_string() }, cert_error: None, }; }; let line = output[idx + LAUNCH_MARKER.len()..] .lines() .next() .unwrap_or("") .trim(); let value: serde_json::Value = match serde_json::from_str(line) { Ok(v) => v, Err(e) => { return LaunchVerdict { ok: false, detail: format!("unreadable launch check result: {}", e), cert_error: None, } } }; let ok = value.get("ok").and_then(|b| b.as_bool()).unwrap_or(false); let detail = value .get("detail") .and_then(|d| d.as_str()) .unwrap_or("") .to_string(); let nav = value.get("nav"); let cert_error = nav .filter(|n| n.get("cert").and_then(|c| c.as_bool()).unwrap_or(false)) .and_then(|n| n.get("detail").and_then(|d| d.as_str())) .map(|s| s.to_string()); let detail = if !ok { detail } else { match nav.and_then(|n| n.get("ok").and_then(|o| o.as_bool())) { // A launch that works and a page that loads: setup is genuinely done. Some(true) => format!("Browser launched ({}) and loaded a page.", detail), // Launch fine, page not. Cert failures are reported separately; any // other cause is likely just an offline container, so it is stated // without being turned into an alarm. Some(false) => format!( "Browser launched ({}), but couldn't load a test page: {}", detail, nav.and_then(|n| n.get("detail").and_then(|d| d.as_str())) .unwrap_or("no detail") ), _ => format!("Browser launched ({}).", detail), } }; LaunchVerdict { ok, detail, cert_error, } } /// The launch check. One `argv` element, no newlines, same contract as the /// detection probe. /// /// Playwright leaves the Chromium sandbox disabled by default, which is what /// makes this work in a container at all. The timeout exists so a browser that /// hangs on a missing library still returns a verdict rather than sitting there /// until the exec is torn down. The navigation is best-effort and never decides /// `ok` — it exists to tell a TLS-intercepted network apart from a broken /// install. const LAUNCH_PROBE: &str = concat!( r#"const d=process.env.TRIPLE_C_PW_DIR,ch=process.env.TRIPLE_C_PW_CHANNEL||undefined,u=process.env.TRIPLE_C_PW_URL;"#, r#"let done=false;const say=(ok,detail,nav)=>{if(done)return;done=true;"#, r#"process.stdout.write("\n__TRIPLE_C_BROWSER_LAUNCH__"+JSON.stringify({ok,detail,nav:nav||null})+"\n");};"#, r#"const one=(e)=>String((e&&e.message)||e).split("\n").slice(0,8).join(" | ");"#, r#"const t=setTimeout(()=>{say(false,"the browser did not finish starting within 90s");process.exit(0);},90000);"#, r#"(async()=>{let b=null;try{const {chromium}=require(d);b=await chromium.launch(ch?{channel:ch}:{});"#, r#"let v="";try{v=b.version();}catch(e){}"#, r#"let nav={ok:true,cert:false,detail:""};"#, r#"try{const p=await b.newPage();await p.goto(u,{timeout:20000});}"#, // A certificate failure is classified here, next to the message, because // Chromium's wording is the only place the distinction exists. r#"catch(e){const m=one(e);nav={ok:false,cert:/ERR_CERT|CERT_AUTHORITY|ERR_SSL|SSL_ERROR|self.signed/i.test(m),detail:m};}"#, r#"await b.close();clearTimeout(t);say(true,v,nav);}"#, r#"catch(e){clearTimeout(t);try{if(b)await b.close();}catch(e2){}say(false,one(e));}"#, r#"process.exit(0);})();"#, ); /// One command's result: its exit code and the tail of what it printed. struct StepResult { exit_code: i64, log: String, } impl StepResult { fn log_or<'a>(&'a self, fallback: &'a str) -> &'a str { if self.log.trim().is_empty() { fallback } else { &self.log } } } /// Run one command in the container, streaming every line it prints to the pane /// as `container-progress` and keeping the tail for the result. /// /// Uses `create_attached_exec_as` — the single attached-exec path — rather than /// `exec_oneshot`, because these commands run for minutes and the point is that /// the user can watch them. async fn run_step( app: &AppHandle, project_id: &str, container_id: &str, user: &str, workdir: &str, cmd: Vec, limit: Duration, ) -> Result { let AttachedExec { exec_id, mut output, input, } = create_attached_exec_as(container_id, cmd, false, user, workdir).await?; // Nothing is ever written to this exec. Closing stdin means anything that // would prompt sees EOF and gives up, instead of waiting for a person who // isn't there. drop(input); let mut tail: VecDeque = VecDeque::with_capacity(LOG_LINES); let mut partial = String::new(); let pump = async { while let Some(msg) = output.next().await { let data = msg.map_err(|e| format!("Lost the container's output: {}", e))?; let chunk = String::from_utf8_lossy(&data.into_bytes()).into_owned(); // npm and Playwright both redraw with `\r`; treating that as a line // break is what turns a progress bar into progress. for piece in chunk.split_inclusive(|c| c == '\n' || c == '\r') { partial.push_str(piece); if piece.ends_with('\n') || piece.ends_with('\r') || partial.len() >= MAX_PARTIAL { let line = std::mem::take(&mut partial); record(&mut tail, line.trim(), app, project_id); } } } Ok::<(), String>(()) }; let outcome = tokio::time::timeout(limit, pump).await; if !partial.trim().is_empty() { let line = std::mem::take(&mut partial); record(&mut tail, line.trim(), app, project_id); } let log = tail.iter().cloned().collect::>().join("\n"); match outcome { // The captured tail is deliberately part of the timeout message: a step // that ran for 45 minutes and stopped has its reason in its last lines. Err(_) => Err(format!( "The command didn't finish within {} minutes and was abandoned.\n\nLast output:\n{}", limit.as_secs() / 60, if log.trim().is_empty() { "none" } else { &log } )), Ok(Err(e)) => Err(e), Ok(Ok(())) => Ok(StepResult { exit_code: wait_for_exec_exit(&exec_id).await.unwrap_or(0), log, }), } } /// Keep a line for the result and show it in the pane. fn record(tail: &mut VecDeque, line: &str, app: &AppHandle, project_id: &str) { if line.is_empty() { return; } if tail.len() == LOG_LINES { tail.pop_front(); } tail.push_back(line.to_string()); emit_progress(app, project_id, &truncate(line, 160)); } /// Append a further command's output to the accumulated log. fn push_section(log: &mut String, section: &str) { if section.trim().is_empty() { return; } if !log.is_empty() { log.push_str("\n\n"); } log.push_str(section.trim()); } /// Combine warnings so a second one never silently replaces the first. fn merge(existing: Option, next: String) -> Option { Some(match existing { Some(prev) => format!("{}\n\n{}", prev, next), None => next, }) } /// Progress is a single line in the UI, so an over-long one is cut here rather /// than allowed to reflow the layout. Cuts on a char boundary. fn truncate(line: &str, max: usize) -> String { if line.chars().count() <= max { return line.to_string(); } let mut s: String = line.chars().take(max.saturating_sub(1)).collect(); s.push('…'); s } #[cfg(test)] mod tests { use super::*; #[test] fn the_package_set_is_the_minimum_that_satisfies_the_probe() { // The viewer package is not optional, and `@playwright/mcp` is not a // member: it can bind sessions, it can never serve the UI. assert!(VIEWER_PACKAGE.starts_with("@playwright/cli@")); assert!(PLAYWRIGHT_FALLBACK.starts_with("playwright@")); assert!(!VIEWER_PACKAGE.contains("@playwright/mcp")); } #[test] fn playwright_is_not_installed_at_latest_alongside_the_viewer() { // `@latest` for both is exactly what splits the tree into two // `playwright-core`s wanting different browser revisions — the viewer // green, every `require("playwright")` dead. The version comes from the // viewer's own manifest instead; `@latest` is only the fallback for an // unreadable one. assert!(!VIEWER_PACKAGE.contains("playwright@latest")); assert_eq!(PLAYWRIGHT_FALLBACK, "playwright@latest"); } #[test] fn packages_are_installed_where_no_sudo_and_no_bind_mount_are_involved() { // Bind mounts live at /workspace/; /workspace itself does // not belong to the user's repository, and does belong to `claude`. assert_eq!(INSTALL_DIR, "/workspace"); } #[test] fn both_browser_targets_are_offered_and_named_for_who_needs_them() { assert_eq!(BrowserTarget::parse("chromium").unwrap(), BrowserTarget::Chromium); assert_eq!(BrowserTarget::parse("chrome").unwrap(), BrowserTarget::Chrome); // `@playwright/mcp` asks for the chrome channel specifically, so the UI // must be able to say so. assert!(BrowserTarget::Chrome.needed_for().contains("@playwright/mcp")); assert_eq!(BrowserTarget::Chrome.channel(), Some("chrome")); assert_eq!(BrowserTarget::Chromium.channel(), None); // And a size, before the click, for both. for t in [BrowserTarget::Chromium, BrowserTarget::Chrome] { assert!(t.download_note().to_lowercase().contains("mb"), "{:?}", t); } } #[test] fn an_unknown_browser_is_refused_with_the_two_that_work() { let err = BrowserTarget::parse("firefox").unwrap_err(); assert!(err.contains("chromium"), "{}", err); assert!(err.contains("chrome"), "{}", err); } #[test] fn baked_in_libraries_are_detected_and_the_apt_step_is_skipped() { // What a container built from a current base image says. The whole // point of baking them: this must not re-run apt. assert_eq!( classify_library_check("All system dependencies are installed.\n", 0), LibraryState::Present ); } #[test] fn an_older_image_without_them_is_detected_and_named() { let v = classify_library_check( "Missing system dependencies (12):\n libnss3\n libgbm1\n", 1, ); match v { LibraryState::Missing(d) => assert!(d.contains("(12)"), "{}", d), other => panic!("expected Missing, got {:?}", other), } } #[test] fn an_unrecognised_platform_installs_rather_than_reporting_success() { // Playwright prints this and exits **0** having installed nothing, so a // check that trusted the exit code would skip the apt step on exactly // the container that needs it. let v = classify_library_check( "Cannot install dependencies for ubuntu24.04-riscv64 with Playwright 1.62.1!\n", 0, ); assert!(matches!(v, LibraryState::Unknown(_)), "{:?}", v); } #[test] fn a_check_that_could_not_run_installs_rather_than_guessing() { // e.g. apt cannot resolve a package because the image's package index // was cleaned and the package is genuinely absent. let v = classify_library_check("E: Unable to locate package libgbm1\n", 100); match v { LibraryState::Unknown(d) => assert!(d.contains("100"), "{}", d), other => panic!("expected Unknown, got {:?}", other), } // Present contributes nothing to the progress line; the other two must // explain themselves, because the reason is shown to the user. assert_eq!(LibraryState::Present.detail(), ""); assert!(!LibraryState::Unknown("why".into()).detail().is_empty()); } #[test] fn a_successful_launch_and_page_load_is_reported_as_working() { let v = parse_launch_output(concat!( "some chromium noise\n__TRIPLE_C_BROWSER_LAUNCH__", r#"{"ok":true,"detail":"140.0.1","nav":{"ok":true,"cert":false,"detail":""}}"#, "\n", )); assert!(v.ok); assert!(v.cert_error.is_none()); assert!(v.detail.contains("140.0.1"), "{}", v.detail); assert!(v.detail.contains("loaded a page"), "{}", v.detail); } #[test] fn a_missing_shared_library_is_reported_verbatim() { // The exact failure the base image produces. It must reach the user as // itself, not as "installation failed". let v = parse_launch_output(concat!( "__TRIPLE_C_BROWSER_LAUNCH__", r#"{"ok":false,"detail":"Host system is missing dependencies: libnss3.so"}"#, "\n", )); assert!(!v.ok); assert!(v.detail.contains("libnss3.so"), "{}", v.detail); } #[test] fn a_tls_intercepting_proxy_is_distinguished_from_a_broken_install() { // The browser is fine; the container doesn't trust the network's CA. // Reporting this as a launch failure sends the user to fix Playwright. let v = parse_launch_output(concat!( "__TRIPLE_C_BROWSER_LAUNCH__", r#"{"ok":true,"detail":"140.0.1","nav":{"ok":false,"cert":true,"#, r#""detail":"page.goto: net::ERR_CERT_AUTHORITY_INVALID at https://registry.npmjs.org/"}}"#, "\n", )); assert!(v.ok, "the browser did launch"); assert_eq!( v.cert_error.as_deref().map(|s| s.contains("ERR_CERT_AUTHORITY_INVALID")), Some(true) ); } #[test] fn an_offline_container_is_not_reported_as_a_certificate_problem() { let v = parse_launch_output(concat!( "__TRIPLE_C_BROWSER_LAUNCH__", r#"{"ok":true,"detail":"140.0.1","nav":{"ok":false,"cert":false,"#, r#""detail":"net::ERR_NAME_NOT_RESOLVED"}}"#, "\n", )); assert!(v.ok); assert!(v.cert_error.is_none()); assert!(v.detail.contains("ERR_NAME_NOT_RESOLVED"), "{}", v.detail); } #[test] fn an_unmarked_stream_surfaces_the_containers_own_error() { let v = parse_launch_output("node: command not found\n"); assert!(!v.ok); assert!(v.detail.contains("command not found"), "{}", v.detail); } #[test] fn an_empty_stream_is_explained_rather_than_parsed() { let v = parse_launch_output(" \n"); assert!(!v.ok); assert!(v.detail.contains("no output"), "{}", v.detail); } #[test] fn the_launch_probe_is_a_single_argv_element() { assert!(!LAUNCH_PROBE.contains('\n')); assert!(LAUNCH_PROBE.contains(LAUNCH_MARKER)); // Paths and channels are read from the environment, never interpolated. assert!(LAUNCH_PROBE.contains("process.env.TRIPLE_C_PW_DIR")); assert!(LAUNCH_PROBE.contains("process.env.TRIPLE_C_PW_CHANNEL")); assert!(LAUNCH_PROBE.contains("ERR_CERT")); } #[test] fn warnings_accumulate_rather_than_overwrite() { let w = merge(Some("first".to_string()), "second".to_string()).unwrap(); assert!(w.contains("first") && w.contains("second"), "{}", w); assert_eq!(merge(None, "only".to_string()).as_deref(), Some("only")); } #[test] fn long_progress_lines_are_cut_on_a_char_boundary() { let line = "é".repeat(400); let cut = truncate(&line, 160); assert_eq!(cut.chars().count(), 160); assert!(cut.ends_with('…')); assert_eq!(truncate("short", 160), "short"); } }