diff --git a/CLAUDE.md b/CLAUDE.md index 1c3dea7..ddec523 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -104,6 +104,25 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li OAuth listener, wrong for remote control of a browser. Host ports are confined to `47820..=47827` because CSP `frame-src` cannot express a port range and must enumerate them; a unit test asserts the Rust range matches `tauri.conf.json`. Opt-in per project. + - **Detection has to look past `node_modules`.** `claude mcp add … npx @playwright/mcp@latest` + installs into `~/.npm/_npx//node_modules`, not any `node_modules`, so `detect.rs` + globs that cache as well as `/workspace`, `$HOME/node_modules` and `npm root -g`. It also + hops from a wrapper `playwright` to its **nested** `playwright-core`: verified that npm does + not hoist for global installs, and the wrapper ships no `types/types.d.ts`, so reading the + wrapper alone reports a current build as "predates `browser.bind()`". + - **`@playwright/mcp` can never satisfy this pane.** It bundles a `playwright-core` that binds, + but never `@playwright/cli`, which is the viewer. Never offer it as a setup route — only as + what binds sessions automatically once Playwright is present. + - **`install.rs` installs into `/workspace`, as `claude`, with `--no-save`.** `/workspace` is + *not* a bind mount — project directories are mounted at `/workspace/{mount_name}` — so this + touches nothing of the user's, needs no sudo (npm's prefix is `/usr`, which is root-owned), + and is on the module resolution path for scripts in the project. Browsers go to + `~/.cache/ms-playwright` as `claude`, i.e. the home volume. + - **The base image ships none of Chromium's shared libraries.** `playwright install chromium` + therefore downloads a browser that cannot launch, which is why installing Chrome via apt + looks like a fix. The install action runs `install-deps` as root first and then *actually + launches* the browser to verify. `@playwright/mcp` wants the `chrome` **channel** + specifically, so both browsers are offered. - **`docker/`** — Docker API layer using bollard: - `client.rs` — Singleton Docker connection via `OnceLock` - `container.rs` — Container lifecycle (create, start, stop, remove, inspect) diff --git a/app/src-tauri/src/browser_view/commands.rs b/app/src-tauri/src/browser_view/commands.rs index b9f5efb..293b0f8 100644 --- a/app/src-tauri/src/browser_view/commands.rs +++ b/app/src-tauri/src/browser_view/commands.rs @@ -4,6 +4,7 @@ use tauri::{AppHandle, State}; +use crate::browser_view::install::{self, BrowserSetupOutcome}; use crate::browser_view::{manager, BrowserViewStatus}; use crate::AppState; @@ -27,20 +28,7 @@ pub async fn set_browser_view_enabled( return Ok(manager().status(&project_id).await); } - let project = state - .projects_store - .get(&project_id) - .ok_or_else(|| format!("Project {} not found", project_id))?; - - let Some(container_id) = project.container_id.clone() else { - return Err("Start the container before opening the browser view.".to_string()); - }; - if !crate::docker::container::is_container_running(&container_id) - .await - .unwrap_or(false) - { - return Err("Start the container before opening the browser view.".to_string()); - } + let container_id = running_container(&state, &project_id, "opening the browser view").await?; manager() .start( @@ -61,18 +49,84 @@ pub async fn get_browser_view_status(project_id: String) -> Result, ) -> Result { - let project = state - .projects_store - .get(&project_id) - .ok_or_else(|| format!("Project {} not found", project_id))?; - let container_id = project - .container_id - .ok_or_else(|| "Start the container to check for Playwright.".to_string())?; + let container_id = running_container(&state, &project_id, "checking for Playwright").await?; crate::browser_view::detect::detect(&container_id).await } + +/// Install `playwright` and `@playwright/cli` into the container. +/// +/// **This mutates the container**, so it is a command of its own and is only +/// ever reached by the user pressing the button — nothing here runs on tab +/// open. Progress streams on `container-progress`; the outcome carries a fresh +/// probe so the pane updates itself. +/// +/// Browsers are *not* fetched here. They are hundreds of megabytes and get +/// their own action, with the size stated before the click. +#[tauri::command] +pub async fn install_browser_view_support( + project_id: String, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result { + let container_id = running_container(&state, &project_id, "installing Playwright").await?; + install::install_packages(&app_handle, &project_id, &container_id).await +} + +/// Install a browser — `chromium` (Playwright's own build, for scripts that +/// call `chromium.launch()`) or `chrome` (the Google Chrome channel that +/// `@playwright/mcp` asks for) — along with the system libraries it needs, and +/// verify that it actually starts. +/// +/// Also a mutation, also user-initiated only. +#[tauri::command] +pub async fn install_browser_view_browser( + project_id: String, + browser: String, + app_handle: AppHandle, + state: State<'_, AppState>, +) -> Result { + let target = install::BrowserTarget::parse(&browser)?; + let container_id = running_container(&state, &project_id, "installing a browser").await?; + install::install_browser(&app_handle, &project_id, &container_id, target).await +} + +/// The project's container, or a sentence saying why there isn't one. +/// +/// Every command here needs a *running* container, and every one of them used +/// to be able to fail somewhere further in with a Docker error instead. The +/// `action` is folded into the message so "start the container first" arrives +/// attached to what the user was trying to do. +async fn running_container( + state: &State<'_, AppState>, + project_id: &str, + action: &str, +) -> Result { + let project = state + .projects_store + .get(project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + let Some(container_id) = project.container_id.clone() else { + return Err(format!( + "This project has no container yet. Start it before {}.", + action + )); + }; + if !crate::docker::container::is_container_running(&container_id) + .await + .unwrap_or(false) + { + return Err(format!( + "The container for “{}” isn't running. Start it before {}.", + project.name, action + )); + } + Ok(container_id) +} diff --git a/app/src-tauri/src/browser_view/detect.rs b/app/src-tauri/src/browser_view/detect.rs index 154f345..280343d 100644 --- a/app/src-tauri/src/browser_view/detect.rs +++ b/app/src-tauri/src/browser_view/detect.rs @@ -17,6 +17,22 @@ //! 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. +//! +//! ## Where a Playwright can legitimately be +//! +//! `node_modules` is not the only answer, and assuming it was is what made this +//! probe lie. `claude mcp add … npx @playwright/mcp@latest` — the way most +//! people end up with Playwright in the container — installs nothing into any +//! `node_modules`: npx unpacks the tree into `~/.npm/_npx//node_modules` +//! and runs it from there. So that cache is searched too, every entry of it, +//! and [`PlaywrightDetection::searched`] echoes back every root actually +//! consulted so a "not found" is checkable rather than merely asserted. +//! +//! Note what that npx route can and cannot do: `@playwright/mcp` bundles a +//! `playwright-core` new enough to `bind()`, so it can satisfy points 1 and 2 — +//! but it never ships `@playwright/cli`, so it can never satisfy point 3 on its +//! own. Any message that offers it as a way to *set up* this pane is sending +//! the user down a dead end; see [`PlaywrightDetection::blocker`]. use serde::{Deserialize, Serialize}; @@ -39,6 +55,14 @@ pub struct PlaywrightDetection { /// Absolute path of the resolved package manifest, for the diagnostics line. #[serde(default)] pub playwright_path: Option, + /// Absolute path of the resolved Playwright's own CLI entry (`cli.js`). + /// + /// Both `playwright` and `playwright-core` declare one, and it is the thing + /// that installs browsers and their system libraries. Driving *that* file + /// with `node` — rather than whatever `playwright` happens to be on `PATH` — + /// is what keeps the browser install pinned to the copy this pane found. + #[serde(default)] + pub playwright_cli: Option, /// Whether the resolved build's type definitions declare `Browser.bind()`. #[serde(default)] pub has_bind: bool, @@ -50,6 +74,25 @@ pub struct PlaywrightDetection { /// we can signal. #[serde(default)] pub cli_entry: Option, + /// Browser bundles present in the Playwright browser cache + /// (`~/.cache/ms-playwright`), e.g. `chromium-1200`. `ffmpeg-*` is excluded + /// — it is not a browser and its presence must not read as one. + /// + /// Not part of [`PlaywrightDetection::is_usable`]: the viewer serves + /// whatever has been published to it, and a browser could in principle be + /// remote. It is here because "installed but no browser to drive" is a real + /// state the pane has to be able to say out loud. + #[serde(default)] + pub browsers: Vec, + /// Path to Google Chrome, if the `chrome` *channel* is installed. + /// + /// Separate from [`Self::browsers`] because it is not in Playwright's cache + /// at all — the channel is an apt package. It is tracked because + /// `@playwright/mcp` asks for `channel: 'chrome'` specifically, so a + /// container with the bundled Chromium and no Chrome is set up for the + /// user's own scripts and not for the MCP plugin. + #[serde(default)] + pub chrome_channel: Option, /// Where the probe looked, echoed back for the "not found" message. #[serde(default)] pub searched: Vec, @@ -63,6 +106,13 @@ impl PlaywrightDetection { /// A specific, actionable explanation of what is missing. `None` when the /// container is ready. + /// + /// Every branch names the *package* that is missing and points at this + /// pane's install action, because assembling npm commands by hand is the + /// thing that went wrong for real users. `@playwright/mcp` is named only in + /// the role it actually plays — it binds sessions automatically once + /// Playwright is present — and never as a route through setup, because it + /// does not ship `@playwright/cli` and so can never make the viewer work. pub fn blocker(&self) -> Option { if self.node_version.is_none() { return Some( @@ -72,41 +122,66 @@ impl PlaywrightDetection { } 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(", ") - } + "Playwright isn't installed in this container. Two packages are needed: \ + `playwright` (for the `browser.bind()` live-dashboard API) and \ + `@playwright/cli` (the viewer UI this pane embeds). Use “Set up Playwright” \ + below to install both into the container. Installing `@playwright/mcp` on \ + its own is not enough — it binds sessions for you once Playwright is there, \ + but it never provides the viewer. Looked in: {}.", + self.searched_text() )); } 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("?") + "Playwright {} is installed{}, but it predates the live-dashboard API \ + (`browser.bind()`). Use “Set up Playwright” below to upgrade to the latest \ + `playwright`, then restart the browser Claude is driving.", + self.playwright_version.as_deref().unwrap_or("?"), + match self.playwright_path.as_deref() { + Some(p) => format!(" at {}", p), + None => String::new(), + } )); } 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(), - ); + return Some(format!( + "Playwright {} is installed, but `@playwright/cli` — the package that serves \ + the viewer UI — isn't, and nothing else provides it (`@playwright/mcp` does \ + not). Use “Set up Playwright” below to install it. Looked in: {}.", + self.playwright_version.as_deref().unwrap_or("?"), + self.searched_text() + )); } None } + + /// Whether Playwright is present but has no browser at all to drive — + /// neither a downloaded bundle nor the Chrome channel. Advisory: the viewer + /// still runs, it just has nothing to show until a browser is bound. + pub fn needs_browser(&self) -> bool { + self.playwright_version.is_some() + && self.browsers.is_empty() + && self.chrome_channel.is_none() + } + + /// The searched roots as prose, so a message never trails off into "Looked + /// in: ." when the probe couldn't build a root list at all. + fn searched_text(&self) -> String { + if self.searched.is_empty() { + "the container's default module paths".to_string() + } else { + self.searched.join(", ") + } + } } /// 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`. +/// script finds the global `node_modules` root and the npx cache itself, so a +/// Playwright installed with `npm i -g`, or merely *run* once through +/// `npx @playwright/mcp`, is found as readily as one in +/// `/workspace/node_modules`. pub async fn detect(container_id: &str) -> Result { let output = exec_oneshot( container_id, @@ -151,15 +226,44 @@ pub(crate) fn parse_probe_output(output: &str) -> Result` is a separate tree — `@playwright/mcp` and any other + // npx-run package each get their own — so all of them are searched, in a + // stable order, and all of them are reported in `searched`. + r#"const npx=[];if(cache){try{for(const d of fs.readdirSync(path.join(cache,"_npx")).sort()){"#, + r#"const p=path.join(cache,"_npx",d,"node_modules");"#, + r#"try{if(fs.statSync(p).isDirectory())npx.push(p);}catch(e){}}}catch(e){}}"#, + r#"const roots=[...new Set(["/workspace",process.cwd(),home?path.join(home,"node_modules"):null,g,...npx].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){}"#, + r#"const at=(s,r)=>{try{return require.resolve(s,{paths:[r]});}catch(e){return null;}};"#, + r#"const res=(s)=>{for(const r of roots){const p=at(s,r);if(p)return p;}return null;};"#, + // One `bin` reader for both packages: `bin` is a string for some manifests + // and an object for others, and getting that wrong on either one loses the + // entry point silently. + r#"const bin=(m,j)=>{const b=typeof j.bin==="string"?{[j.name]:j.bin}:(j.bin||{});"#, + r#"const k=Object.keys(b)[0];return k?path.resolve(path.dirname(m),b[k]):null;};"#, + // `playwright-core` is what carries the typings and the browser registry, but + // it is frequently *nested*: verified against a real `npm i -g playwright + // @playwright/cli`, npm does not hoist for global installs, so the global + // root holds `playwright/` and `@playwright/cli/` and no top-level + // `playwright-core/`. Resolving only the outer `playwright` would then read + // a package that ships no `types/types.d.ts` at all and report a perfectly + // current build as "predates browser.bind()". So: hop from the wrapper to + // its own `playwright-core`, and only fall back to the wrapper's manifest. + r#"let core=res("playwright-core/package.json");"#, + r#"if(!core){const pw=res("playwright/package.json");"#, + r#"if(pw)core=at("playwright-core/package.json",path.dirname(pw))||pw;}"#, + r#"if(core){try{out.playwright_path=core;const j=JSON.parse(fs.readFileSync(core,"utf8"));"#, + r#"out.playwright_version=j.version;out.playwright_cli=bin(core,j);}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. @@ -167,8 +271,16 @@ const PROBE: &str = concat!( 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#"out.cli_entry=bin(cli,j);}catch(e){}}"#, + // Browser bundles. `ffmpeg-*` lives in the same directory and is filtered + // out: it is not something that can be driven, and counting it would let + // the pane claim a browser is present when none is. + r#"try{const bd=process.env.PLAYWRIGHT_BROWSERS_PATH||(home?path.join(home,".cache","ms-playwright"):null);"#, + r#"if(bd)out.browsers=fs.readdirSync(bd).filter((n)=>/^(chromium|firefox|webkit)/.test(n)).sort();}catch(e){}"#, + // The Chrome *channel* is an apt package, not a Playwright download, so it + // is looked for where apt puts it. + r#"try{for(const p of ["/usr/bin/google-chrome-stable","/usr/bin/google-chrome","/opt/google/chrome/chrome"]){"#, + r#"if(fs.existsSync(p)){out.chrome_channel=p;break;}}}catch(e){}"#, r#"process.stdout.write("\n__TRIPLE_C_BROWSER_VIEW__"+JSON.stringify(out)+"\n");"#, ); @@ -201,27 +313,158 @@ mod tests { } #[test] - fn a_missing_playwright_is_reported_with_where_we_looked() { + fn a_missing_playwright_names_both_packages_and_where_we_looked() { let d = parse_probe_output(&payload( - r#"{"node_version":"22.11.0","searched":["/workspace","/usr/lib/node_modules"]}"#, + r#"{"node_version":"22.11.0","searched":["/workspace","/usr/lib/node_modules","/home/claude/.npm/_npx/a1/node_modules"]}"#, )) .unwrap(); assert!(!d.is_usable()); let msg = d.blocker().unwrap(); - assert!(msg.contains("npm i -D playwright"), "{}", msg); + // The two packages that actually have to be there, by name. + assert!(msg.contains("`playwright`"), "{}", msg); + assert!(msg.contains("`@playwright/cli`"), "{}", msg); assert!(msg.contains("browser.bind"), "{}", msg); + // Every root consulted, including the npx cache, so the claim is checkable. assert!(msg.contains("/usr/lib/node_modules"), "{}", msg); + assert!(msg.contains("/home/claude/.npm/_npx/a1/node_modules"), "{}", msg); + } + + #[test] + fn no_message_offers_playwright_mcp_as_a_way_through_setup() { + // It bundles a playwright-core new enough to bind, but never ships the + // viewer — so proposing it as an install route is a dead end, which is + // exactly what a user hit. It may only be named for what it does do. + for json in [ + r#"{"node_version":"22.11.0","searched":["/workspace"]}"#, + r#"{"node_version":"22.11.0","playwright_version":"1.44.0","has_bind":false}"#, + r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true}"#, + ] { + let msg = parse_probe_output(&payload(json)).unwrap().blocker().unwrap(); + let offers_install = msg.contains("install `@playwright/mcp`") + || msg.contains("or use `@playwright/mcp`") + || msg.contains("npm i -D @playwright/mcp") + || msg.contains("npm i -g @playwright/mcp"); + assert!(!offers_install, "{}", msg); + // And every message points at the one action that does work. + assert!(msg.contains("Set up Playwright"), "{}", 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"}"#, + r#"{"node_version":"22.11.0","playwright_version":"1.44.0","playwright_path":"/workspace/node_modules/playwright/package.json","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); + assert!(msg.contains("/workspace/node_modules/playwright"), "{}", msg); + assert!(msg.contains("Set up Playwright"), "{}", msg); + } + + #[test] + fn an_npx_cached_playwright_counts_as_installed() { + // What `claude mcp add … npx @playwright/mcp@latest` leaves behind: a + // real playwright-core, in no `node_modules` the old probe looked at. + // It satisfies bind — and nothing else, because npx never brings the + // viewer with it. + let d = parse_probe_output(&payload( + concat!( + r#"{"node_version":"22.11.0","playwright_version":"1.62.1","#, + r#""playwright_path":"/home/claude/.npm/_npx/9f/node_modules/playwright-core/package.json","#, + r#""playwright_cli":"/home/claude/.npm/_npx/9f/node_modules/playwright-core/cli.js","#, + r#""has_bind":true,"#, + r#""searched":["/workspace","/usr/lib/node_modules","/home/claude/.npm/_npx/9f/node_modules"]}"#, + ), + )) + .unwrap(); + assert_eq!(d.playwright_version.as_deref(), Some("1.62.1")); + assert!(d.has_bind); + assert_eq!( + d.playwright_cli.as_deref(), + Some("/home/claude/.npm/_npx/9f/node_modules/playwright-core/cli.js") + ); + // Still not usable, and the message says why: the viewer is missing. + assert!(!d.is_usable()); + let msg = d.blocker().unwrap(); + assert!(msg.contains("@playwright/cli"), "{}", msg); + } + + #[test] + fn the_probe_searches_the_npx_cache_as_well_as_the_module_roots() { + // The roots are built inside the probe, so this is the only place the + // set can be asserted without a container. Each fragment is load-bearing: + // dropping any one of them is how an install becomes invisible. + assert!(PROBE.contains(r#""/workspace""#), "{}", PROBE); + assert!(PROBE.contains("process.cwd()"), "{}", PROBE); + assert!(PROBE.contains(r#"path.join(home,"node_modules")"#), "{}", PROBE); + assert!(PROBE.contains("npm root -g"), "{}", PROBE); + assert!(PROBE.contains(r#"path.join(cache,"_npx")"#), "{}", PROBE); + assert!(PROBE.contains("npm_config_cache"), "{}", PROBE); + // Every one of them, not just the first hit, and all of them reported. + assert!(PROBE.contains("...npx"), "{}", PROBE); + assert!(PROBE.contains("out.searched=roots"), "{}", PROBE); + } + + #[test] + fn a_partial_tree_still_answers_rather_than_failing() { + // Playwright resolved, but its manifest unreadable and no viewer: the + // probe's guards must still produce a parseable payload carrying what + // it did learn, because that is what the message is built from. + let d = parse_probe_output(&payload( + r#"{"node_version":"22.11.0","has_bind":false,"searched":["/workspace"],"browsers":["chromium-1200"]}"#, + )) + .unwrap(); + assert_eq!(d.node_version.as_deref(), Some("22.11.0")); + assert_eq!(d.browsers, vec!["chromium-1200".to_string()]); + assert!(d.blocker().is_some()); + } + + #[test] + fn a_playwright_with_no_browser_bundle_is_flagged_without_blocking() { + let d = parse_probe_output(&payload( + concat!( + r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#, + r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":[]}"#, + ), + )) + .unwrap(); + // Serving the viewer is possible; there is just nothing to drive yet. + assert!(d.is_usable()); + assert_eq!(d.blocker(), None); + assert!(d.needs_browser()); + + let with_browser = parse_probe_output(&payload( + concat!( + r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#, + r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1200"]}"#, + ), + )) + .unwrap(); + assert!(!with_browser.needs_browser()); + + // The Chrome channel counts too — it is an apt package rather than a + // Playwright download, so it never appears in `browsers`, and + // `@playwright/mcp` is the caller that asks for it. + let chrome_only = parse_probe_output(&payload( + concat!( + r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#, + r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":[],"#, + r#""chrome_channel":"/usr/bin/google-chrome-stable"}"#, + ), + )) + .unwrap(); + assert!(!chrome_only.needs_browser()); + assert_eq!( + chrome_only.chrome_channel.as_deref(), + Some("/usr/bin/google-chrome-stable") + ); + } + + #[test] + fn the_probe_looks_for_the_chrome_channel_where_apt_puts_it() { + assert!(PROBE.contains("google-chrome-stable"), "{}", PROBE); + assert!(PROBE.contains("/opt/google/chrome/chrome"), "{}", PROBE); } #[test] @@ -252,6 +495,19 @@ mod tests { assert!(err.contains("no output"), "{}", err); } + #[test] + fn the_probe_reads_bind_from_the_nested_core_of_a_wrapper_install() { + // `npm i -g playwright` leaves `playwright-core` under + // `playwright/node_modules`, and the wrapper ships no + // `types/types.d.ts` — so without this hop a current build reports + // `has_bind: false`. Verified against a real global install. + assert!( + PROBE.contains(r#"at("playwright-core/package.json",path.dirname(pw))"#), + "{}", + PROBE + ); + } + #[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 diff --git a/app/src-tauri/src/browser_view/install.rs b/app/src-tauri/src/browser_view/install.rs new file mode 100644 index 0000000..2e84947 --- /dev/null +++ b/app/src-tauri/src/browser_view/install.rs @@ -0,0 +1,857 @@ +//! 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 base image ships **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. +//! * 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 two packages the pane genuinely needs, pinned to `@latest` because +/// `browser.bind()` is recent and the viewer tracks it. +/// +/// This is the *minimum* set. A user who followed the old guidance ended up +/// with a global install as well as these; only these are required. Note what +/// is not here: `@playwright/mcp` is Claude's MCP configuration to make, not +/// this pane's, and it contributes nothing to serving a viewer. +pub const PACKAGES: [&str; 2] = ["playwright@latest", "@playwright/cli@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); +/// 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 plus the system libraries it needs — several \ + hundred MB in total, a few minutes on a normal connection" + } + 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 and @playwright/cli into {}/node_modules…", + INSTALL_DIR + ), + ); + + // `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 explicitly asked for. + let mut 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(), + ]; + cmd.extend(PACKAGES.iter().map(|p| p.to_string())); + + let step = run_step( + app, + project_id, + container_id, + "claude", + INSTALL_DIR, + cmd, + NPM_TIMEOUT, + ) + .await?; + if step.exit_code != 0 { + return Err(format!( + "npm couldn't install Playwright in this container (exit {}).\n\nnpm said:\n{}", + step.exit_code, + step.log_or("it produced no output at all") + )); + } + + 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(); + 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, + }) +} + +/// 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. + // + // This is what `playwright install --with-deps` does internally. Running + // `install-deps` directly *as root* is the same apt work without depending + // on 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 running `install-deps` first + // costs little and makes the two paths behave identically. + emit_progress( + app, + project_id, + "Step 1/3 — installing browser system libraries with apt (needs root; a minute or two)…", + ); + 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?; + log.push_str(&deps.log); + if deps.exit_code != 0 { + // Not fatal on its own — 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, + }) +} + +/// 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!(PACKAGES.iter().any(|p| p.starts_with("playwright@"))); + assert!(PACKAGES.iter().any(|p| p.starts_with("@playwright/cli@"))); + assert!(!PACKAGES.iter().any(|p| p.contains("@playwright/mcp"))); + assert_eq!(PACKAGES.len(), 2); + } + + #[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 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"); + } +} diff --git a/app/src-tauri/src/browser_view/mod.rs b/app/src-tauri/src/browser_view/mod.rs index b77c618..cde1369 100644 --- a/app/src-tauri/src/browser_view/mod.rs +++ b/app/src-tauri/src/browser_view/mod.rs @@ -63,6 +63,7 @@ pub mod commands; pub mod detect; +pub mod install; pub mod proxy; use std::collections::HashMap; diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index b5f65ed..281b360 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -426,6 +426,8 @@ pub fn run() { browser_view::commands::set_browser_view_enabled, browser_view::commands::get_browser_view_status, browser_view::commands::check_browser_view_support, + browser_view::commands::install_browser_view_support, + browser_view::commands::install_browser_view_browser, // Shared Claude Code auth token commands::auth_token_commands::acquire_claude_token, commands::auth_token_commands::submit_claude_token_code, diff --git a/app/src/components/projects/home/BrowserTab.test.tsx b/app/src/components/projects/home/BrowserTab.test.tsx index 0cd7221..6f63510 100644 --- a/app/src/components/projects/home/BrowserTab.test.tsx +++ b/app/src/components/projects/home/BrowserTab.test.tsx @@ -1,23 +1,41 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; import BrowserTab from "./BrowserTab"; -import type { BrowserViewStatus, Project } from "../../../lib/types"; +import type { + BrowserSetupOutcome, + BrowserViewStatus, + PlaywrightDetection, + Project, +} from "../../../lib/types"; const getBrowserViewStatus = vi.fn<() => Promise>(); const setBrowserViewEnabled = vi.fn<() => Promise>(); +const checkBrowserViewSupport = vi.fn<() => Promise>(); +const installBrowserViewSupport = vi.fn<() => Promise>(); +const installBrowserViewBrowser = vi.fn<(id: string, b: string) => Promise>(); const pushToast = vi.fn(); +const setContainerProgress = vi.fn(); vi.mock("../../../lib/tauri-commands", () => ({ getBrowserViewStatus: () => getBrowserViewStatus(), setBrowserViewEnabled: () => setBrowserViewEnabled(), + checkBrowserViewSupport: () => checkBrowserViewSupport(), + installBrowserViewSupport: () => installBrowserViewSupport(), + installBrowserViewBrowser: (id: string, b: string) => installBrowserViewBrowser(id, b), })); vi.mock("@tauri-apps/api/event", () => ({ listen: vi.fn(async () => () => {}), })); +const storeState = { + pushToast, + setContainerProgress, + containerProgress: {} as Record, +}; + vi.mock("../../../store/appState", () => ({ - useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }), + useAppState: (selector: (s: unknown) => unknown) => selector(storeState), })); const OFF: BrowserViewStatus = { @@ -31,6 +49,33 @@ const OFF: BrowserViewStatus = { message: null, }; +const NOTHING: PlaywrightDetection = { + node_version: "22.11.0", + playwright_version: null, + playwright_path: null, + playwright_cli: null, + has_bind: false, + cli_version: null, + cli_entry: null, + browsers: [], + chrome_channel: null, + searched: [ + "/workspace", + "/usr/lib/node_modules", + "/home/claude/.npm/_npx/9f3a/node_modules", + ], +}; + +const READY: PlaywrightDetection = { + ...NOTHING, + playwright_version: "1.62.1", + playwright_path: "/workspace/node_modules/playwright-core/package.json", + playwright_cli: "/workspace/node_modules/playwright-core/cli.js", + has_bind: true, + cli_version: "0.1.18", + cli_entry: "/workspace/node_modules/@playwright/cli/playwright-cli.js", +}; + const project: Project = { id: "p1", name: "api-server", @@ -63,7 +108,9 @@ const project: Project = { beforeEach(() => { vi.clearAllMocks(); + storeState.containerProgress = {}; getBrowserViewStatus.mockResolvedValue(OFF); + checkBrowserViewSupport.mockResolvedValue(READY); }); describe("BrowserTab", () => { @@ -72,17 +119,24 @@ describe("BrowserTab", () => { expect(await screen.findByText(/container isn’t running/i)).toBeInTheDocument(); expect(screen.queryByRole("button", { name: /start browser view/i })).toBeNull(); expect(getBrowserViewStatus).not.toHaveBeenCalled(); + expect(checkBrowserViewSupport).not.toHaveBeenCalled(); }); - it("starts off, and never starts a view without being asked", async () => { + it("starts off, and never starts a view or installs anything without being asked", async () => { + checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] }); render(); await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled()); expect(screen.getByText("Off")).toBeInTheDocument(); expect(screen.queryByTitle(/browser view for/i)).toBeNull(); expect(setBrowserViewEnabled).not.toHaveBeenCalled(); + // Probing is read-only and expected; installing is a mutation and is not. + await waitFor(() => expect(checkBrowserViewSupport).toHaveBeenCalled()); + expect(installBrowserViewSupport).not.toHaveBeenCalled(); + expect(installBrowserViewBrowser).not.toHaveBeenCalled(); }); it("shows the live pane, pointed at loopback with a token, once started", async () => { + checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] }); setBrowserViewEnabled.mockResolvedValue({ ...OFF, enabled: true, @@ -109,27 +163,121 @@ describe("BrowserTab", () => { expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument(); }); + it("offers setup before the user hits a wall, naming what is missing", async () => { + checkBrowserViewSupport.mockResolvedValue(NOTHING); + + render(); + + // No Start attempt was needed to learn this. + expect(await screen.findByRole("button", { name: /set up playwright/i })).toBeInTheDocument(); + expect(screen.getByText(/Missing: playwright, @playwright\/cli/)).toBeInTheDocument(); + // The npx cache is shown among the searched roots — that is where an + // MCP-installed Playwright actually lives. + expect(screen.getByText(/_npx\/9f3a\/node_modules/)).toBeInTheDocument(); + // A browser can't be installed before Playwright is. + expect(screen.getByRole("button", { name: /install chromium/i })).toBeDisabled(); + }); + + it("installs Playwright on request and updates itself from the fresh probe", async () => { + checkBrowserViewSupport.mockResolvedValue(NOTHING); + installBrowserViewSupport.mockResolvedValue({ + detection: READY, + log: "added 5 packages in 3s", + browser_launched: null, + warning: "Playwright is installed, but this container has no browser to drive yet.", + }); + + render(); + const button = await screen.findByRole("button", { name: /set up playwright/i }); + await act(async () => { + fireEvent.click(button); + }); + + await waitFor(() => expect(installBrowserViewSupport).toHaveBeenCalled()); + // The pane re-rendered from the returned probe — no reopening the tab. + expect(await screen.findByText("1.62.1")).toBeInTheDocument(); + // Stated in the warning box, and again in the pane's own summary line. + expect(screen.getAllByText(/no browser to drive yet/).length).toBeGreaterThan(0); + // And the browser buttons are now live. + expect(screen.getByRole("button", { name: /install chromium/i })).toBeEnabled(); + expect(screen.getByRole("button", { name: /install chrome channel/i })).toBeEnabled(); + // The progress line is always cleared, whatever happened. + expect(setContainerProgress).toHaveBeenCalledWith("p1", null); + }); + + it("says which browser is for which caller, and states the size first", async () => { + checkBrowserViewSupport.mockResolvedValue(READY); + render(); + + expect(await screen.findByText(/several hundred mb/i)).toBeInTheDocument(); + // The copy is broken across a element, so match the container. + expect( + screen.getByText((_, el) => + (el?.textContent ?? "").includes("@playwright/mcp") && + (el?.textContent ?? "").includes("asks for") && + el?.tagName.toLowerCase() === "li", + ), + ).toBeInTheDocument(); + expect(screen.getByText(/roughly 150 mb/i)).toBeInTheDocument(); + }); + + it("installs the chrome channel when that is the one asked for", async () => { + checkBrowserViewSupport.mockResolvedValue(READY); + installBrowserViewBrowser.mockResolvedValue({ + detection: { ...READY, chrome_channel: "/usr/bin/google-chrome-stable" }, + log: "Installing google-chrome-stable", + browser_launched: true, + warning: null, + }); + + render(); + const button = await screen.findByRole("button", { name: /install chrome channel/i }); + await act(async () => { + fireEvent.click(button); + }); + + await waitFor(() => + expect(installBrowserViewBrowser).toHaveBeenCalledWith("p1", "chrome"), + ); + // Shown as the step's "done" line and again in the diagnostics table. + await waitFor(() => + expect(screen.getAllByText(/google-chrome-stable/).length).toBeGreaterThan(0), + ); + }); + + it("reports an install failure with the real command output", async () => { + checkBrowserViewSupport.mockResolvedValue(NOTHING); + installBrowserViewSupport.mockRejectedValue( + "npm couldn't install Playwright in this container (exit 1).\n\nnpm said:\nEACCES: permission denied", + ); + + render(); + const button = await screen.findByRole("button", { name: /set up playwright/i }); + await act(async () => { + fireEvent.click(button); + }); + + expect(await screen.findByText(/EACCES: permission denied/)).toBeInTheDocument(); + expect(pushToast).toHaveBeenCalledWith( + expect.objectContaining({ kind: "error" }), + ); + expect(setContainerProgress).toHaveBeenCalledWith("p1", null); + }); + it("explains precisely what is missing instead of spinning", async () => { + checkBrowserViewSupport.mockRejectedValue("container busy"); getBrowserViewStatus.mockResolvedValue({ ...OFF, enabled: true, state: "unavailable", message: - "Playwright isn't installed in this container. Install it with `npm i -D playwright`.", - detection: { - node_version: "22.11.0", - playwright_version: null, - playwright_path: null, - has_bind: false, - cli_version: null, - cli_entry: null, - searched: ["/workspace", "/usr/lib/node_modules"], - }, + "Playwright isn't installed in this container. Two packages are needed: `playwright` and `@playwright/cli`.", + detection: NOTHING, }); render(); - expect(await screen.findByText(/npm i -D playwright/)).toBeInTheDocument(); + expect(await screen.findByText(/Two packages are needed/)).toBeInTheDocument(); expect(screen.getByText("Unavailable")).toBeInTheDocument(); // The probe's findings are shown, so the user can see why. expect(screen.getByText("22.11.0")).toBeInTheDocument(); @@ -139,6 +287,7 @@ describe("BrowserTab", () => { }); it("surfaces a start failure rather than leaving the pane blank", async () => { + checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] }); setBrowserViewEnabled.mockRejectedValue("container went away"); render(); @@ -155,6 +304,7 @@ describe("BrowserTab", () => { }); it("stops the view when asked", async () => { + checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] }); getBrowserViewStatus.mockResolvedValue({ ...OFF, enabled: true, diff --git a/app/src/components/projects/home/BrowserTab.tsx b/app/src/components/projects/home/BrowserTab.tsx index 8e1fde0..dac0d0a 100644 --- a/app/src/components/projects/home/BrowserTab.tsx +++ b/app/src/components/projects/home/BrowserTab.tsx @@ -1,15 +1,22 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { listen } from "@tauri-apps/api/event"; import type { + BrowserInstallTarget, + BrowserSetupOutcome, BrowserViewChangedEvent, BrowserViewStatus, + PlaywrightDetection, Project, } from "../../../lib/types"; import { + checkBrowserViewSupport, getBrowserViewStatus, + installBrowserViewBrowser, + installBrowserViewSupport, setBrowserViewEnabled, } from "../../../lib/tauri-commands"; import { useAppState } from "../../../store/appState"; +import AccordionSection from "../../ui/AccordionSection"; import Button from "../../ui/Button"; import StatusIndicator from "../../ui/StatusIndicator"; @@ -29,6 +36,9 @@ const OFF: BrowserViewStatus = { message: null, }; +/** Which install is in flight. `null` means none — nothing installs itself. */ +type SetupJob = null | "packages" | BrowserInstallTarget; + /** * Watch — and take over — the browser Claude is driving with Playwright inside * the container. @@ -38,6 +48,12 @@ const OFF: BrowserViewStatus = { * loopback. Nothing starts until the user asks: this is remote control of a * browser in a privileged sandbox, so it is off by default and opted into per * project, exactly like the auth bridge. + * + * The same rule, harder, applies to setup. Opening this tab *probes* the + * container (one `node -e`, read-only) so the pane can say what is missing + * before the user asks for a view — but it never installs anything. Installing + * packages and downloading a browser are container mutations measured in + * hundreds of megabytes; both are separate, labelled, user-pressed buttons. */ export default function BrowserTab({ project, active }: Props) { const [status, setStatus] = useState(OFF); @@ -45,7 +61,14 @@ export default function BrowserTab({ project, active }: Props) { const [error, setError] = useState(null); /** Bumped to force the iframe to reload without changing its src. */ const [reloadKey, setReloadKey] = useState(0); + /** Last read-only probe of the container, for the setup panel. */ + const [detection, setDetection] = useState(null); + const [job, setJob] = useState(null); + const [outcome, setOutcome] = useState(null); + const [setupError, setSetupError] = useState(null); const pushToast = useAppState((s) => s.pushToast); + const setContainerProgress = useAppState((s) => s.setContainerProgress); + const progress = useAppState((s) => s.containerProgress[project.id]); const running = project.status === "running"; // The backend is the source of truth: it emits whenever a view starts or is @@ -77,6 +100,11 @@ export default function BrowserTab({ project, active }: Props) { getBrowserViewStatus(projectId) .then((s) => mounted.current && setStatus(s)) .catch(() => {}); + // Read-only. This is what lets the pane offer setup before the user hits a + // wall, and it is why a "not installed" answer is never stale. + checkBrowserViewSupport(projectId) + .then((d) => mounted.current && setDetection(d)) + .catch(() => {}); }, [active, projectId, running]); const toggle = useCallback( @@ -101,8 +129,52 @@ export default function BrowserTab({ project, active }: Props) { [projectId, pushToast], ); - // A stopped container can't be hosting a browser, so say that plainly rather - // than offering a control that would only fail. + /** Run one install. Every path clears the progress line it started. */ + const install = useCallback( + async (which: Exclude) => { + setJob(which); + setSetupError(null); + setOutcome(null); + try { + const result = + which === "packages" + ? await installBrowserViewSupport(projectId) + : await installBrowserViewBrowser(projectId, which); + if (!mounted.current) return; + // The command re-probes, so the pane updates itself — no reopening the + // tab, no second button to press. + setDetection(result.detection); + setOutcome(result); + if (result.warning) { + // Not an error — the step did what it said — but the caveat is the + // part that decides whether the browser will actually work. + pushToast({ + kind: "info", + message: "Setup finished, with something to know", + detail: result.warning, + }); + } else { + pushToast({ + kind: "success", + message: + which === "packages" ? "Playwright installed" : `${which} installed and verified`, + }); + } + } catch (e) { + const detail = String(e); + if (mounted.current) setSetupError(detail); + pushToast({ kind: "error", message: "Setup failed", detail }); + } finally { + setContainerProgress(projectId, null); + if (mounted.current) setJob(null); + } + }, + [projectId, pushToast, setContainerProgress], + ); + + // A stopped container can't be hosting a browser — and can't be installed + // into either, so say that plainly rather than offering controls that would + // only fail. if (!running) { return ( @@ -113,6 +185,16 @@ export default function BrowserTab({ project, active }: Props) { } const live = status.state === "running" && status.url; + // Prefer the probe: it is the fresher of the two, and it is the one that + // reflects an install that just finished. + const probed = detection ?? status.detection; + const ready = isUsable(probed); + // Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an + // apt package, so it never shows up in `browsers`, and a container that has + // it is not missing a browser. + const needsBrowser = + probed !== null && probed.browsers.length === 0 && probed.chrome_channel === null; + const needsSetup = probed !== null && (!ready || needsBrowser); return (
@@ -151,7 +233,7 @@ export default function BrowserTab({ project, active }: Props) { + } + /> + + + Both install the system libraries first — the base image ships none of them, + which is why a browser can download successfully and then refuse to start — + and both end by actually launching the browser to prove it works. Browsers + land in ~/.cache/ms-playwright, which is on the home volume, so + they survive container recreation and are only lost on a project Reset. + + } + done={browsers.length > 0 || chrome !== null} + doneLabel={[ + browsers.length > 0 ? browsers.join(", ") : null, + chrome ? `Chrome channel (${chrome})` : null, + ] + .filter(Boolean) + .join(" · ")} + action={ +
+ + +
+ } + > +
    +
  • + Chromium — Playwright’s + own build, used by chromium.launch() with no channel. Several + hundred MB. +
  • +
  • + Chrome channel — Google + Chrome from apt, which is what @playwright/mcp asks for. Install + this one if Claude drives the browser through the MCP plugin. Roughly 150 MB. +
  • +
