Install one Playwright tree, and notice when a container has two
Setup installed `playwright@latest` and `@playwright/cli@latest` together. Verified on a real container, that produces a tree that looks right and is broken: `@playwright/cli@0.1.18` pins `playwright-core@1.63.0-alpha`, npm hoists it, and `playwright@latest` (1.62.1) 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 chromium-1237. Every script Claude writes says `require("playwright")`, gets the nested 1.62.1, and dies with: Executable doesn't exist at …/chromium_headless_shell-1234/… while the pane reports a browser installed, because one is. This is deterministic, not bad luck: every container set up through the pane lands in it. So the viewer package is installed first, and the `playwright` version installed after it is the one that package pins — read from the manifest npm just wrote, falling back to `@latest` only if it cannot be read. One core, one browser revision, both halves agreeing. Re-running "Set up Playwright" repairs an already-split tree. Detection now asks the question directly rather than listing a cache: it asks each resolved copy for `chromium.executablePath()` and whether that file exists — the viewer's copy *and* the one `require("playwright")` returns, since those are routinely different. `needs_browser()` covers "installed but not launchable", and the pane names both halves instead of saying "install a browser" over a cache that visibly has one. An absent field is "the probe didn't answer", never "skewed": containers predating these fields must not be told their browsers are wrong. The Rust side gets that from Option; the TypeScript mirror needed `!= null`, which an existing test caught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -93,6 +93,32 @@ pub struct PlaywrightDetection {
|
|||||||
/// user's own scripts and not for the MCP plugin.
|
/// user's own scripts and not for the MCP plugin.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub chrome_channel: Option<String>,
|
pub chrome_channel: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub script_chromium_executable_exists: bool,
|
||||||
/// Where the probe looked, echoed back for the "not found" message.
|
/// Where the probe looked, echoed back for the "not found" message.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub searched: Vec<String>,
|
pub searched: Vec<String>,
|
||||||
@@ -155,13 +181,82 @@ impl PlaywrightDetection {
|
|||||||
None
|
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<String> {
|
||||||
|
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 —
|
/// Whether Playwright is present but has no browser at all to drive —
|
||||||
/// neither a downloaded bundle nor the Chrome channel. Advisory: the viewer
|
/// neither a downloaded bundle nor the Chrome channel. Advisory: the viewer
|
||||||
/// still runs, it just has nothing to show until a browser is bound.
|
/// still runs, it just has nothing to show until a browser is bound.
|
||||||
pub fn needs_browser(&self) -> bool {
|
pub fn needs_browser(&self) -> bool {
|
||||||
self.playwright_version.is_some()
|
self.playwright_version.is_some()
|
||||||
&& self.browsers.is_empty()
|
|
||||||
&& self.chrome_channel.is_none()
|
&& 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
|
/// 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.
|
// 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#"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){}"#,
|
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
|
// The Chrome *channel* is an apt package, not a Playwright download, so it
|
||||||
// is looked for where apt puts 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#"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);
|
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]
|
#[test]
|
||||||
fn a_missing_viewer_package_is_reported_separately() {
|
fn a_missing_viewer_package_is_reported_separately() {
|
||||||
let d = parse_probe_output(&payload(
|
let d = parse_probe_output(&payload(
|
||||||
|
|||||||
@@ -73,14 +73,33 @@ use crate::docker::exec::{
|
|||||||
|
|
||||||
use super::detect::{self, PlaywrightDetection};
|
use super::detect::{self, PlaywrightDetection};
|
||||||
|
|
||||||
/// The two packages the pane genuinely needs, pinned to `@latest` because
|
/// The viewer package — installed **first**, and it decides the version of
|
||||||
/// `browser.bind()` is recent and the viewer tracks it.
|
/// `playwright` installed after it.
|
||||||
///
|
///
|
||||||
/// This is the *minimum* set. A user who followed the old guidance ended up
|
/// `@playwright/mcp` is deliberately not part of the set: it is Claude's MCP
|
||||||
/// with a global install as well as these; only these are required. Note what
|
/// configuration to make, and it contributes nothing to serving a viewer.
|
||||||
/// is not here: `@playwright/mcp` is Claude's MCP configuration to make, not
|
///
|
||||||
/// this pane's, and it contributes nothing to serving a viewer.
|
/// **Order matters here, and `playwright` is deliberately not `@latest`.**
|
||||||
pub const PACKAGES: [&str; 2] = ["playwright@latest", "@playwright/cli@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
|
/// Where the packages are installed. Container storage, not a bind mount — see
|
||||||
/// the module docs.
|
/// the module docs.
|
||||||
@@ -213,49 +232,33 @@ pub async fn install_packages(
|
|||||||
emit_progress(
|
emit_progress(
|
||||||
app,
|
app,
|
||||||
project_id,
|
project_id,
|
||||||
&format!(
|
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
|
||||||
"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
|
let mut step = npm_install(app, project_id, container_id, VIEWER_PACKAGE).await?;
|
||||||
// `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 {
|
if step.exit_code != 0 {
|
||||||
return Err(format!(
|
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.exit_code,
|
||||||
step.log_or("it produced no output at all")
|
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…");
|
emit_progress(app, project_id, "Re-checking what the container has…");
|
||||||
let detection = detect::detect(container_id).await?;
|
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
|
// saying so here is what stops someone walking away from a pane that will
|
||||||
// never show them anything.
|
// never show them anything.
|
||||||
let mut warning = detection.blocker();
|
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 = merge(
|
||||||
warning,
|
warning,
|
||||||
"Playwright is installed, but this container has no browser to drive yet. Install \
|
"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<StepResult, String> {
|
||||||
|
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
|
/// Install a browser: its system libraries first, then the browser, then prove
|
||||||
/// one actually starts.
|
/// one actually starts.
|
||||||
///
|
///
|
||||||
@@ -865,10 +954,20 @@ mod tests {
|
|||||||
fn the_package_set_is_the_minimum_that_satisfies_the_probe() {
|
fn the_package_set_is_the_minimum_that_satisfies_the_probe() {
|
||||||
// The viewer package is not optional, and `@playwright/mcp` is not a
|
// The viewer package is not optional, and `@playwright/mcp` is not a
|
||||||
// member: it can bind sessions, it can never serve the UI.
|
// member: it can bind sessions, it can never serve the UI.
|
||||||
assert!(PACKAGES.iter().any(|p| p.starts_with("playwright@")));
|
assert!(VIEWER_PACKAGE.starts_with("@playwright/cli@"));
|
||||||
assert!(PACKAGES.iter().any(|p| p.starts_with("@playwright/cli@")));
|
assert!(PLAYWRIGHT_FALLBACK.starts_with("playwright@"));
|
||||||
assert!(!PACKAGES.iter().any(|p| p.contains("@playwright/mcp")));
|
assert!(!VIEWER_PACKAGE.contains("@playwright/mcp"));
|
||||||
assert_eq!(PACKAGES.len(), 2);
|
}
|
||||||
|
|
||||||
|
#[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]
|
#[test]
|
||||||
|
|||||||
@@ -69,6 +69,11 @@ const NOTHING: PlaywrightDetection = {
|
|||||||
cli_entry: null,
|
cli_entry: null,
|
||||||
browsers: [],
|
browsers: [],
|
||||||
chrome_channel: null,
|
chrome_channel: null,
|
||||||
|
chromium_executable: null,
|
||||||
|
chromium_executable_exists: false,
|
||||||
|
script_playwright_version: null,
|
||||||
|
script_chromium_executable: null,
|
||||||
|
script_chromium_executable_exists: false,
|
||||||
searched: [
|
searched: [
|
||||||
"/workspace",
|
"/workspace",
|
||||||
"/usr/lib/node_modules",
|
"/usr/lib/node_modules",
|
||||||
@@ -362,6 +367,41 @@ describe("BrowserTab", () => {
|
|||||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
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(<BrowserTab project={project} active />);
|
||||||
|
|
||||||
|
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(<BrowserTab project={project} active />);
|
||||||
|
|
||||||
|
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 () => {
|
it("only offers a window of its own once there is something to watch", async () => {
|
||||||
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
render(<BrowserTab project={project} active />);
|
render(<BrowserTab project={project} active />);
|
||||||
|
|||||||
@@ -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
|
// apt package, so it never shows up in `browsers`, and a container that has
|
||||||
// it is not missing a browser.
|
// it is not missing a browser.
|
||||||
const needsBrowser =
|
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);
|
const needsSetup = probed !== null && (!ready || needsBrowser);
|
||||||
|
|
||||||
return (
|
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;
|
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. */
|
/** What the container is short of, as a list rather than as prose. */
|
||||||
function missingParts(d: PlaywrightDetection | null): string[] {
|
function missingParts(d: PlaywrightDetection | null): string[] {
|
||||||
if (!d) return [];
|
if (!d) return [];
|
||||||
@@ -469,6 +512,10 @@ function Setup({
|
|||||||
const browsers = detection?.browsers ?? [];
|
const browsers = detection?.browsers ?? [];
|
||||||
const chrome = detection?.chrome_channel ?? null;
|
const chrome = detection?.chrome_channel ?? null;
|
||||||
const noBrowser = browsers.length === 0 && chrome === 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 (
|
return (
|
||||||
<div className="p-4 max-w-[46rem] space-y-4">
|
<div className="p-4 max-w-[46rem] space-y-4">
|
||||||
@@ -476,17 +523,21 @@ function Setup({
|
|||||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
||||||
{!havePackages
|
{!havePackages
|
||||||
? "This container can’t serve a browser view yet"
|
? "This container can’t serve a browser view yet"
|
||||||
: noBrowser
|
: skew
|
||||||
? "Playwright is ready — but there’s no browser to drive yet"
|
? "The installed browser isn’t the one Playwright launches"
|
||||||
: "This container is set up"}
|
: noBrowser
|
||||||
|
? "Playwright is ready — but there’s no browser to drive yet"
|
||||||
|
: "This container is set up"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||||
{message ??
|
{message ??
|
||||||
(missing.length > 0
|
(missing.length > 0
|
||||||
? `Missing: ${missing.join(", ")}.`
|
? `Missing: ${missing.join(", ")}.`
|
||||||
: noBrowser
|
: skew
|
||||||
? "Playwright and the viewer are installed. Install a browser below so there is something to watch."
|
? skewText(detection)
|
||||||
: "Start the view from the button above once Claude has a browser open.")}
|
: 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.")}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -469,6 +469,15 @@ export interface PlaywrightDetection {
|
|||||||
/** Path to Google Chrome when the `chrome` channel — what `@playwright/mcp`
|
/** 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`. */
|
* asks for — is installed. It is an apt package, so it is never in `browsers`. */
|
||||||
chrome_channel: string | null;
|
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.
|
/** Module roots the probe searched, echoed back for the "not found" message.
|
||||||
* Includes the npx cache (`~/.npm/_npx/*/node_modules`), which is where a
|
* Includes the npx cache (`~/.npm/_npx/*/node_modules`), which is where a
|
||||||
* Playwright installed through Claude Code's MCP setup actually lives. */
|
* Playwright installed through Claude Code's MCP setup actually lives. */
|
||||||
|
|||||||
Reference in New Issue
Block a user