Compare commits

...
Author SHA1 Message Date
shadow-testandClaude Opus 5 4fdfed7955 Bake the browser's runtime libraries into the base image
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m28s
Build App / build-windows (pull_request) Successful in 5m13s
Build Container / build-container (pull_request) Successful in 13m11s
Build App / build-linux (pull_request) Successful in 6m53s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
`npx playwright install chromium` downloaded ~150 MB of browser that then
died with "error while loading shared libraries: libglib-2.0.so.0" —
verified, not inferred, against the current image. The image shipped none
of Chromium's shared libraries, which is why `apt install
google-chrome-stable` looked like the cure: apt was quietly installing the
same set as Chrome's own dependencies.

Installing them at runtime instead converges on the worst possible state.
The libraries land in the container's writable layer, so they are re-paid
after every Reset and *lost* on base-image migration, which replays apt
from a manifest. The browsers ride in ~/.cache/ms-playwright, inside the
home volume, and survive both — leaving a 400 MB browser present with its
libraries gone. So the libraries are baked and the browsers are not: each
half now lives where it already persists.

The layer runs `npx --yes playwright@latest install-deps chromium` rather
than a hand-written apt list. Ubuntu 24.04's 64-bit-time_t transition
renamed a swathe of these packages (libasound2t64, libatk1.0-0t64,
libglib2.0-0t64, …) and a new Chromium dependency would drift straight back
into the launch failure this exists to prevent; letting Playwright name its
own dependencies is self-maintaining. It sits immediately after Node — npx
is its only prerequisite — and well above the shim COPYs, so editing a shim
does not re-run it.

The `--dry-run` that follows is a build-time assertion, not decoration: on a
platform Playwright has no list for, `install-deps` prints a warning and
returns having installed **nothing, with exit status 0**. Without the
assertion that ships a broken image behind a clean build log.

Measured, on a build of this file with the layer applied over an otherwise
identical image: +99 packages, +334 MiB unpacked and +119 MiB compressed
(2950 → 3284 MiB, 759 → 878 MiB). Two thirds of that is not reachable by
trimming — libgbm1, which Chromium needs, pulls mesa-libgallium, which
pulls libllvm20. A chromium-only apt list measures 247 MiB against
install-deps' 341 MiB; the ~94 MiB difference is xvfb and the CJK/emoji
fonts, kept because the base ships no fonts at all and every page this
feature exists to display would otherwise render as tofu.

Verified on real builds, both architectures: a `--platform linux/arm64`
build of this file installs the same 99 packages and passes the same
assertion. On the new amd64 image, `playwright install chromium` with no
`--with-deps` and no `install-deps` launches headless Chromium 151.0.7922.34
and loads a page; on the old image the identical script fails on
libglib-2.0.so.0.

`install.rs` no longer runs `install-deps` unconditionally — that would be a
minutes-long apt run for nothing on a current image. It asks
`install-deps --dry-run` first and skips the install when everything is
present, saying which of the two happened on the progress stream. The check
is Playwright's rather than a probe of our own for library names, so check
and fix cannot disagree about what the dependency set is. Note that
`--dry-run` exits 0 both when everything is installed and when Playwright
has no list for the platform, so the verdict is read from its output.

Containers on older images stay the normal case until people migrate, and
they still work: on such an image the simulation cannot even resolve the
package names (the index is cleaned in every base image), which reports as
"couldn't tell" and installs — the right answer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
2026-08-10 10:57:21 -07:00
shadow-testandClaude Opus 5 a5bcc462a7 Browser view: find every Playwright, and set one up in two clicks
Build App / compute-version (pull_request) Successful in 14s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m18s
Build App / build-linux (pull_request) Successful in 6m42s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Detection missed the npx cache, so a Playwright installed through Claude
Code's MCP setup (`npx @playwright/mcp@latest`, which unpacks into
~/.npm/_npx/<hash>/node_modules and no node_modules at all) was invisible.
The probe now globs that cache alongside the existing roots and reports
every root it consulted.

It also read `has_bind` off whichever manifest resolved first. Verified
that npm does not hoist for global installs and that the `playwright`
wrapper ships no types/types.d.ts, so `npm i -g playwright` made the pane
call a current build "predates browser.bind()". The probe now hops from
the wrapper to its nested playwright-core.

The messages no longer offer `@playwright/mcp` as a way through setup: it
bundles a playwright-core that binds but never `@playwright/cli`, so that
route could not have worked. It is named only for what it does do.

New `install.rs` + two commands do the setup, streaming on the existing
`container-progress` event and re-probing on success:

  * playwright + @playwright/cli into /workspace as `claude`, --no-save.
    /workspace is not a bind mount (projects mount at
    /workspace/{mount_name}), so nothing of the user's is touched, no sudo
    is needed, and Node resolves it from scripts in the project.
  * A browser, as its own action with the size stated first: apt libraries
    as root, then the download, then a real headless launch to prove it
    works. The base image ships none of Chromium's shared libraries, which
    is why a download could succeed and the browser still not start.
    Chromium and the Chrome channel are both offered — @playwright/mcp
    asks for `chrome` specifically. A certificate failure is reported as a
    container trust-store problem rather than a broken install.

Installing is always user-initiated; opening the tab only probes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
2026-08-10 10:15:02 -07:00
jknapp 584fcdd837 Merge pull request #15: create the WOW64 junctions in the workflow instead of by hand
Build App / compute-version (push) Successful in 16s
Build App / build-macos (push) Successful in 2m38s
Build App / build-windows (push) Successful in 5m21s
Build App / build-linux (push) Successful in 5m48s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 14s
Moves the last hand-made piece of the Windows fix into version control. Verified by deleting the manual junctions from the build VM first, so CI had to recreate them from scratch.
2026-08-10 15:00:25 +00:00
13 changed files with 2133 additions and 94 deletions
+47 -1
View File
@@ -104,6 +104,32 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
OAuth listener, wrong for remote control of a browser. Host ports are confined to
`47820..=47827` because CSP `frame-src` cannot express a port range and must enumerate them;
a unit test asserts the Rust range matches `tauri.conf.json`. Opt-in per project.
- **Detection has to look past `node_modules`.** `claude mcp add … npx @playwright/mcp@latest`
installs into `~/.npm/_npx/<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.
- **Current base images ship Chromium's shared libraries; older ones do not** — and a project
keeps the base image it was first built from until it is migrated, so "older" is the normal
case. Without them `playwright install chromium` downloads a browser that cannot launch, which
is why installing Chrome via apt looks like a fix. `install.rs` asks
`install-deps --dry-run` first and skips the apt step when the answer is "all present",
*saying so* in the progress stream. Do not decide this by probing for library names: the
dry-run simulates the same `apt-get install` the fix would run, so check and fix cannot
disagree about what the dependency set is. Note that `--dry-run` exits **0** both when
everything is installed and when Playwright has no list for the platform — match on its
output, not its exit code. Either way the action ends by *actually launching* the browser to
verify. `@playwright/mcp` wants the `chrome` **channel** specifically, so both browsers are
offered.
- **`docker/`** — Docker API layer using bollard:
- `client.rs` — Singleton Docker connection via `OnceLock`
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
@@ -134,7 +160,27 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
### Container (`container/`)
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed, plus the shared
libraries a browser links against (see below)
- **Browser runtime libraries are baked in; browser *binaries* are not.** A layer runs
`npx --yes playwright@latest install-deps chromium` as root, so Playwright names its own
dependencies and the list cannot rot against Ubuntu 24.04's `t64` renames or a new Chromium
dependency. Measured: +99 packages, +334 MiB unpacked / +119 MiB compressed, on both arches. Do
not replace it with a hand-written apt list without pinning the Playwright version you derived
it from — a `chromium`-only list saves ~94 MiB (Playwright's `tools` group: xvfb and the CJK
fonts) and nothing more, because `libgbm1``mesa-libgallium``libllvm20` is ~213 MiB that
no trimming removes.
- The `install-deps --dry-run` call after it is a **build-time assertion, not decoration**: on a
platform Playwright's table does not cover, `install-deps` prints a warning and returns having
installed nothing **with exit status 0**. Without the assertion that ships a broken image
behind a clean build log.
- Baking the libraries but not the browsers is the whole point of the split. Browsers live in
`~/.cache/ms-playwright` (home volume) and already survive recreation *and* migration; a
runtime `apt-get install` of the libraries lands in the writable layer, is re-paid after every
Reset, and is **lost on base-image migration**, which replays apt from a manifest. The runtime
approach converges on the worst state: a 400 MB browser present with its libraries gone.
- The layer sits immediately after Node (npx is its only prerequisite) and well above the shim
`COPY`s, so editing a shim does not re-run a multi-hundred-megabyte apt install.
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
+6
View File
@@ -1127,6 +1127,12 @@ The sandbox container (Ubuntu 24.04) comes pre-installed with:
The container also includes **clipboard shims** (`xclip`, `xsel`, `pbcopy`) that forward copy operations to the host via OSC 52, a **browser shim** (`triple-c-open`, installed as `xdg-open`, `sensible-browser`, `www-browser`, `x-www-browser` and `$BROWSER`) that relays URLs to your host browser — see [Opening URLs in Your Browser](#opening-urls-in-your-browser-url-relay) — and an **audio shim** (`rec`, `arecord`) for future voice mode support.
It also ships the **system libraries a browser needs to run** (`libnss3`, `libgbm1`, `libatk*`, `libasound2t64`, `libcups2t64`, `libpango`, `libdrm2`, fonts, and the rest of the set Playwright asks for). So `npx playwright install chromium` gives you a browser that actually starts. Before these were baked in, that download succeeded and the browser then died with *"Host system is missing dependencies: libnss3.so"*, which is why `sudo apt install google-chrome-stable` looked like the cure — apt was quietly installing the same libraries as Chrome's own dependencies.
The **browsers themselves are not pre-installed** — they are hundreds of megabytes and tied to the Playwright version you use. Install one with the Browser tab's setup buttons, or `npx playwright install chromium` in a terminal. They land in `~/.cache/ms-playwright`, which is on the home volume, so a browser survives container recreation and base-image migration and is only lost on a project **Reset**.
If your project's container was created from an older base image, it won't have the libraries — the Browser tab's install action detects that and installs them for you first, and says so while it does. That install lives in the container's writable layer, so it is undone by a **Reset** and by a base-image migration; migrating the project onto the current base image is what picks the libraries up for good.
You can install additional tools at runtime with `sudo apt install`, `pip install`, `npm install -g`, etc. Installed packages persist across container stops (but not across resets).
---
+20
View File
@@ -386,4 +386,24 @@ Users can override this in Settings via the global `docker_socket_path` option.
**Shims**: `xclip`/`xsel`/`pbcopy` (OSC 52 clipboard forwarding), `xdg-open`/`sensible-browser`/`www-browser`/`x-www-browser`/`$BROWSER` (OSC 7777 URL relay to the host browser), `rec`/`arecord` (audio FIFO for voice mode)
**Browser runtime libraries**: the shared libraries Chromium links against (`libnss3`, `libgbm1`,
`libatk*`, `libasound2t64`, `libcups2t64`, `libpango`, `libdrm2`, … plus fonts) are baked in, via
`npx playwright install-deps chromium` at build time. Without them `playwright install chromium`
downloads a browser that then dies at launch with *"Host system is missing dependencies:
libnss3.so"* — which is why installing `google-chrome-stable` used to look like the fix (apt was
pulling the libraries in as *its* dependencies). Measured cost of the layer: +99 packages,
**+334 MiB unpacked / +119 MiB compressed** (2950 → 3284 MiB unpacked, 759 → 878 MiB compressed).
Two thirds of that is not avoidable by trimming — `libgbm1`, which Chromium needs, depends on
`mesa-libgallium`, which depends on `libllvm20`. The list is taken from Playwright rather than
hand-written so it cannot rot against Ubuntu 24.04's `t64` renames or a future Chromium dependency,
and the `install-deps --dry-run` that follows it is a build-time assertion: on a platform
Playwright has no list for, `install-deps` installs nothing and still exits 0.
**Browser binaries are deliberately not baked.** They are large, they are version-coupled to
whatever Playwright the user installs, and they already persist: `~/.cache/ms-playwright` is inside
the home volume, so a downloaded browser survives container recreation *and* base-image migration.
The libraries are the opposite — a runtime `apt-get install` lands in the container's writable
layer, is re-paid after every Reset, and is lost on migration (which replays apt from a manifest
against the new base). Baking one and not the other puts each half where it already persists.
**Default user**: `claude` (UID/GID 1000, remapped by entrypoint to match host)
+76 -22
View File
@@ -4,6 +4,7 @@
use tauri::{AppHandle, State};
use crate::browser_view::install::{self, BrowserSetupOutcome};
use crate::browser_view::{manager, BrowserViewStatus};
use crate::AppState;
@@ -27,20 +28,7 @@ pub async fn set_browser_view_enabled(
return Ok(manager().status(&project_id).await);
}
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let Some(container_id) = project.container_id.clone() else {
return Err("Start the container before opening the browser view.".to_string());
};
if !crate::docker::container::is_container_running(&container_id)
.await
.unwrap_or(false)
{
return Err("Start the container before opening the browser view.".to_string());
}
let container_id = running_container(&state, &project_id, "opening the browser view").await?;
manager()
.start(
@@ -61,18 +49,84 @@ pub async fn get_browser_view_status(project_id: String) -> Result<BrowserViewSt
/// Probe the container for Playwright without starting anything.
///
/// 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]
pub async fn check_browser_view_support(
project_id: String,
state: State<'_, AppState>,
) -> Result<crate::browser_view::detect::PlaywrightDetection, String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.ok_or_else(|| "Start the container to check for Playwright.".to_string())?;
let container_id = running_container(&state, &project_id, "checking for Playwright").await?;
crate::browser_view::detect::detect(&container_id).await
}
/// Install `playwright` and `@playwright/cli` into the container.
///
/// **This mutates the container**, so it is a command of its own and is only
/// ever reached by the user pressing the button — nothing here runs on tab
/// open. Progress streams on `container-progress`; the outcome carries a fresh
/// probe so the pane updates itself.
///
/// Browsers are *not* fetched here. They are hundreds of megabytes and get
/// their own action, with the size stated before the click.
#[tauri::command]
pub async fn install_browser_view_support(
project_id: String,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<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)
}
+292 -36
View File
@@ -17,6 +17,22 @@
//! Discovery of published browsers is local-filesystem based (a cache directory
//! plus a unix-socket singleton in the temp dir), which is exactly why the viewer
//! has to run *in the container* next to the browsers rather than on the host.
//!
//! ## Where a Playwright can legitimately be
//!
//! `node_modules` is not the only answer, and assuming it was is what made this
//! probe lie. `claude mcp add … npx @playwright/mcp@latest` — the way most
//! people end up with Playwright in the container — installs nothing into any
//! `node_modules`: npx unpacks the tree into `~/.npm/_npx/<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};
@@ -39,6 +55,14 @@ pub struct PlaywrightDetection {
/// Absolute path of the resolved package manifest, for the diagnostics line.
#[serde(default)]
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()`.
#[serde(default)]
pub has_bind: bool,
@@ -50,6 +74,25 @@ pub struct PlaywrightDetection {
/// we can signal.
#[serde(default)]
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.
#[serde(default)]
pub searched: Vec<String>,
@@ -63,6 +106,13 @@ impl PlaywrightDetection {
/// A specific, actionable explanation of what is missing. `None` when the
/// container is ready.
///
/// Every branch names the *package* that is missing and points at this
/// pane's install action, because assembling npm commands by hand is the
/// thing that went wrong for real users. `@playwright/mcp` is named only in
/// the role it actually plays — it binds sessions automatically once
/// Playwright is present — and never as a route through setup, because it
/// does not ship `@playwright/cli` and so can never make the viewer work.
pub fn blocker(&self) -> Option<String> {
if self.node_version.is_none() {
return Some(
@@ -72,41 +122,66 @@ impl PlaywrightDetection {
}
if self.playwright_version.is_none() {
return Some(format!(
"Playwright isn't installed in this container. Install it with \
`npm i -D playwright` (or `npm i -g playwright`), then have Claude call \
`await browser.bind('claude')` after launching a browser or use \
`@playwright/mcp`, which binds automatically. Looked in: {}.",
"Playwright isn't installed in this container. Two packages are needed: \
`playwright` (for the `browser.bind()` live-dashboard API) and \
`@playwright/cli` (the viewer UI this pane embeds). Use Set up Playwright \
below to install both into the container. Installing `@playwright/mcp` on \
its own is not enough it binds sessions for you once Playwright is there, \
but it never provides the viewer. Looked in: {}.",
self.searched_text()
));
}
if !self.has_bind {
return Some(format!(
"Playwright {} is installed{}, but it predates the live-dashboard API \
(`browser.bind()`). 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() {
"the container's default module paths".to_string()
} else {
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.
///
/// No shell quoting is involved: the script is a single `argv` element. The
/// script finds the global `node_modules` root itself, so a Playwright installed
/// with `npm i -g` is found as readily as one in `/workspace/node_modules`.
/// script finds the global `node_modules` root and the npx cache itself, so a
/// Playwright installed with `npm i -g`, or merely *run* once through
/// `npx @playwright/mcp`, is found as readily as one in
/// `/workspace/node_modules`.
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
let output = exec_oneshot(
container_id,
@@ -151,15 +226,44 @@ pub(crate) fn parse_probe_output(output: &str) -> Result<PlaywrightDetection, St
/// produces "detection failed".
const PROBE: &str = concat!(
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
// 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#"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#"const res=(s)=>{for(const r of roots){try{return require.resolve(s,{paths:[r]});}catch(e){}}return null;};"#,
r#"const core=res("playwright-core/package.json")||res("playwright/package.json");"#,
r#"if(core){try{out.playwright_path=core;out.playwright_version=JSON.parse(fs.readFileSync(core,"utf8")).version;}catch(e){}"#,
r#"const at=(s,r)=>{try{return require.resolve(s,{paths:[r]});}catch(e){return null;}};"#,
r#"const res=(s)=>{for(const r of roots){const p=at(s,r);if(p)return p;}return null;};"#,
// One `bin` reader for both packages: `bin` is a string for some manifests
// and an object for others, and getting that wrong on either one loses the
// entry point silently.
r#"const bin=(m,j)=>{const b=typeof j.bin==="string"?{[j.name]:j.bin}:(j.bin||{});"#,
r#"const k=Object.keys(b)[0];return k?path.resolve(path.dirname(m),b[k]):null;};"#,
// `playwright-core` is what carries the typings and the browser registry, but
// it is frequently *nested*: verified against a real `npm i -g playwright
// @playwright/cli`, npm does not hoist for global installs, so the global
// root holds `playwright/` and `@playwright/cli/` and no top-level
// `playwright-core/`. Resolving only the outer `playwright` would then read
// a package that ships no `types/types.d.ts` at all and report a perfectly
// current build as "predates browser.bind()". So: hop from the wrapper to
// its own `playwright-core`, and only fall back to the wrapper's manifest.
r#"let core=res("playwright-core/package.json");"#,
r#"if(!core){const pw=res("playwright/package.json");"#,
r#"if(pw)core=at("playwright-core/package.json",path.dirname(pw))||pw;}"#,
r#"if(core){try{out.playwright_path=core;const j=JSON.parse(fs.readFileSync(core,"utf8"));"#,
r#"out.playwright_version=j.version;out.playwright_cli=bin(core,j);}catch(e){}"#,
// `bind`/`unbind` are checked against the shipped type definitions rather
// than by loading the module: it is a static read, needs no browser, and
// cannot be tripped up by a package that fails to import.
@@ -167,8 +271,16 @@ const PROBE: &str = concat!(
r#"out.has_bind=/\bunbind\s*\(\s*\)/.test(t)&&/\bbind\s*\(/.test(t);}catch(e){}}"#,
r#"const cli=res("@playwright/cli/package.json");"#,
r#"if(cli){try{const j=JSON.parse(fs.readFileSync(cli,"utf8"));out.cli_version=j.version;"#,
r#"const b=typeof j.bin==="string"?{[j.name]:j.bin}:(j.bin||{});const k=Object.keys(b)[0];"#,
r#"if(k)out.cli_entry=path.resolve(path.dirname(cli),b[k]);}catch(e){}}"#,
r#"out.cli_entry=bin(cli,j);}catch(e){}}"#,
// Browser bundles. `ffmpeg-*` lives in the same directory and is filtered
// out: it is not something that can be driven, and counting it would let
// the pane claim a browser is present when none is.
r#"try{const bd=process.env.PLAYWRIGHT_BROWSERS_PATH||(home?path.join(home,".cache","ms-playwright"):null);"#,
r#"if(bd)out.browsers=fs.readdirSync(bd).filter((n)=>/^(chromium|firefox|webkit)/.test(n)).sort();}catch(e){}"#,
// The Chrome *channel* is an apt package, not a Playwright download, so it
// is looked for where apt puts it.
r#"try{for(const p of ["/usr/bin/google-chrome-stable","/usr/bin/google-chrome","/opt/google/chrome/chrome"]){"#,
r#"if(fs.existsSync(p)){out.chrome_channel=p;break;}}}catch(e){}"#,
r#"process.stdout.write("\n__TRIPLE_C_BROWSER_VIEW__"+JSON.stringify(out)+"\n");"#,
);
@@ -201,27 +313,158 @@ mod tests {
}
#[test]
fn a_missing_playwright_is_reported_with_where_we_looked() {
fn a_missing_playwright_names_both_packages_and_where_we_looked() {
let d = parse_probe_output(&payload(
r#"{"node_version":"22.11.0","searched":["/workspace","/usr/lib/node_modules"]}"#,
r#"{"node_version":"22.11.0","searched":["/workspace","/usr/lib/node_modules","/home/claude/.npm/_npx/a1/node_modules"]}"#,
))
.unwrap();
assert!(!d.is_usable());
let msg = d.blocker().unwrap();
assert!(msg.contains("npm i -D playwright"), "{}", msg);
// The two packages that actually have to be there, by name.
assert!(msg.contains("`playwright`"), "{}", msg);
assert!(msg.contains("`@playwright/cli`"), "{}", msg);
assert!(msg.contains("browser.bind"), "{}", msg);
// Every root consulted, including the npx cache, so the claim is checkable.
assert!(msg.contains("/usr/lib/node_modules"), "{}", msg);
assert!(msg.contains("/home/claude/.npm/_npx/a1/node_modules"), "{}", msg);
}
#[test]
fn no_message_offers_playwright_mcp_as_a_way_through_setup() {
// It bundles a playwright-core new enough to bind, but never ships the
// viewer — so proposing it as an install route is a dead end, which is
// exactly what a user hit. It may only be named for what it does do.
for json in [
r#"{"node_version":"22.11.0","searched":["/workspace"]}"#,
r#"{"node_version":"22.11.0","playwright_version":"1.44.0","has_bind":false}"#,
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true}"#,
] {
let msg = parse_probe_output(&payload(json)).unwrap().blocker().unwrap();
let offers_install = msg.contains("install `@playwright/mcp`")
|| msg.contains("or use `@playwright/mcp`")
|| msg.contains("npm i -D @playwright/mcp")
|| msg.contains("npm i -g @playwright/mcp");
assert!(!offers_install, "{}", msg);
// And every message points at the one action that does work.
assert!(msg.contains("Set up Playwright"), "{}", msg);
}
}
#[test]
fn a_playwright_without_bind_asks_for_an_upgrade() {
let d = parse_probe_output(&payload(
r#"{"node_version":"22.11.0","playwright_version":"1.44.0","has_bind":false,"cli_entry":"/x/cli.js"}"#,
r#"{"node_version":"22.11.0","playwright_version":"1.44.0","playwright_path":"/workspace/node_modules/playwright/package.json","has_bind":false,"cli_entry":"/x/cli.js"}"#,
))
.unwrap();
let msg = d.blocker().unwrap();
assert!(msg.contains("1.44.0"), "{}", msg);
assert!(msg.contains("playwright@latest"), "{}", msg);
assert!(msg.contains("/workspace/node_modules/playwright"), "{}", msg);
assert!(msg.contains("Set up Playwright"), "{}", msg);
}
#[test]
fn an_npx_cached_playwright_counts_as_installed() {
// What `claude mcp add … npx @playwright/mcp@latest` leaves behind: a
// real playwright-core, in no `node_modules` the old probe looked at.
// It satisfies bind — and nothing else, because npx never brings the
// viewer with it.
let d = parse_probe_output(&payload(
concat!(
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","#,
r#""playwright_path":"/home/claude/.npm/_npx/9f/node_modules/playwright-core/package.json","#,
r#""playwright_cli":"/home/claude/.npm/_npx/9f/node_modules/playwright-core/cli.js","#,
r#""has_bind":true,"#,
r#""searched":["/workspace","/usr/lib/node_modules","/home/claude/.npm/_npx/9f/node_modules"]}"#,
),
))
.unwrap();
assert_eq!(d.playwright_version.as_deref(), Some("1.62.1"));
assert!(d.has_bind);
assert_eq!(
d.playwright_cli.as_deref(),
Some("/home/claude/.npm/_npx/9f/node_modules/playwright-core/cli.js")
);
// Still not usable, and the message says why: the viewer is missing.
assert!(!d.is_usable());
let msg = d.blocker().unwrap();
assert!(msg.contains("@playwright/cli"), "{}", msg);
}
#[test]
fn the_probe_searches_the_npx_cache_as_well_as_the_module_roots() {
// The roots are built inside the probe, so this is the only place the
// set can be asserted without a container. Each fragment is load-bearing:
// dropping any one of them is how an install becomes invisible.
assert!(PROBE.contains(r#""/workspace""#), "{}", PROBE);
assert!(PROBE.contains("process.cwd()"), "{}", PROBE);
assert!(PROBE.contains(r#"path.join(home,"node_modules")"#), "{}", PROBE);
assert!(PROBE.contains("npm root -g"), "{}", PROBE);
assert!(PROBE.contains(r#"path.join(cache,"_npx")"#), "{}", PROBE);
assert!(PROBE.contains("npm_config_cache"), "{}", PROBE);
// Every one of them, not just the first hit, and all of them reported.
assert!(PROBE.contains("...npx"), "{}", PROBE);
assert!(PROBE.contains("out.searched=roots"), "{}", PROBE);
}
#[test]
fn a_partial_tree_still_answers_rather_than_failing() {
// Playwright resolved, but its manifest unreadable and no viewer: the
// probe's guards must still produce a parseable payload carrying what
// it did learn, because that is what the message is built from.
let d = parse_probe_output(&payload(
r#"{"node_version":"22.11.0","has_bind":false,"searched":["/workspace"],"browsers":["chromium-1200"]}"#,
))
.unwrap();
assert_eq!(d.node_version.as_deref(), Some("22.11.0"));
assert_eq!(d.browsers, vec!["chromium-1200".to_string()]);
assert!(d.blocker().is_some());
}
#[test]
fn a_playwright_with_no_browser_bundle_is_flagged_without_blocking() {
let d = parse_probe_output(&payload(
concat!(
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":[]}"#,
),
))
.unwrap();
// Serving the viewer is possible; there is just nothing to drive yet.
assert!(d.is_usable());
assert_eq!(d.blocker(), None);
assert!(d.needs_browser());
let with_browser = parse_probe_output(&payload(
concat!(
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":["chromium-1200"]}"#,
),
))
.unwrap();
assert!(!with_browser.needs_browser());
// The Chrome channel counts too — it is an apt package rather than a
// Playwright download, so it never appears in `browsers`, and
// `@playwright/mcp` is the caller that asks for it.
let chrome_only = parse_probe_output(&payload(
concat!(
r#"{"node_version":"22.11.0","playwright_version":"1.62.1","has_bind":true,"#,
r#""cli_version":"0.1.18","cli_entry":"/g/cli.js","browsers":[],"#,
r#""chrome_channel":"/usr/bin/google-chrome-stable"}"#,
),
))
.unwrap();
assert!(!chrome_only.needs_browser());
assert_eq!(
chrome_only.chrome_channel.as_deref(),
Some("/usr/bin/google-chrome-stable")
);
}
#[test]
fn the_probe_looks_for_the_chrome_channel_where_apt_puts_it() {
assert!(PROBE.contains("google-chrome-stable"), "{}", PROBE);
assert!(PROBE.contains("/opt/google/chrome/chrome"), "{}", PROBE);
}
#[test]
@@ -252,6 +495,19 @@ mod tests {
assert!(err.contains("no output"), "{}", err);
}
#[test]
fn the_probe_reads_bind_from_the_nested_core_of_a_wrapper_install() {
// `npm i -g playwright` leaves `playwright-core` under
// `playwright/node_modules`, and the wrapper ships no
// `types/types.d.ts` — so without this hop a current build reports
// `has_bind: false`. Verified against a real global install.
assert!(
PROBE.contains(r#"at("playwright-core/package.json",path.dirname(pw))"#),
"{}",
PROBE
);
}
#[test]
fn the_probe_is_a_single_argv_element_with_no_quoting_hazards() {
// It is passed straight to `node -e`; a stray single quote would only
File diff suppressed because it is too large Load Diff
+1
View File
@@ -63,6 +63,7 @@
pub mod commands;
pub mod detect;
pub mod install;
pub mod proxy;
use std::collections::HashMap;
+2
View File
@@ -426,6 +426,8 @@ pub fn run() {
browser_view::commands::set_browser_view_enabled,
browser_view::commands::get_browser_view_status,
browser_view::commands::check_browser_view_support,
browser_view::commands::install_browser_view_support,
browser_view::commands::install_browser_view_browser,
// Shared Claude Code auth token
commands::auth_token_commands::acquire_claude_token,
commands::auth_token_commands::submit_claude_token_code,
@@ -1,23 +1,41 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import BrowserTab from "./BrowserTab";
import type { BrowserViewStatus, Project } from "../../../lib/types";
import type {
BrowserSetupOutcome,
BrowserViewStatus,
PlaywrightDetection,
Project,
} from "../../../lib/types";
const getBrowserViewStatus = vi.fn<() => Promise<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 setContainerProgress = vi.fn();
vi.mock("../../../lib/tauri-commands", () => ({
getBrowserViewStatus: () => getBrowserViewStatus(),
setBrowserViewEnabled: () => setBrowserViewEnabled(),
checkBrowserViewSupport: () => checkBrowserViewSupport(),
installBrowserViewSupport: () => installBrowserViewSupport(),
installBrowserViewBrowser: (id: string, b: string) => installBrowserViewBrowser(id, b),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
const storeState = {
pushToast,
setContainerProgress,
containerProgress: {} as Record<string, string>,
};
vi.mock("../../../store/appState", () => ({
useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }),
useAppState: (selector: (s: unknown) => unknown) => selector(storeState),
}));
const OFF: BrowserViewStatus = {
@@ -31,6 +49,33 @@ const OFF: BrowserViewStatus = {
message: null,
};
const NOTHING: PlaywrightDetection = {
node_version: "22.11.0",
playwright_version: null,
playwright_path: null,
playwright_cli: null,
has_bind: false,
cli_version: null,
cli_entry: null,
browsers: [],
chrome_channel: null,
searched: [
"/workspace",
"/usr/lib/node_modules",
"/home/claude/.npm/_npx/9f3a/node_modules",
],
};
const READY: PlaywrightDetection = {
...NOTHING,
playwright_version: "1.62.1",
playwright_path: "/workspace/node_modules/playwright-core/package.json",
playwright_cli: "/workspace/node_modules/playwright-core/cli.js",
has_bind: true,
cli_version: "0.1.18",
cli_entry: "/workspace/node_modules/@playwright/cli/playwright-cli.js",
};
const project: Project = {
id: "p1",
name: "api-server",
@@ -63,7 +108,9 @@ const project: Project = {
beforeEach(() => {
vi.clearAllMocks();
storeState.containerProgress = {};
getBrowserViewStatus.mockResolvedValue(OFF);
checkBrowserViewSupport.mockResolvedValue(READY);
});
describe("BrowserTab", () => {
@@ -72,17 +119,24 @@ describe("BrowserTab", () => {
expect(await screen.findByText(/container isnt running/i)).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /start browser view/i })).toBeNull();
expect(getBrowserViewStatus).not.toHaveBeenCalled();
expect(checkBrowserViewSupport).not.toHaveBeenCalled();
});
it("starts off, and never starts a view without being asked", async () => {
it("starts off, and never starts a view or installs anything without being asked", async () => {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
render(<BrowserTab project={project} active />);
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
expect(screen.getByText("Off")).toBeInTheDocument();
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
expect(setBrowserViewEnabled).not.toHaveBeenCalled();
// Probing is read-only and expected; installing is a mutation and is not.
await waitFor(() => expect(checkBrowserViewSupport).toHaveBeenCalled());
expect(installBrowserViewSupport).not.toHaveBeenCalled();
expect(installBrowserViewBrowser).not.toHaveBeenCalled();
});
it("shows the live pane, pointed at loopback with a token, once started", async () => {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
setBrowserViewEnabled.mockResolvedValue({
...OFF,
enabled: true,
@@ -109,27 +163,121 @@ describe("BrowserTab", () => {
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
});
it("offers setup before the user hits a wall, naming what is missing", async () => {
checkBrowserViewSupport.mockResolvedValue(NOTHING);
render(<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 () => {
checkBrowserViewSupport.mockRejectedValue("container busy");
getBrowserViewStatus.mockResolvedValue({
...OFF,
enabled: true,
state: "unavailable",
message:
"Playwright isn't installed in this container. Install it with `npm i -D playwright`.",
detection: {
node_version: "22.11.0",
playwright_version: null,
playwright_path: null,
has_bind: false,
cli_version: null,
cli_entry: null,
searched: ["/workspace", "/usr/lib/node_modules"],
},
"Playwright isn't installed in this container. Two packages are needed: `playwright` and `@playwright/cli`.",
detection: NOTHING,
});
render(<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();
// The probe's findings are shown, so the user can see why.
expect(screen.getByText("22.11.0")).toBeInTheDocument();
@@ -139,6 +287,7 @@ describe("BrowserTab", () => {
});
it("surfaces a start failure rather than leaving the pane blank", async () => {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
setBrowserViewEnabled.mockRejectedValue("container went away");
render(<BrowserTab project={project} active />);
@@ -155,6 +304,7 @@ describe("BrowserTab", () => {
});
it("stops the view when asked", async () => {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
getBrowserViewStatus.mockResolvedValue({
...OFF,
enabled: true,
+344 -20
View File
@@ -1,15 +1,22 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { listen } from "@tauri-apps/api/event";
import type {
BrowserInstallTarget,
BrowserSetupOutcome,
BrowserViewChangedEvent,
BrowserViewStatus,
PlaywrightDetection,
Project,
} from "../../../lib/types";
import {
checkBrowserViewSupport,
getBrowserViewStatus,
installBrowserViewBrowser,
installBrowserViewSupport,
setBrowserViewEnabled,
} from "../../../lib/tauri-commands";
import { useAppState } from "../../../store/appState";
import AccordionSection from "../../ui/AccordionSection";
import Button from "../../ui/Button";
import StatusIndicator from "../../ui/StatusIndicator";
@@ -29,6 +36,9 @@ const OFF: BrowserViewStatus = {
message: null,
};
/** Which install is in flight. `null` means none — nothing installs itself. */
type SetupJob = null | "packages" | BrowserInstallTarget;
/**
* Watch and take over the browser Claude is driving with Playwright inside
* the container.
@@ -38,6 +48,12 @@ const OFF: BrowserViewStatus = {
* loopback. Nothing starts until the user asks: this is remote control of a
* browser in a privileged sandbox, so it is off by default and opted into per
* project, exactly like the auth bridge.
*
* The same rule, harder, applies to setup. Opening this tab *probes* the
* container (one `node -e`, read-only) so the pane can say what is missing
* before the user asks for a view but it never installs anything. Installing
* packages and downloading a browser are container mutations measured in
* hundreds of megabytes; both are separate, labelled, user-pressed buttons.
*/
export default function BrowserTab({ project, active }: Props) {
const [status, setStatus] = useState<BrowserViewStatus>(OFF);
@@ -45,7 +61,14 @@ export default function BrowserTab({ project, active }: Props) {
const [error, setError] = useState<string | null>(null);
/** Bumped to force the iframe to reload without changing its src. */
const [reloadKey, setReloadKey] = useState(0);
/** Last read-only probe of the container, for the setup panel. */
const [detection, setDetection] = useState<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 setContainerProgress = useAppState((s) => s.setContainerProgress);
const progress = useAppState((s) => s.containerProgress[project.id]);
const running = project.status === "running";
// The backend is the source of truth: it emits whenever a view starts or is
@@ -77,6 +100,11 @@ export default function BrowserTab({ project, active }: Props) {
getBrowserViewStatus(projectId)
.then((s) => mounted.current && setStatus(s))
.catch(() => {});
// Read-only. This is what lets the pane offer setup before the user hits a
// wall, and it is why a "not installed" answer is never stale.
checkBrowserViewSupport(projectId)
.then((d) => mounted.current && setDetection(d))
.catch(() => {});
}, [active, projectId, running]);
const toggle = useCallback(
@@ -101,8 +129,52 @@ export default function BrowserTab({ project, active }: Props) {
[projectId, pushToast],
);
// A stopped container can't be hosting a browser, so say that plainly rather
// than offering a control that would only fail.
/** Run one install. Every path clears the progress line it started. */
const install = useCallback(
async (which: Exclude<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) {
return (
<Explainer title="The container isnt running.">
@@ -113,6 +185,16 @@ export default function BrowserTab({ project, active }: Props) {
}
const live = status.state === "running" && status.url;
// Prefer the probe: it is the fresher of the two, and it is the one that
// reflects an install that just finished.
const probed = detection ?? status.detection;
const ready = isUsable(probed);
// Mirrors Rust `PlaywrightDetection::needs_browser`: the Chrome channel is an
// apt package, so it never shows up in `browsers`, and a container that has
// it is not missing a browser.
const needsBrowser =
probed !== null && probed.browsers.length === 0 && probed.chrome_channel === null;
const needsSetup = probed !== null && (!ready || needsBrowser);
return (
<div className="flex flex-col h-full min-h-0">
@@ -151,7 +233,7 @@ export default function BrowserTab({ project, active }: Props) {
<Button
size="md"
variant={live ? "secondary" : "primary"}
disabled={busy}
disabled={busy || job !== null}
onClick={() => toggle(!status.enabled || status.state !== "running")}
>
{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">
{status.state === "unavailable" ? (
<Unavailable status={status} />
{/* Setup stays on screen while an install is running and after it
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 ? (
<Explainer title="The browser view didnt start." tone="error">
<span className="font-mono text-xs break-words">{error}</span>
@@ -190,25 +287,214 @@ export default function BrowserTab({ project, active }: Props) {
);
}
/** The container can't serve a view — say exactly what is missing. */
function Unavailable({ status }: { status: BrowserViewStatus }) {
const d = status.detection;
/** Mirrors Rust `PlaywrightDetection::is_usable`. */
function isUsable(d: PlaywrightDetection | null): boolean {
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 shipped none of its
* shared libraries, and a long tail of commands after that. Current base images
* bake those libraries in, so that last one is fixed at the source but a
* project keeps its original base image until it is migrated, so the install
* action still handles a container that lacks them.
*/
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 (
<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)]">
This container cant serve a browser view yet
{!havePackages
? "This container cant serve a browser view yet"
: noBrowser
? "Playwright is ready — but theres no browser to drive yet"
: "This container is set up"}
</h2>
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed">
{status.message}
<p className="mt-1 text-[13px] text-[var(--text-secondary)] leading-relaxed">
{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>
{d && (
<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} />
<Detail label="Playwright" value={d.playwright_version} />
<Detail label="browser.bind()" value={d.has_bind ? "available" : "not in this build"} />
<Detail label="@playwright/cli" value={d.cli_version} />
{d.searched.length > 0 && (
<Detail label="Searched" value={d.searched.join(", ")} />
</div>
<Step
title="1. Playwright and the viewer UI"
detail={
<>
Installs <Code>playwright</Code> and <Code>@playwright/cli</Code> into{" "}
<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 check the system libraries a browser links against first. Current base
images ship them, so that step is normally skipped; a container built from an
older image gets them installed with apt, which is the difference between a
browser that downloads successfully and one that also starts. 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> Playwrights
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 didnt 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>
)}
@@ -216,6 +502,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 }) {
return (
<>
+19 -1
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome } from "./types";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome } from "./types";
// Docker
export const checkDocker = () => invoke<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. */
export const checkBrowserViewSupport = (projectId: string) =>
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
// every Anthropic-backend project. The token itself is never exposed here: it
+27 -1
View File
@@ -434,14 +434,40 @@ export interface PlaywrightDetection {
node_version: string | null;
playwright_version: string | null;
playwright_path: string | null;
/** Playwright's own `cli.js`, which installs browsers and their apt libraries. */
playwright_cli: string | null;
/** Whether the resolved Playwright declares the `browser.bind()` live-dashboard API. */
has_bind: boolean;
cli_version: string | null;
cli_entry: string | null;
/** Module roots the probe searched, echoed back for the "not found" message. */
/** Browser bundles in `~/.cache/ms-playwright`, e.g. `chromium-1200`. Never `ffmpeg-*`. */
browsers: string[];
/** Path to Google Chrome when the `chrome` channel what `@playwright/mcp`
* asks for is installed. It is an apt package, so it is never in `browsers`. */
chrome_channel: string | null;
/** Module roots the probe searched, echoed back for the "not found" message.
* Includes the npx cache (`~/.npm/_npx/*/node_modules`), which is where a
* Playwright installed through Claude Code's MCP setup actually lives. */
searched: string[];
}
/** Result of an install action. Mirrors Rust `BrowserSetupOutcome`. */
export interface BrowserSetupOutcome {
/** Fresh probe taken after the install, so the pane can update itself. */
detection: PlaywrightDetection;
/** Tail of the real npm/apt/Playwright output — shown instead of a generic message. */
log: string;
/** Whether a browser was actually started and closed. `null` when the step
* didn't try (the package step doesn't). */
browser_launched: boolean | null;
/** Something that didn't fail the action but the user still needs to know. */
warning: string | null;
}
/** Browsers the pane can install. `chromium` is Playwright's own build;
* `chrome` is the Google Chrome channel `@playwright/mcp` asks for. */
export type BrowserInstallTarget = "chromium" | "chrome";
/** Mirrors Rust `BrowserViewState` (serde snake_case). */
export type BrowserViewState = "off" | "running" | "unavailable";
+89
View File
@@ -78,6 +78,95 @@ RUN curl -fsSL https://deb.nodesource.com/gpgkey/nodesource-repo.gpg.key \
&& rm -rf /var/lib/apt/lists/* \
&& npm install -g pnpm
# ── Browser runtime libraries (Chromium / Google Chrome) ────────────────────
# Chromium links against a set of shared libraries Ubuntu's base image does not
# ship — libnss3, libgbm1, libatk*, libasound2t64, libcups2t64, libpango,
# libdrm2 and friends. Without them `playwright install chromium` downloads a
# browser that then dies at launch with "Host system is missing dependencies:
# libnss3.so", which reads like a Playwright bug and is not one. Installing
# google-chrome-stable used to look like the fix only because apt pulled these
# in as *its* dependencies.
#
# ## Why baked, and why only the libraries
#
# A runtime `apt-get install` lands in the container's writable layer: it is
# re-paid after every project Reset, and it is *lost* on base-image migration,
# which replays apt from a manifest against the new base. The browsers
# themselves live in ~/.cache/ms-playwright, inside the home volume, and survive
# both — so the runtime approach converges on the worst state, a 400 MB browser
# present with its libraries gone. Baking the libraries and leaving the browsers
# out puts each half where it already persists.
#
# Browser binaries are deliberately NOT baked: they are large, they are
# version-coupled to whatever Playwright the user installs, and the home volume
# already keeps them.
#
# ## Why `install-deps` rather than a hand-written apt list
#
# Playwright names its own dependencies, so the list cannot silently rot. That
# matters more than usual on Ubuntu 24.04, whose 64-bit-time_t transition
# renamed a swathe of these packages (libasound2 → libasound2t64, libatk1.0-0 →
# libatk1.0-0t64, libglib2.0-0 → libglib2.0-0t64, …); a hardcoded list drifts
# into "E: Unable to locate package" build failures, and a list that predates a
# new Chromium dependency drifts into exactly the launch failure this layer
# exists to prevent.
#
# Verified on a real `--platform linux/arm64` build of this file, not assumed:
# it resolves and installs there too (99 packages on both arches), and the
# --dry-run assertion below passes. Worth checking rather than assuming:
# Playwright looks its dependency list up under `<distro><version>-<arch>`, so
# arm64 is a separate lookup that could have missed.
#
# ## What it costs
#
# Measured with this layer applied on top of an otherwise identical image
# (linux/amd64, playwright 1.62.1): **+99 packages, +334 MiB unpacked, +119 MiB
# compressed** — the image goes 2950 → 3284 MiB unpacked, 759 → 878 MiB
# compressed. (`docker history` calls the layer 361 MB, i.e. 344 MiB; the
# difference is tar metadata `du` doesn't count.)
#
# Where it goes, by dpkg Installed-Size:
# ~213 MiB libllvm20 + mesa-libgallium + libicu74. Not optional and not
# avoidable by trimming the list: libgbm1, which Chromium genuinely
# needs, Depends on mesa-libgallium, which Depends on libllvm20.
# ~94 MiB Playwright's `tools` group — xvfb and the CJK/emoji fonts. Kept:
# the base image ships no fonts at all, so without them every page
# this feature exists to display renders as tofu, and xvfb is what
# lets a *headed* browser run in here.
# the rest Chromium's own library closure.
#
# An explicit apt list of just `chromium`'s dependencies measures 247 MiB
# installed against install-deps' 341 MiB, so hand-maintaining one would save
# ~94 MiB. Not worth owning the drift; if you disagree, derive the list from
# `install-deps --dry-run chromium` and pin the Playwright version you took it
# from in a comment here.
#
# The retry loop is for the same transient mirror-sync failures the other apt
# layers guard against; install-deps runs its own un-retried `apt-get update`
# internally. `npx --yes` is what makes it non-interactive, and the version it
# resolved is printed so a build log says which Playwright named this set.
#
# Placed immediately after Node (npx is its only prerequisite) and well above
# the shim COPYs, so editing a shim at the bottom of this file does not re-run a
# multi-hundred-megabyte apt install.
#
# `--dry-run` afterwards is the build-time assertion, and it is not decoration:
# on a platform Playwright's table does not cover, `install-deps` prints a
# warning and returns having installed **nothing, with exit status 0**. Without
# this check that failure mode would ship an image whose build log looked clean.
# `--dry-run` exits non-zero if any required package is still missing.
RUN npx --yes playwright@latest --version \
&& ok=0 \
&& for i in 1 2 3 4 5; do \
if npx --yes playwright@latest install-deps chromium; then ok=1; break; fi; \
echo "install-deps failed (attempt $i), retrying in 10s..."; \
rm -rf /var/lib/apt/lists/*; \
sleep 10; \
done \
&& [ "$ok" = 1 ] \
&& npx --yes playwright@latest install-deps --dry-run chromium \
&& rm -rf /var/lib/apt/lists/* /root/.npm
# ── Python 3 + pip + uv + ruff ──────────────────────────────────────────────
RUN for i in 1 2 3 4 5; do \
apt-get -o Acquire::Retries=3 update && break; \