+
+ + {busy && ( +

+ {progress ?? "Working…"} +

+ )} + + {error && ( +
+

That didn’t work.

+
+            {error}
+          
+
+ )} + + {outcome?.warning && ( +
+

Worth knowing

+

+ {outcome.warning} +

+
+ )} + + {outcome?.log && ( + +
+            {outcome.log}
+          
+
+ )} + + {detection && ( +
+ + + + + + 0 ? browsers.join(", ") : null} + /> + + {detection.searched.length > 0 && ( + )}
)} @@ -216,6 +497,44 @@ function Unavailable({ status }: { status: BrowserViewStatus }) { ); } +/** One numbered setup step: what it does, whether it is done, and its button. */ +function Step({ + title, + detail, + done, + doneLabel, + action, + children, +}: { + title: string; + detail: React.ReactNode; + done: boolean; + doneLabel?: string; + action: React.ReactNode; + children?: React.ReactNode; +}) { + return ( +
+
+
+
+

{title}

+ +
+

{detail}

+ {done && doneLabel && ( +

+ {doneLabel} +

+ )} + {children} +
+
{action}
+
+
+ ); +} + function Detail({ label, value }: { label: string; value: string | null }) { return ( <> diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 8e69678..89de993 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -1,5 +1,5 @@ import { invoke } from "@tauri-apps/api/core"; -import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome } from "./types"; +import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome } from "./types"; // Docker export const checkDocker = () => invoke("check_docker"); @@ -178,6 +178,24 @@ export const getBrowserViewStatus = (projectId: string) => /** Probe for Playwright without starting anything — used to re-check after installing it. */ export const checkBrowserViewSupport = (projectId: string) => invoke("check_browser_view_support", { projectId }); +/** + * Install `playwright` + `@playwright/cli` into the container's `/workspace`. + * + * A container mutation, so it only ever runs from an explicit click. Progress + * streams on the existing `container-progress` event; the result carries a + * fresh probe. Browsers are a separate action — see below. + */ +export const installBrowserViewSupport = (projectId: string) => + invoke("install_browser_view_support", { projectId }); +/** + * Install a browser and the apt libraries it needs, then verify it launches. + * `chromium` is Playwright's own build; `chrome` is the channel + * `@playwright/mcp` asks for. Hundreds of MB — never call this implicitly. + */ +export const installBrowserViewBrowser = ( + projectId: string, + browser: BrowserInstallTarget, +) => invoke("install_browser_view_browser", { projectId, browser }); // Shared Claude Code auth token — one `claude setup-token` run authenticates // every Anthropic-backend project. The token itself is never exposed here: it diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 427f10c..a560cea 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -434,14 +434,40 @@ export interface PlaywrightDetection { node_version: string | null; playwright_version: string | null; playwright_path: string | null; + /** Playwright's own `cli.js`, which installs browsers and their apt libraries. */ + playwright_cli: string | null; /** Whether the resolved Playwright declares the `browser.bind()` live-dashboard API. */ has_bind: boolean; cli_version: string | null; cli_entry: string | null; - /** Module roots the probe searched, echoed back for the "not found" message. */ + /** Browser bundles in `~/.cache/ms-playwright`, e.g. `chromium-1200`. Never `ffmpeg-*`. */ + browsers: string[]; + /** Path to Google Chrome when the `chrome` channel — what `@playwright/mcp` + * asks for — is installed. It is an apt package, so it is never in `browsers`. */ + chrome_channel: string | null; + /** Module roots the probe searched, echoed back for the "not found" message. + * Includes the npx cache (`~/.npm/_npx/*​/node_modules`), which is where a + * Playwright installed through Claude Code's MCP setup actually lives. */ searched: string[]; } +/** Result of an install action. Mirrors Rust `BrowserSetupOutcome`. */ +export interface BrowserSetupOutcome { + /** Fresh probe taken after the install, so the pane can update itself. */ + detection: PlaywrightDetection; + /** Tail of the real npm/apt/Playwright output — shown instead of a generic message. */ + log: string; + /** Whether a browser was actually started and closed. `null` when the step + * didn't try (the package step doesn't). */ + browser_launched: boolean | null; + /** Something that didn't fail the action but the user still needs to know. */ + warning: string | null; +} + +/** Browsers the pane can install. `chromium` is Playwright's own build; + * `chrome` is the Google Chrome channel `@playwright/mcp` asks for. */ +export type BrowserInstallTarget = "chromium" | "chrome"; + /** Mirrors Rust `BrowserViewState` (serde snake_case). */ export type BrowserViewState = "off" | "running" | "unavailable";