Browser view: find every Playwright, and set one up in two clicks #17
@@ -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
|
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;
|
`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.
|
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/<hash>/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:
|
- **`docker/`** — Docker API layer using bollard:
|
||||||
- `client.rs` — Singleton Docker connection via `OnceLock`
|
- `client.rs` — Singleton Docker connection via `OnceLock`
|
||||||
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
|
|
||||||
use tauri::{AppHandle, State};
|
use tauri::{AppHandle, State};
|
||||||
|
|
||||||
|
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
||||||
use crate::browser_view::{manager, BrowserViewStatus};
|
use crate::browser_view::{manager, BrowserViewStatus};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
@@ -27,20 +28,7 @@ pub async fn set_browser_view_enabled(
|
|||||||
return Ok(manager().status(&project_id).await);
|
return Ok(manager().status(&project_id).await);
|
||||||
}
|
}
|
||||||
|
|
||||||
let project = state
|
let container_id = running_container(&state, &project_id, "opening the browser view").await?;
|
||||||
.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());
|
|
||||||
}
|
|
||||||
|
|
||||||
manager()
|
manager()
|
||||||
.start(
|
.start(
|
||||||
@@ -61,18 +49,84 @@ pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewSt
|
|||||||
/// Probe the container for Playwright without starting anything.
|
/// Probe the container for Playwright without starting anything.
|
||||||
///
|
///
|
||||||
/// Lets the pane say "install this" before the user asks for a view, and lets
|
/// Lets the pane say "install this" before the user asks for a view, and lets
|
||||||
/// them re-check after installing without toggling the feature.
|
/// them re-check after installing without toggling the feature. Read-only: it
|
||||||
|
/// runs one `node -e` and changes nothing.
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn check_browser_view_support(
|
pub async fn check_browser_view_support(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
state: State<'_, AppState>,
|
state: State<'_, AppState>,
|
||||||
) -> Result<crate::browser_view::detect::PlaywrightDetection, String> {
|
) -> Result<crate::browser_view::detect::PlaywrightDetection, String> {
|
||||||
let project = state
|
let container_id = running_container(&state, &project_id, "checking for Playwright").await?;
|
||||||
.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())?;
|
|
||||||
crate::browser_view::detect::detect(&container_id).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<BrowserSetupOutcome, String> {
|
||||||
|
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<BrowserSetupOutcome, String> {
|
||||||
|
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<String, String> {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,6 +17,22 @@
|
|||||||
//! Discovery of published browsers is local-filesystem based (a cache directory
|
//! 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
|
//! 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.
|
//! 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/<hash>/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};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
@@ -39,6 +55,14 @@ pub struct PlaywrightDetection {
|
|||||||
/// Absolute path of the resolved package manifest, for the diagnostics line.
|
/// Absolute path of the resolved package manifest, for the diagnostics line.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub playwright_path: Option<String>,
|
pub playwright_path: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
/// Whether the resolved build's type definitions declare `Browser.bind()`.
|
/// Whether the resolved build's type definitions declare `Browser.bind()`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub has_bind: bool,
|
pub has_bind: bool,
|
||||||
@@ -50,6 +74,25 @@ pub struct PlaywrightDetection {
|
|||||||
/// we can signal.
|
/// we can signal.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub cli_entry: Option<String>,
|
pub cli_entry: Option<String>,
|
||||||
|
/// 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<String>,
|
||||||
|
/// 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<String>,
|
||||||
/// 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>,
|
||||||
@@ -63,6 +106,13 @@ impl PlaywrightDetection {
|
|||||||
|
|
||||||
/// A specific, actionable explanation of what is missing. `None` when the
|
/// A specific, actionable explanation of what is missing. `None` when the
|
||||||
/// container is ready.
|
/// 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<String> {
|
pub fn blocker(&self) -> Option<String> {
|
||||||
if self.node_version.is_none() {
|
if self.node_version.is_none() {
|
||||||
return Some(
|
return Some(
|
||||||
@@ -72,41 +122,66 @@ impl PlaywrightDetection {
|
|||||||
}
|
}
|
||||||
if self.playwright_version.is_none() {
|
if self.playwright_version.is_none() {
|
||||||
return Some(format!(
|
return Some(format!(
|
||||||
"Playwright isn't installed in this container. Install it with \
|
"Playwright isn't installed in this container. Two packages are needed: \
|
||||||
`npm i -D playwright` (or `npm i -g playwright`), then have Claude call \
|
`playwright` (for the `browser.bind()` live-dashboard API) and \
|
||||||
`await browser.bind('claude')` after launching a browser — or use \
|
`@playwright/cli` (the viewer UI this pane embeds). Use “Set up Playwright” \
|
||||||
`@playwright/mcp`, which binds automatically. Looked in: {}.",
|
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()`). 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(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() {
|
if self.searched.is_empty() {
|
||||||
"the container's default module paths".to_string()
|
"the container's default module paths".to_string()
|
||||||
} else {
|
} else {
|
||||||
self.searched.join(", ")
|
self.searched.join(", ")
|
||||||
}
|
}
|
||||||
));
|
|
||||||
}
|
|
||||||
if !self.has_bind {
|
|
||||||
return Some(format!(
|
|
||||||
"Playwright {} is installed, but it predates the live-dashboard API \
|
|
||||||
(`browser.bind()`). Upgrade with `npm i -D playwright@latest` and restart \
|
|
||||||
the browser Claude is driving.",
|
|
||||||
self.playwright_version.as_deref().unwrap_or("?")
|
|
||||||
));
|
|
||||||
}
|
|
||||||
if self.cli_entry.is_none() {
|
|
||||||
return Some(
|
|
||||||
"Playwright is installed, but the viewer UI package isn't. Install it with \
|
|
||||||
`npm i -D @playwright/cli`, then reopen this tab."
|
|
||||||
.to_string(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
None
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One `node -e` probe, run as `claude` inside the container.
|
/// One `node -e` probe, run as `claude` inside the container.
|
||||||
///
|
///
|
||||||
/// No shell quoting is involved: the script is a single `argv` element. The
|
/// 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
|
/// script finds the global `node_modules` root and the npx cache itself, so a
|
||||||
/// with `npm i -g` is found as readily as one in `/workspace/node_modules`.
|
/// 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<PlaywrightDetection, String> {
|
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
|
||||||
let output = exec_oneshot(
|
let output = exec_oneshot(
|
||||||
container_id,
|
container_id,
|
||||||
@@ -151,15 +226,44 @@ pub(crate) fn parse_probe_output(output: &str) -> Result<PlaywrightDetection, St
|
|||||||
/// produces "detection failed".
|
/// produces "detection failed".
|
||||||
const PROBE: &str = concat!(
|
const PROBE: &str = concat!(
|
||||||
r#"const fs=require("fs"),path=require("path"),cp=require("child_process");"#,
|
r#"const fs=require("fs"),path=require("path"),cp=require("child_process");"#,
|
||||||
r#"const out={node_version:process.versions.node,searched:[],has_bind:false};"#,
|
r#"const out={node_version:process.versions.node,searched:[],has_bind:false,browsers:[]};"#,
|
||||||
// `npm root -g` is the only reliable way to learn the global prefix, and it
|
// `npm root -g` is the only reliable way to learn the global prefix, and it
|
||||||
// is cheap enough to pay for once per pane open.
|
// is cheap enough to pay for once per pane open.
|
||||||
r#"let g=null;try{g=cp.execSync("npm root -g",{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()||null;}catch(e){}"#,
|
r#"let g=null;try{g=cp.execSync("npm root -g",{encoding:"utf8",stdio:["ignore","pipe","ignore"]}).trim()||null;}catch(e){}"#,
|
||||||
r#"const roots=[...new Set(["/workspace",process.cwd(),process.env.HOME?path.join(process.env.HOME,"node_modules"):null,g].filter(Boolean))];"#,
|
r#"const home=process.env.HOME||null;"#,
|
||||||
|
// The npx cache. `npm config get cache` would be authoritative but costs a
|
||||||
|
// second npm start-up; npm exports its resolved config into the
|
||||||
|
// environment of anything it runs, so `npm_config_cache` covers the
|
||||||
|
// overridden case and `~/.npm` covers the default.
|
||||||
|
r#"const cache=process.env.npm_config_cache||(home?path.join(home,".npm"):null);"#,
|
||||||
|
// Every `_npx/<hash>` 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#"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 at=(s,r)=>{try{return require.resolve(s,{paths:[r]});}catch(e){return null;}};"#,
|
||||||
r#"const core=res("playwright-core/package.json")||res("playwright/package.json");"#,
|
r#"const res=(s)=>{for(const r of roots){const p=at(s,r);if(p)return p;}return null;};"#,
|
||||||
r#"if(core){try{out.playwright_path=core;out.playwright_version=JSON.parse(fs.readFileSync(core,"utf8")).version;}catch(e){}"#,
|
// 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
|
// `bind`/`unbind` are checked against the shipped type definitions rather
|
||||||
// than by loading the module: it is a static read, needs no browser, and
|
// 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.
|
// 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#"out.has_bind=/\bunbind\s*\(\s*\)/.test(t)&&/\bbind\s*\(/.test(t);}catch(e){}}"#,
|
||||||
r#"const cli=res("@playwright/cli/package.json");"#,
|
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#"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#"out.cli_entry=bin(cli,j);}catch(e){}}"#,
|
||||||
r#"if(k)out.cli_entry=path.resolve(path.dirname(cli),b[k]);}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");"#,
|
r#"process.stdout.write("\n__TRIPLE_C_BROWSER_VIEW__"+JSON.stringify(out)+"\n");"#,
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -201,27 +313,158 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[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(
|
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();
|
.unwrap();
|
||||||
assert!(!d.is_usable());
|
assert!(!d.is_usable());
|
||||||
let msg = d.blocker().unwrap();
|
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);
|
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("/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]
|
#[test]
|
||||||
fn a_playwright_without_bind_asks_for_an_upgrade() {
|
fn a_playwright_without_bind_asks_for_an_upgrade() {
|
||||||
let d = parse_probe_output(&payload(
|
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();
|
.unwrap();
|
||||||
let msg = d.blocker().unwrap();
|
let msg = d.blocker().unwrap();
|
||||||
assert!(msg.contains("1.44.0"), "{}", msg);
|
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]
|
#[test]
|
||||||
@@ -252,6 +495,19 @@ mod tests {
|
|||||||
assert!(err.contains("no output"), "{}", err);
|
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]
|
#[test]
|
||||||
fn the_probe_is_a_single_argv_element_with_no_quoting_hazards() {
|
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
|
// It is passed straight to `node -e`; a stray single quote would only
|
||||||
|
|||||||
@@ -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<Self, String> {
|
||||||
|
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<bool>,
|
||||||
|
/// 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<BrowserSetupOutcome, String> {
|
||||||
|
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<BrowserSetupOutcome, String> {
|
||||||
|
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<String> = 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<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
|
limit: Duration,
|
||||||
|
) -> Result<StepResult, String> {
|
||||||
|
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<String> = 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::<Vec<_>>().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<String>, 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<String>, next: String) -> Option<String> {
|
||||||
|
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/<mount_name>; /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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -63,6 +63,7 @@
|
|||||||
|
|
||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod detect;
|
pub mod detect;
|
||||||
|
pub mod install;
|
||||||
pub mod proxy;
|
pub mod proxy;
|
||||||
|
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|||||||
@@ -426,6 +426,8 @@ pub fn run() {
|
|||||||
browser_view::commands::set_browser_view_enabled,
|
browser_view::commands::set_browser_view_enabled,
|
||||||
browser_view::commands::get_browser_view_status,
|
browser_view::commands::get_browser_view_status,
|
||||||
browser_view::commands::check_browser_view_support,
|
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
|
// Shared Claude Code auth token
|
||||||
commands::auth_token_commands::acquire_claude_token,
|
commands::auth_token_commands::acquire_claude_token,
|
||||||
commands::auth_token_commands::submit_claude_token_code,
|
commands::auth_token_commands::submit_claude_token_code,
|
||||||
|
|||||||
@@ -1,23 +1,41 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import BrowserTab from "./BrowserTab";
|
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<BrowserViewStatus>>();
|
const getBrowserViewStatus = vi.fn<() => Promise<BrowserViewStatus>>();
|
||||||
const setBrowserViewEnabled = vi.fn<() => Promise<BrowserViewStatus>>();
|
const setBrowserViewEnabled = vi.fn<() => Promise<BrowserViewStatus>>();
|
||||||
|
const checkBrowserViewSupport = vi.fn<() => Promise<PlaywrightDetection>>();
|
||||||
|
const installBrowserViewSupport = vi.fn<() => Promise<BrowserSetupOutcome>>();
|
||||||
|
const installBrowserViewBrowser = vi.fn<(id: string, b: string) => Promise<BrowserSetupOutcome>>();
|
||||||
const pushToast = vi.fn();
|
const pushToast = vi.fn();
|
||||||
|
const setContainerProgress = vi.fn();
|
||||||
|
|
||||||
vi.mock("../../../lib/tauri-commands", () => ({
|
vi.mock("../../../lib/tauri-commands", () => ({
|
||||||
getBrowserViewStatus: () => getBrowserViewStatus(),
|
getBrowserViewStatus: () => getBrowserViewStatus(),
|
||||||
setBrowserViewEnabled: () => setBrowserViewEnabled(),
|
setBrowserViewEnabled: () => setBrowserViewEnabled(),
|
||||||
|
checkBrowserViewSupport: () => checkBrowserViewSupport(),
|
||||||
|
installBrowserViewSupport: () => installBrowserViewSupport(),
|
||||||
|
installBrowserViewBrowser: (id: string, b: string) => installBrowserViewBrowser(id, b),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@tauri-apps/api/event", () => ({
|
vi.mock("@tauri-apps/api/event", () => ({
|
||||||
listen: vi.fn(async () => () => {}),
|
listen: vi.fn(async () => () => {}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
const storeState = {
|
||||||
|
pushToast,
|
||||||
|
setContainerProgress,
|
||||||
|
containerProgress: {} as Record<string, string>,
|
||||||
|
};
|
||||||
|
|
||||||
vi.mock("../../../store/appState", () => ({
|
vi.mock("../../../store/appState", () => ({
|
||||||
useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }),
|
useAppState: (selector: (s: unknown) => unknown) => selector(storeState),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const OFF: BrowserViewStatus = {
|
const OFF: BrowserViewStatus = {
|
||||||
@@ -31,6 +49,33 @@ const OFF: BrowserViewStatus = {
|
|||||||
message: null,
|
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 = {
|
const project: Project = {
|
||||||
id: "p1",
|
id: "p1",
|
||||||
name: "api-server",
|
name: "api-server",
|
||||||
@@ -63,7 +108,9 @@ const project: Project = {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
storeState.containerProgress = {};
|
||||||
getBrowserViewStatus.mockResolvedValue(OFF);
|
getBrowserViewStatus.mockResolvedValue(OFF);
|
||||||
|
checkBrowserViewSupport.mockResolvedValue(READY);
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("BrowserTab", () => {
|
describe("BrowserTab", () => {
|
||||||
@@ -72,17 +119,24 @@ describe("BrowserTab", () => {
|
|||||||
expect(await screen.findByText(/container isn’t running/i)).toBeInTheDocument();
|
expect(await screen.findByText(/container isn’t running/i)).toBeInTheDocument();
|
||||||
expect(screen.queryByRole("button", { name: /start browser view/i })).toBeNull();
|
expect(screen.queryByRole("button", { name: /start browser view/i })).toBeNull();
|
||||||
expect(getBrowserViewStatus).not.toHaveBeenCalled();
|
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(<BrowserTab project={project} active />);
|
render(<BrowserTab project={project} active />);
|
||||||
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
|
||||||
expect(screen.getByText("Off")).toBeInTheDocument();
|
expect(screen.getByText("Off")).toBeInTheDocument();
|
||||||
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
|
||||||
expect(setBrowserViewEnabled).not.toHaveBeenCalled();
|
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 () => {
|
it("shows the live pane, pointed at loopback with a token, once started", async () => {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
setBrowserViewEnabled.mockResolvedValue({
|
setBrowserViewEnabled.mockResolvedValue({
|
||||||
...OFF,
|
...OFF,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -109,27 +163,121 @@ describe("BrowserTab", () => {
|
|||||||
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
|
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(<BrowserTab project={project} active />);
|
||||||
|
|
||||||
|
// 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(<BrowserTab project={project} active />);
|
||||||
|
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(<BrowserTab project={project} active />);
|
||||||
|
|
||||||
|
expect(await screen.findByText(/several hundred mb/i)).toBeInTheDocument();
|
||||||
|
// The copy is broken across a <code> 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(<BrowserTab project={project} active />);
|
||||||
|
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(<BrowserTab project={project} active />);
|
||||||
|
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 () => {
|
it("explains precisely what is missing instead of spinning", async () => {
|
||||||
|
checkBrowserViewSupport.mockRejectedValue("container busy");
|
||||||
getBrowserViewStatus.mockResolvedValue({
|
getBrowserViewStatus.mockResolvedValue({
|
||||||
...OFF,
|
...OFF,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
state: "unavailable",
|
state: "unavailable",
|
||||||
message:
|
message:
|
||||||
"Playwright isn't installed in this container. Install it with `npm i -D playwright`.",
|
"Playwright isn't installed in this container. Two packages are needed: `playwright` and `@playwright/cli`.",
|
||||||
detection: {
|
detection: NOTHING,
|
||||||
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"],
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<BrowserTab project={project} active />);
|
render(<BrowserTab project={project} active />);
|
||||||
|
|
||||||
expect(await screen.findByText(/npm i -D playwright/)).toBeInTheDocument();
|
expect(await screen.findByText(/Two packages are needed/)).toBeInTheDocument();
|
||||||
expect(screen.getByText("Unavailable")).toBeInTheDocument();
|
expect(screen.getByText("Unavailable")).toBeInTheDocument();
|
||||||
// The probe's findings are shown, so the user can see why.
|
// The probe's findings are shown, so the user can see why.
|
||||||
expect(screen.getByText("22.11.0")).toBeInTheDocument();
|
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 () => {
|
it("surfaces a start failure rather than leaving the pane blank", async () => {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
setBrowserViewEnabled.mockRejectedValue("container went away");
|
setBrowserViewEnabled.mockRejectedValue("container went away");
|
||||||
|
|
||||||
render(<BrowserTab project={project} active />);
|
render(<BrowserTab project={project} active />);
|
||||||
@@ -155,6 +304,7 @@ describe("BrowserTab", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("stops the view when asked", async () => {
|
it("stops the view when asked", async () => {
|
||||||
|
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
|
||||||
getBrowserViewStatus.mockResolvedValue({
|
getBrowserViewStatus.mockResolvedValue({
|
||||||
...OFF,
|
...OFF,
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import type {
|
import type {
|
||||||
|
BrowserInstallTarget,
|
||||||
|
BrowserSetupOutcome,
|
||||||
BrowserViewChangedEvent,
|
BrowserViewChangedEvent,
|
||||||
BrowserViewStatus,
|
BrowserViewStatus,
|
||||||
|
PlaywrightDetection,
|
||||||
Project,
|
Project,
|
||||||
} from "../../../lib/types";
|
} from "../../../lib/types";
|
||||||
import {
|
import {
|
||||||
|
checkBrowserViewSupport,
|
||||||
getBrowserViewStatus,
|
getBrowserViewStatus,
|
||||||
|
installBrowserViewBrowser,
|
||||||
|
installBrowserViewSupport,
|
||||||
setBrowserViewEnabled,
|
setBrowserViewEnabled,
|
||||||
} from "../../../lib/tauri-commands";
|
} from "../../../lib/tauri-commands";
|
||||||
import { useAppState } from "../../../store/appState";
|
import { useAppState } from "../../../store/appState";
|
||||||
|
import AccordionSection from "../../ui/AccordionSection";
|
||||||
import Button from "../../ui/Button";
|
import Button from "../../ui/Button";
|
||||||
import StatusIndicator from "../../ui/StatusIndicator";
|
import StatusIndicator from "../../ui/StatusIndicator";
|
||||||
|
|
||||||
@@ -29,6 +36,9 @@ const OFF: BrowserViewStatus = {
|
|||||||
message: null,
|
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
|
* Watch — and take over — the browser Claude is driving with Playwright inside
|
||||||
* the container.
|
* the container.
|
||||||
@@ -38,6 +48,12 @@ const OFF: BrowserViewStatus = {
|
|||||||
* loopback. Nothing starts until the user asks: this is remote control of a
|
* 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
|
* browser in a privileged sandbox, so it is off by default and opted into per
|
||||||
* project, exactly like the auth bridge.
|
* 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) {
|
export default function BrowserTab({ project, active }: Props) {
|
||||||
const [status, setStatus] = useState<BrowserViewStatus>(OFF);
|
const [status, setStatus] = useState<BrowserViewStatus>(OFF);
|
||||||
@@ -45,7 +61,14 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
/** Bumped to force the iframe to reload without changing its src. */
|
/** Bumped to force the iframe to reload without changing its src. */
|
||||||
const [reloadKey, setReloadKey] = useState(0);
|
const [reloadKey, setReloadKey] = useState(0);
|
||||||
|
/** Last read-only probe of the container, for the setup panel. */
|
||||||
|
const [detection, setDetection] = useState<PlaywrightDetection | null>(null);
|
||||||
|
const [job, setJob] = useState<SetupJob>(null);
|
||||||
|
const [outcome, setOutcome] = useState<BrowserSetupOutcome | null>(null);
|
||||||
|
const [setupError, setSetupError] = useState<string | null>(null);
|
||||||
const pushToast = useAppState((s) => s.pushToast);
|
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";
|
const running = project.status === "running";
|
||||||
|
|
||||||
// The backend is the source of truth: it emits whenever a view starts or is
|
// 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)
|
getBrowserViewStatus(projectId)
|
||||||
.then((s) => mounted.current && setStatus(s))
|
.then((s) => mounted.current && setStatus(s))
|
||||||
.catch(() => {});
|
.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]);
|
}, [active, projectId, running]);
|
||||||
|
|
||||||
const toggle = useCallback(
|
const toggle = useCallback(
|
||||||
@@ -101,8 +129,52 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
[projectId, pushToast],
|
[projectId, pushToast],
|
||||||
);
|
);
|
||||||
|
|
||||||
// A stopped container can't be hosting a browser, so say that plainly rather
|
/** Run one install. Every path clears the progress line it started. */
|
||||||
// than offering a control that would only fail.
|
const install = useCallback(
|
||||||
|
async (which: Exclude<SetupJob, null>) => {
|
||||||
|
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) {
|
if (!running) {
|
||||||
return (
|
return (
|
||||||
<Explainer title="The container isn’t running.">
|
<Explainer title="The container isn’t running.">
|
||||||
@@ -113,6 +185,16 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const live = status.state === "running" && status.url;
|
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 (
|
return (
|
||||||
<div className="flex flex-col h-full min-h-0">
|
<div className="flex flex-col h-full min-h-0">
|
||||||
@@ -151,7 +233,7 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
<Button
|
<Button
|
||||||
size="md"
|
size="md"
|
||||||
variant={live ? "secondary" : "primary"}
|
variant={live ? "secondary" : "primary"}
|
||||||
disabled={busy}
|
disabled={busy || job !== null}
|
||||||
onClick={() => toggle(!status.enabled || status.state !== "running")}
|
onClick={() => toggle(!status.enabled || status.state !== "running")}
|
||||||
>
|
>
|
||||||
{busy ? "Working…" : live ? "Stop" : "Start browser view"}
|
{busy ? "Working…" : live ? "Stop" : "Start browser view"}
|
||||||
@@ -169,8 +251,23 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex-1 min-h-0 overflow-y-auto">
|
<div className="flex-1 min-h-0 overflow-y-auto">
|
||||||
{status.state === "unavailable" ? (
|
{/* Setup stays on screen while an install is running and after it
|
||||||
<Unavailable status={status} />
|
finishes, so its output and caveats don't vanish at the moment
|
||||||
|
they become readable. */}
|
||||||
|
{needsSetup ||
|
||||||
|
status.state === "unavailable" ||
|
||||||
|
job !== null ||
|
||||||
|
outcome !== null ||
|
||||||
|
setupError !== null ? (
|
||||||
|
<Setup
|
||||||
|
detection={probed}
|
||||||
|
message={status.state === "unavailable" ? status.message : null}
|
||||||
|
job={job}
|
||||||
|
progress={job ? progress : undefined}
|
||||||
|
outcome={outcome}
|
||||||
|
error={setupError}
|
||||||
|
onInstall={install}
|
||||||
|
/>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<Explainer title="The browser view didn’t start." tone="error">
|
<Explainer title="The browser view didn’t start." tone="error">
|
||||||
<span className="font-mono text-xs break-words">{error}</span>
|
<span className="font-mono text-xs break-words">{error}</span>
|
||||||
@@ -190,25 +287,209 @@ export default function BrowserTab({ project, active }: Props) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The container can't serve a view — say exactly what is missing. */
|
/** Mirrors Rust `PlaywrightDetection::is_usable`. */
|
||||||
function Unavailable({ status }: { status: BrowserViewStatus }) {
|
function isUsable(d: PlaywrightDetection | null): boolean {
|
||||||
const d = status.detection;
|
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the container is short of, as a list rather than as prose. */
|
||||||
|
function missingParts(d: PlaywrightDetection | null): string[] {
|
||||||
|
if (!d) return [];
|
||||||
|
const out: string[] = [];
|
||||||
|
if (!d.node_version) out.push("Node.js");
|
||||||
|
if (!d.playwright_version) out.push("playwright");
|
||||||
|
else if (!d.has_bind) out.push("a newer playwright — this build has no browser.bind()");
|
||||||
|
if (!d.cli_entry) out.push("@playwright/cli");
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Setup, as one action per line, each saying what it costs before it is
|
||||||
|
* pressed.
|
||||||
|
*
|
||||||
|
* The old pane printed npm commands here and left the rest to the user. The
|
||||||
|
* result, verified with a real one: an `@playwright/mcp` install that could
|
||||||
|
* never satisfy this pane, a global install that hit EACCES, a Chromium that
|
||||||
|
* downloaded and then would not start because the image ships none of its
|
||||||
|
* shared libraries, and a long tail of commands after that.
|
||||||
|
*/
|
||||||
|
function Setup({
|
||||||
|
detection,
|
||||||
|
message,
|
||||||
|
job,
|
||||||
|
progress,
|
||||||
|
outcome,
|
||||||
|
error,
|
||||||
|
onInstall,
|
||||||
|
}: {
|
||||||
|
detection: PlaywrightDetection | null;
|
||||||
|
message: string | null;
|
||||||
|
job: SetupJob;
|
||||||
|
progress?: string;
|
||||||
|
outcome: BrowserSetupOutcome | null;
|
||||||
|
error: string | null;
|
||||||
|
onInstall: (which: Exclude<SetupJob, null>) => void;
|
||||||
|
}) {
|
||||||
|
const busy = job !== null;
|
||||||
|
const havePackages = isUsable(detection);
|
||||||
|
const missing = missingParts(detection);
|
||||||
|
const browsers = detection?.browsers ?? [];
|
||||||
|
const chrome = detection?.chrome_channel ?? null;
|
||||||
|
const noBrowser = browsers.length === 0 && chrome === null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 max-w-[46rem] space-y-3">
|
<div className="p-4 max-w-[46rem] space-y-4">
|
||||||
|
<div>
|
||||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
||||||
This container can’t serve a browser view yet
|
{!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"}
|
||||||
</h2>
|
</h2>
|
||||||
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||||
{status.message}
|
{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.")}
|
||||||
</p>
|
</p>
|
||||||
{d && (
|
</div>
|
||||||
<dl className="text-xs grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 pt-2 border-t border-[var(--border-color)]">
|
|
||||||
<Detail label="Node.js" value={d.node_version} />
|
<Step
|
||||||
<Detail label="Playwright" value={d.playwright_version} />
|
title="1. Playwright and the viewer UI"
|
||||||
<Detail label="browser.bind()" value={d.has_bind ? "available" : "not in this build"} />
|
detail={
|
||||||
<Detail label="@playwright/cli" value={d.cli_version} />
|
<>
|
||||||
{d.searched.length > 0 && (
|
Installs <Code>playwright</Code> and <Code>@playwright/cli</Code> into{" "}
|
||||||
<Detail label="Searched" value={d.searched.join(", ")} />
|
<Code>/workspace/node_modules</Code> inside the container. That directory is
|
||||||
|
container storage — your project folders are mounted one level down, so
|
||||||
|
nothing of yours is touched — and no <Code>sudo</Code> is involved. Small
|
||||||
|
download; browsers come next.
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
done={havePackages}
|
||||||
|
doneLabel={`Installed — playwright ${detection?.playwright_version ?? ""}, @playwright/cli ${detection?.cli_version ?? ""}`}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant={havePackages ? "secondary" : "primary"}
|
||||||
|
disabled={busy}
|
||||||
|
onClick={() => onInstall("packages")}
|
||||||
|
>
|
||||||
|
{job === "packages" ? "Installing…" : havePackages ? "Reinstall" : "Set up Playwright"}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<Step
|
||||||
|
title="2. A browser to drive"
|
||||||
|
detail={
|
||||||
|
<>
|
||||||
|
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 <Code>~/.cache/ms-playwright</Code>, 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={
|
||||||
|
<div className="flex flex-col gap-2 items-end">
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
variant={browsers.length > 0 || !havePackages ? "secondary" : "primary"}
|
||||||
|
disabled={busy || !havePackages}
|
||||||
|
onClick={() => onInstall("chromium")}
|
||||||
|
>
|
||||||
|
{job === "chromium" ? "Installing…" : "Install Chromium"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="md"
|
||||||
|
disabled={busy || !havePackages}
|
||||||
|
onClick={() => onInstall("chrome")}
|
||||||
|
>
|
||||||
|
{job === "chrome" ? "Installing…" : "Install Chrome channel"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ul className="mt-2 space-y-1 text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||||
|
<li>
|
||||||
|
<strong className="text-[var(--text-primary)]">Chromium</strong> — Playwright’s
|
||||||
|
own build, used by <Code>chromium.launch()</Code> with no channel. Several
|
||||||
|
hundred MB.
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<strong className="text-[var(--text-primary)]">Chrome channel</strong> — Google
|
||||||
|
Chrome from apt, which is what <Code>@playwright/mcp</Code> asks for. Install
|
||||||
|
this one if Claude drives the browser through the MCP plugin. Roughly 150 MB.
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</Step>
|
||||||
|
|
||||||
|
{busy && (
|
||||||
|
<p
|
||||||
|
className="text-xs font-mono text-[var(--text-secondary)] break-all"
|
||||||
|
aria-live="polite"
|
||||||
|
>
|
||||||
|
{progress ?? "Working…"}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="text-xs text-[var(--error)]">
|
||||||
|
<p className="font-semibold">That didn’t work.</p>
|
||||||
|
<pre className="mt-1 whitespace-pre-wrap font-mono break-words text-[var(--text-secondary)]">
|
||||||
|
{error}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{outcome?.warning && (
|
||||||
|
<div className="text-xs text-[var(--text-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-3">
|
||||||
|
<p className="font-semibold">Worth knowing</p>
|
||||||
|
<p className="mt-1 whitespace-pre-wrap text-[var(--text-secondary)] leading-relaxed">
|
||||||
|
{outcome.warning}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{outcome?.log && (
|
||||||
|
<AccordionSection
|
||||||
|
id="browser-view-install-log"
|
||||||
|
title="Install output"
|
||||||
|
defaultOpen={false}
|
||||||
|
>
|
||||||
|
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-words text-[var(--text-secondary)] max-h-64 overflow-y-auto">
|
||||||
|
{outcome.log}
|
||||||
|
</pre>
|
||||||
|
</AccordionSection>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{detection && (
|
||||||
|
<dl className="text-xs grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 pt-3 border-t border-[var(--border-color)]">
|
||||||
|
<Detail label="Node.js" value={detection.node_version} />
|
||||||
|
<Detail label="Playwright" value={detection.playwright_version} />
|
||||||
|
<Detail label="Resolved from" value={detection.playwright_path} />
|
||||||
|
<Detail
|
||||||
|
label="browser.bind()"
|
||||||
|
value={detection.has_bind ? "available" : "not in this build"}
|
||||||
|
/>
|
||||||
|
<Detail label="@playwright/cli" value={detection.cli_version} />
|
||||||
|
<Detail
|
||||||
|
label="Browsers"
|
||||||
|
value={browsers.length > 0 ? browsers.join(", ") : null}
|
||||||
|
/>
|
||||||
|
<Detail label="Chrome channel" value={chrome} />
|
||||||
|
{detection.searched.length > 0 && (
|
||||||
|
<Detail label="Searched" value={detection.searched.join(", ")} />
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
@@ -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 (
|
||||||
|
<div className="border border-[var(--border-color)] rounded-[var(--radius-control)] p-3">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<h3 className="text-[13px] font-semibold text-[var(--text-primary)]">{title}</h3>
|
||||||
|
<StatusIndicator tone={done ? "ok" : "off"} label={done ? "Installed" : "Not installed"} />
|
||||||
|
</div>
|
||||||
|
<p className="mt-1 text-xs text-[var(--text-secondary)] leading-relaxed">{detail}</p>
|
||||||
|
{done && doneLabel && (
|
||||||
|
<p className="mt-1 text-xs font-mono text-[var(--text-secondary)] break-all">
|
||||||
|
{doneLabel}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
<div className="flex-shrink-0">{action}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function Detail({ label, value }: { label: string; value: string | null }) {
|
function Detail({ label, value }: { label: string; value: string | null }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { invoke } from "@tauri-apps/api/core";
|
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
|
// Docker
|
||||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||||
@@ -178,6 +178,24 @@ export const getBrowserViewStatus = (projectId: string) =>
|
|||||||
/** Probe for Playwright without starting anything — used to re-check after installing it. */
|
/** Probe for Playwright without starting anything — used to re-check after installing it. */
|
||||||
export const checkBrowserViewSupport = (projectId: string) =>
|
export const checkBrowserViewSupport = (projectId: string) =>
|
||||||
invoke<PlaywrightDetection>("check_browser_view_support", { projectId });
|
invoke<PlaywrightDetection>("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<BrowserSetupOutcome>("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<BrowserSetupOutcome>("install_browser_view_browser", { projectId, browser });
|
||||||
|
|
||||||
// Shared Claude Code auth token — one `claude setup-token` run authenticates
|
// Shared Claude Code auth token — one `claude setup-token` run authenticates
|
||||||
// every Anthropic-backend project. The token itself is never exposed here: it
|
// every Anthropic-backend project. The token itself is never exposed here: it
|
||||||
|
|||||||
+27
-1
@@ -434,14 +434,40 @@ export interface PlaywrightDetection {
|
|||||||
node_version: string | null;
|
node_version: string | null;
|
||||||
playwright_version: string | null;
|
playwright_version: string | null;
|
||||||
playwright_path: 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. */
|
/** Whether the resolved Playwright declares the `browser.bind()` live-dashboard API. */
|
||||||
has_bind: boolean;
|
has_bind: boolean;
|
||||||
cli_version: string | null;
|
cli_version: string | null;
|
||||||
cli_entry: 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[];
|
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). */
|
/** Mirrors Rust `BrowserViewState` (serde snake_case). */
|
||||||
export type BrowserViewState = "off" | "running" | "unavailable";
|
export type BrowserViewState = "off" | "running" | "unavailable";
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user