diff --git a/app/src-tauri/src/browser_view/detect.rs b/app/src-tauri/src/browser_view/detect.rs index 280343d..241eac9 100644 --- a/app/src-tauri/src/browser_view/detect.rs +++ b/app/src-tauri/src/browser_view/detect.rs @@ -93,6 +93,32 @@ pub struct PlaywrightDetection { /// user's own scripts and not for the MCP plugin. #[serde(default)] pub chrome_channel: Option, + /// The Chromium binary the *resolved* Playwright would launch, asked of the + /// build itself rather than derived from the cache listing. + #[serde(default)] + pub chromium_executable: Option, + /// Whether that binary is actually on disk. + /// + /// False with a non-empty [`Self::browsers`] is the revision-skew case: two + /// Playwright copies in one container pin different revisions, so the cache + /// can be full of browsers and every launch still fail. + #[serde(default)] + pub chromium_executable_exists: bool, + /// The version a *script's* `require("playwright")` resolves to. + /// + /// Tracked separately from [`Self::playwright_version`] because they are + /// routinely different in one directory: `@playwright/cli` pins its own + /// `playwright-core`, npm hoists that, and a separately-installed + /// `playwright` then nests a second core beside it. The viewer uses one, + /// Claude's scripts use the other. + #[serde(default)] + pub script_playwright_version: Option, + /// The Chromium that copy would launch, and whether it is there. This is + /// the pair that decides whether a script Claude writes actually runs. + #[serde(default)] + pub script_chromium_executable: Option, + #[serde(default)] + pub script_chromium_executable_exists: bool, /// Where the probe looked, echoed back for the "not found" message. #[serde(default)] pub searched: Vec, @@ -155,13 +181,82 @@ impl PlaywrightDetection { None } + /// The revision-skew sentence, for the pane's browser step. + /// + /// Separate from [`Self::blocker`] because it does not block the *viewer* — + /// the dashboard runs fine; it is the browser that cannot start. Names both + /// halves, because "install a browser" over a cache that visibly already + /// has one reads as nonsense without them. + pub fn skew_message(&self) -> Option { + if !self.revision_skew() { + return None; + } + // Which half is broken changes what the user sees, so say the one that + // is. The scripts case is the one that looks like a lie: the pane is + // green, the viewer works, and every script Claude writes dies. + if self.scripts_cannot_launch() { + return Some(format!( + "This container has {}, and the viewer works — but `require(\"playwright\")` \ + resolves Playwright {}, which launches {}. That file isn't there, so every \ + script Claude writes fails with “Executable doesn't exist”. Two copies ended \ + up in one tree: `@playwright/cli` pins its own `playwright-core`, and a \ + separately-installed `playwright` nests a second one beside it. “Set up \ + Playwright” below reinstalls them as one consistent set.", + self.browsers.join(", "), + self.script_playwright_version.as_deref().unwrap_or("?"), + self.script_chromium_executable.as_deref().unwrap_or("?"), + )); + } + Some(format!( + "This container has {}, but Playwright {} launches {} — which isn't there, so \ + every `chromium.launch()` fails with “Executable doesn't exist”. That happens \ + when two Playwright copies share a container (typically an npx `@playwright/mcp` \ + alongside this one); each pins its own browser revision. “Install Chromium” below \ + fetches the revision this build needs — it runs that build's own installer, so it \ + cannot pick the wrong one again.", + self.browsers.join(", "), + self.playwright_version.as_deref().unwrap_or("?"), + self.chromium_executable.as_deref().unwrap_or("?"), + )) + } + /// 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() + && (self.browsers.is_empty() || self.revision_skew()) + } + + /// Browsers are installed, but not the revision this Playwright launches. + /// + /// The container looks equipped and every `chromium.launch()` fails with + /// "Executable doesn't exist". It happens whenever two Playwright copies + /// share a container — the npx `@playwright/mcp` one and a `/workspace` + /// one — because each pins its own revision and installs into the same + /// cache. The install action fixes it: it runs the *resolved* build's own + /// CLI, so it fetches exactly the revision that was missing. + /// + /// Requires the probe to have answered: an older container image, or a + /// Playwright too broken to `require`, leaves `chromium_executable` unset, + /// and "didn't answer" must not read as "skewed". + pub fn revision_skew(&self) -> bool { + !self.browsers.is_empty() && (self.viewer_cannot_launch() || self.scripts_cannot_launch()) + } + + /// The copy serving the viewer would not find its browser. + fn viewer_cannot_launch(&self) -> bool { + self.chromium_executable.is_some() && !self.chromium_executable_exists + } + + /// `require("playwright")` — what every script Claude writes uses — would + /// not find its browser. Independent of the above, and the more common of + /// the two: `@playwright/cli` pins a `playwright-core`, npm hoists it, and + /// a separately-installed `playwright` nests a second one that no browser + /// was ever downloaded for. + fn scripts_cannot_launch(&self) -> bool { + self.script_chromium_executable.is_some() && !self.script_chromium_executable_exists } /// The searched roots as prose, so a message never trails off into "Looked @@ -277,6 +372,30 @@ const PROBE: &str = concat!( // 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){}"#, + // What this Playwright would *actually launch*, and whether it is there. + // + // A cache listing is not the same question. Two Playwright copies in one + // container — the npx `@playwright/mcp` one and a `/workspace` one — pin + // different browser revisions, and each installs its own. So the cache can + // hold `chromium-1237` while the resolved build wants `chromium-1234` and + // every `chromium.launch()` dies with "Executable doesn't exist", *while + // the pane reports a browser installed*. Asking the build itself sidesteps + // revision arithmetic entirely: this is the path a launch would use. + r#"const exe=(dir)=>{try{const bt=require(dir).chromium;"#, + r#"const ep=bt&&bt.executablePath?bt.executablePath():null;"#, + r#"return ep?[ep,fs.existsSync(ep)]:null;}catch(e){return null;}};"#, + r#"if(core){const r=exe(path.dirname(core));"#, + r#"if(r){out.chromium_executable=r[0];out.chromium_executable_exists=r[1];}}"#, + // And separately: what a *script* gets. `require("playwright")` is what + // every Playwright example writes, and it resolves the wrapper — which + // carries its own nested `playwright-core` whenever npm could not settle on + // one version. That copy can want a different browser revision than the one + // the viewer's copy installed, so it is asked its own question. + r#"try{const w=res("playwright/package.json");"#, + r#"if(w){const j=JSON.parse(fs.readFileSync(w,"utf8"));out.script_playwright_version=j.version;"#, + r#"const wc=at("playwright-core/package.json",path.dirname(w));"#, + r#"const r=exe(path.dirname(wc||w));"#, + r#"if(r){out.script_chromium_executable=r[0];out.script_chromium_executable_exists=r[1];}}}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"]){"#, @@ -467,6 +586,66 @@ mod tests { assert!(PROBE.contains("/opt/google/chrome/chrome"), "{}", PROBE); } + #[test] + fn the_probe_asks_playwright_what_it_would_launch() { + // Not derived from the cache listing — asked of the build, because the + // cache can hold a browser this build will never launch. + assert!(PROBE.contains("executablePath"), "{}", PROBE); + assert!(PROBE.contains("out.chromium_executable_exists"), "{}", PROBE); + } + + /// A container carrying browsers from a *different* Playwright copy. + fn skewed() -> PlaywrightDetection { + 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-1237"],"#, + r#""chromium_executable":"/home/claude/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome","#, + r#""chromium_executable_exists":false}"#, + ))) + .unwrap() + } + + #[test] + fn a_browser_cache_full_of_the_wrong_revision_counts_as_no_browser() { + let d = skewed(); + // The viewer still serves — it is the browser that cannot start. + assert!(d.is_usable()); + assert_eq!(d.blocker(), None); + assert!(d.revision_skew()); + assert!(d.needs_browser(), "a browser that cannot launch is not a browser"); + } + + #[test] + fn the_skew_message_names_both_revisions_and_the_way_out() { + let msg = skewed().skew_message().unwrap(); + assert!(msg.contains("chromium-1237"), "{}", msg); // what is there + assert!(msg.contains("chromium-1234"), "{}", msg); // what it wants + assert!(msg.contains("Install Chromium"), "{}", msg); // what fixes it + } + + #[test] + fn the_chrome_channel_covers_a_skewed_cache() { + // The channel is an apt binary at a fixed path, so a revision mismatch + // cannot affect it: there is still something to drive. + let mut d = skewed(); + d.chrome_channel = Some("/usr/bin/google-chrome-stable".to_string()); + assert!(!d.needs_browser()); + } + + #[test] + fn a_probe_that_could_not_answer_is_not_reported_as_skew() { + // Older container, or a Playwright too broken to `require`: unset is + // "unknown", and unknown must never render as "your browsers are wrong". + 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":["chromium-1237"]}"#, + ))) + .unwrap(); + assert!(!d.revision_skew()); + assert!(!d.needs_browser()); + assert_eq!(d.skew_message(), None); + } + #[test] fn a_missing_viewer_package_is_reported_separately() { let d = parse_probe_output(&payload( diff --git a/app/src-tauri/src/browser_view/install.rs b/app/src-tauri/src/browser_view/install.rs index 27b8de9..c859b82 100644 --- a/app/src-tauri/src/browser_view/install.rs +++ b/app/src-tauri/src/browser_view/install.rs @@ -73,14 +73,33 @@ use crate::docker::exec::{ 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. +/// The viewer package — installed **first**, and it decides the version of +/// `playwright` installed after 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"]; +/// `@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. @@ -213,49 +232,33 @@ pub async fn install_packages( emit_progress( app, project_id, - &format!( - "Installing playwright and @playwright/cli into {}/node_modules…", - INSTALL_DIR - ), + &format!("Installing @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?; + 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 Playwright in this container (exit {}).\n\nnpm said:\n{}", + "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?; @@ -265,7 +268,12 @@ pub async fn install_packages( // 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() { + // 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 \ @@ -282,6 +290,87 @@ pub async fn install_packages( }) } +/// 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. /// @@ -865,10 +954,20 @@ mod tests { 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); + 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] diff --git a/app/src/components/projects/home/BrowserTab.test.tsx b/app/src/components/projects/home/BrowserTab.test.tsx index 47c8a0f..f38c025 100644 --- a/app/src/components/projects/home/BrowserTab.test.tsx +++ b/app/src/components/projects/home/BrowserTab.test.tsx @@ -69,6 +69,11 @@ const NOTHING: PlaywrightDetection = { cli_entry: null, browsers: [], chrome_channel: null, + chromium_executable: null, + chromium_executable_exists: false, + script_playwright_version: null, + script_chromium_executable: null, + script_chromium_executable_exists: false, searched: [ "/workspace", "/usr/lib/node_modules", @@ -362,6 +367,41 @@ describe("BrowserTab", () => { expect(screen.queryByTitle(/browser view for/i)).toBeNull(); }); + it("names both halves when the installed browser isn\u2019t the one Playwright launches", async () => { + // The cache is full and every script fails \u2014 "install a browser" alone + // would read as nonsense, so the copy has to say which copy wants what. + checkBrowserViewSupport.mockResolvedValue({ + ...READY, + browsers: ["chromium-1237"], + chromium_executable: "/home/claude/.cache/ms-playwright/chromium-1237/chrome-linux64/chrome", + chromium_executable_exists: true, + script_playwright_version: "1.62.1", + script_chromium_executable: + "/home/claude/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome", + script_chromium_executable_exists: false, + }); + + render(); + + expect(await screen.findByText(/isn\u2019t the one Playwright launches/i)).toBeInTheDocument(); + // Both revisions appear in the explanation: what is installed, and what + // the failing copy actually wants. + expect(screen.getAllByText(/chromium-1237/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/chromium-1234/).length).toBeGreaterThan(0); + expect(screen.getAllByText(/Set up Playwright/).length).toBeGreaterThan(0); + }); + + it("does not call an unanswered probe a skew", async () => { + // A container older than these fields omits them; unknown is not broken. + checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] }); + getBrowserViewStatus.mockResolvedValue(LIVE); + + render(); + + expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument(); + expect(screen.queryByText(/isn\u2019t the one Playwright launches/i)).toBeNull(); + }); + it("only offers a window of its own once there is something to watch", async () => { checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] }); render(); diff --git a/app/src/components/projects/home/BrowserTab.tsx b/app/src/components/projects/home/BrowserTab.tsx index 64d15d5..1ed815e 100644 --- a/app/src/components/projects/home/BrowserTab.tsx +++ b/app/src/components/projects/home/BrowserTab.tsx @@ -283,7 +283,9 @@ export default function BrowserTab({ project, active }: Props) { // 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; + probed !== null && + probed.chrome_channel === null && + (probed.browsers.length === 0 || revisionSkew(probed)); const needsSetup = probed !== null && (!ready || needsBrowser); return ( @@ -422,6 +424,47 @@ function isUsable(d: PlaywrightDetection | null): boolean { return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null; } +/** + * Mirrors Rust `PlaywrightDetection::revision_skew`. + * + * Browsers are installed, but not the revision one of the two Playwright copies + * would launch — so the cache looks full and launches fail. A probe that didn't + * answer leaves the executable null, and "unknown" must not read as "broken". + */ +function revisionSkew(d: PlaywrightDetection | null): boolean { + if (!d || d.browsers.length === 0) return false; + // `!= null`, not `!== null`: a probe from a container that predates these + // fields omits them entirely, and `undefined` is "didn't answer" — which must + // never render as "your browsers are wrong". + const viewerBroken = d.chromium_executable != null && !d.chromium_executable_exists; + const scriptsBroken = + d.script_chromium_executable != null && !d.script_chromium_executable_exists; + return viewerBroken || scriptsBroken; +} + +/** + * The skew sentence, naming both halves. + * + * "Install a browser" over a cache that visibly already holds one reads as + * nonsense, so the copy has to say which copy of Playwright wants what. + */ +function skewText(d: PlaywrightDetection | null): string { + if (!d) return ""; + const scriptsBroken = + d.script_chromium_executable !== null && !d.script_chromium_executable_exists; + const [version, wanted] = scriptsBroken + ? [d.script_playwright_version, d.script_chromium_executable] + : [d.playwright_version, d.chromium_executable]; + return ( + `This container has ${d.browsers.join(", ")}, but ` + + `${scriptsBroken ? 'the Playwright a script gets from require("playwright")' : "the Playwright serving the viewer"}` + + ` — ${version ?? "?"} — launches ${wanted ?? "?"}, which isn’t there. ` + + (scriptsBroken + ? "Two copies ended up in one tree, each pinning its own browser revision, so the viewer works and every script Claude writes fails. Re-run “Set up Playwright” to reinstall them as one consistent set." + : "Install Chromium below: it runs that build’s own installer, so it fetches exactly the revision that is missing.") + ); +} + /** What the container is short of, as a list rather than as prose. */ function missingParts(d: PlaywrightDetection | null): string[] { if (!d) return []; @@ -469,6 +512,10 @@ function Setup({ const browsers = detection?.browsers ?? []; const chrome = detection?.chrome_channel ?? null; const noBrowser = browsers.length === 0 && chrome === null; + // Installed browsers that cannot be launched. Handled apart from `noBrowser` + // because the fix is the same button but the sentence must not be "install a + // browser" over a cache that visibly has one. + const skew = revisionSkew(detection) && chrome === null; return (
@@ -476,17 +523,21 @@ function Setup({

{!havePackages ? "This container can’t serve a browser view yet" - : noBrowser - ? "Playwright is ready — but there’s no browser to drive yet" - : "This container is set up"} + : skew + ? "The installed browser isn’t the one Playwright launches" + : noBrowser + ? "Playwright is ready — but there’s no browser to drive yet" + : "This container is set up"}

{message ?? (missing.length > 0 ? `Missing: ${missing.join(", ")}.` - : noBrowser - ? "Playwright and the viewer are installed. Install a browser below so there is something to watch." - : "Start the view from the button above once Claude has a browser open.")} + : skew + ? skewText(detection) + : noBrowser + ? "Playwright and the viewer are installed. Install a browser below so there is something to watch." + : "Start the view from the button above once Claude has a browser open.")}

diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index a792813..0c18c5c 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -469,6 +469,15 @@ export interface PlaywrightDetection { /** 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; + /** The Chromium the *viewer's* Playwright would launch, and whether it exists. */ + chromium_executable: string | null; + chromium_executable_exists: boolean; + /** What a script's `require("playwright")` resolves to — routinely a different + * copy, pinning a different browser revision. If its Chromium is missing, + * every script Claude writes fails while the pane still looks green. */ + script_playwright_version: string | null; + script_chromium_executable: string | null; + script_chromium_executable_exists: boolean; /** 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. */