Setup installed `playwright@latest` and `@playwright/cli@latest` together. Verified on a real container, that produces a tree that looks right and is broken: `@playwright/cli@0.1.18` pins `playwright-core@1.63.0-alpha`, npm hoists it, and `playwright@latest` (1.62.1) nests its own `playwright-core@1.62.1` beside it. The two cores want different browser revisions. The browser step runs the *resolved* — hoisted — CLI, so it downloads chromium-1237. Every script Claude writes says `require("playwright")`, gets the nested 1.62.1, and dies with: Executable doesn't exist at …/chromium_headless_shell-1234/… while the pane reports a browser installed, because one is. This is deterministic, not bad luck: every container set up through the pane lands in it. So the viewer package is installed first, and the `playwright` version installed after it is the one that package pins — read from the manifest npm just wrote, falling back to `@latest` only if it cannot be read. One core, one browser revision, both halves agreeing. Re-running "Set up Playwright" repairs an already-split tree. Detection now asks the question directly rather than listing a cache: it asks each resolved copy for `chromium.executablePath()` and whether that file exists — the viewer's copy *and* the one `require("playwright")` returns, since those are routinely different. `needs_browser()` covers "installed but not launchable", and the pane names both halves instead of saying "install a browser" over a cache that visibly has one. An absent field is "the probe didn't answer", never "skewed": containers predating these fields must not be told their browsers are wrong. The Rust side gets that from Option; the TypeScript mirror needed `!= null`, which an existing test caught. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1147 lines
46 KiB
Rust
1147 lines
46 KiB
Rust
//! 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 image shipped **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. Current base images bake those libraries in (see `container/
|
|
//! Dockerfile`), so this step is now usually a no-op — but a project stays on
|
|
//! the base image it was first built from until someone migrates it, so the
|
|
//! old case is the *normal* case and has to keep working. Hence: check, then
|
|
//! install only if needed, and say which happened.
|
|
//! * 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 viewer package — installed **first**, and it decides the version of
|
|
/// `playwright` installed after it.
|
|
///
|
|
/// `@playwright/mcp` is deliberately not part of the set: it is Claude's MCP
|
|
/// configuration to make, and it contributes nothing to serving a viewer.
|
|
///
|
|
/// **Order matters here, and `playwright` is deliberately not `@latest`.**
|
|
///
|
|
/// Installing both at `@latest` produces a tree that looks right and is broken.
|
|
/// Verified on a real container: `@playwright/cli@0.1.18` pins
|
|
/// `playwright-core@1.63.0-alpha`, npm hoists that to the root, and
|
|
/// `playwright@latest` (1.62.1) then nests its own `playwright-core@1.62.1`
|
|
/// beside it. The two cores want *different browser revisions*. The browser
|
|
/// step runs the resolved — hoisted — CLI, so it downloads 1237; every script
|
|
/// Claude writes says `require("playwright")`, gets the nested 1.62.1, and dies
|
|
/// with "Executable doesn't exist … chromium_headless_shell-1234". The pane
|
|
/// meanwhile reports a browser installed, because one is.
|
|
///
|
|
/// So the viewer package goes first and its own pinned `playwright` version is
|
|
/// what gets installed second — one core, one browser revision, both halves
|
|
/// agreeing. See [`pinned_playwright_spec`].
|
|
pub const VIEWER_PACKAGE: &str = "@playwright/cli@latest";
|
|
|
|
/// Fallback when the viewer's manifest can't be read: better a possibly-skewed
|
|
/// tree than no Playwright at all, and [`detect`](super::detect) reports the
|
|
/// skew either way.
|
|
pub const PLAYWRIGHT_FALLBACK: &str = "playwright@latest";
|
|
|
|
/// Where the packages are installed. Container storage, not a bind mount — see
|
|
/// the module docs.
|
|
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);
|
|
/// `apt-get install -s` over ~100 already-installed packages. Local work; a
|
|
/// container that cannot answer this in two minutes gets the libraries
|
|
/// installed rather than a hang.
|
|
const DEPS_CHECK_TIMEOUT: Duration = Duration::from_secs(2 * 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 — a few hundred MB, a few minutes on a normal \
|
|
connection. The system libraries it needs are already in current base images; \
|
|
on an older container they are installed first"
|
|
}
|
|
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/cli into {}/node_modules…", INSTALL_DIR),
|
|
);
|
|
|
|
let mut step = npm_install(app, project_id, container_id, VIEWER_PACKAGE).await?;
|
|
if step.exit_code != 0 {
|
|
return Err(format!(
|
|
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
|
|
step.exit_code,
|
|
step.log_or("it produced no output at all")
|
|
));
|
|
}
|
|
|
|
// Second, `playwright` at the version the viewer package pins — see
|
|
// `VIEWER_PACKAGE`. Installing it as `@latest` is what splits the tree.
|
|
let spec = pinned_playwright_spec(container_id).await;
|
|
emit_progress(app, project_id, &format!("Installing {}…", spec));
|
|
let second = npm_install(app, project_id, container_id, &spec).await?;
|
|
if second.exit_code != 0 {
|
|
return Err(format!(
|
|
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
|
|
spec,
|
|
second.exit_code,
|
|
second.log_or("it produced no output at all")
|
|
));
|
|
}
|
|
step.log = merge_logs(step.log, second.log);
|
|
|
|
emit_progress(app, project_id, "Re-checking what the container has…");
|
|
let detection = detect::detect(container_id).await?;
|
|
|
|
// 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();
|
|
// Skew outranks "no browser": a container in that state *has* browsers, and
|
|
// telling someone to install one they can see already installed is how a
|
|
// real user ends up doing it three times.
|
|
if let Some(skew) = detection.skew_message() {
|
|
warning = merge(warning, skew);
|
|
} else if detection.needs_browser() {
|
|
warning = merge(
|
|
warning,
|
|
"Playwright is installed, but this container has no browser to drive yet. Install \
|
|
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,
|
|
})
|
|
}
|
|
|
|
/// One `npm install` of one spec, into [`INSTALL_DIR`], as `claude`.
|
|
///
|
|
/// `env VAR=… cmd` rather than an exec env: it keeps the one exec path in
|
|
/// `docker/exec.rs` untouched, and `env` is a real binary so no shell is
|
|
/// involved. The guard matters because these are `@latest`: current Playwright
|
|
/// has no postinstall (verified — `playwright@1.62.1` declares no `scripts` at
|
|
/// all), but if a future release brings the browser download back, this step
|
|
/// must stay small and the download must stay the step the user asked for.
|
|
async fn npm_install(
|
|
app: &AppHandle,
|
|
project_id: &str,
|
|
container_id: &str,
|
|
spec: &str,
|
|
) -> Result<StepResult, String> {
|
|
let cmd = vec![
|
|
"env".to_string(),
|
|
"PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1".to_string(),
|
|
"npm".to_string(),
|
|
"install".to_string(),
|
|
// Leaves any package.json and lockfile at /workspace untouched.
|
|
"--no-save".to_string(),
|
|
"--no-fund".to_string(),
|
|
"--no-audit".to_string(),
|
|
spec.to_string(),
|
|
];
|
|
run_step(
|
|
app,
|
|
project_id,
|
|
container_id,
|
|
"claude",
|
|
INSTALL_DIR,
|
|
cmd,
|
|
NPM_TIMEOUT,
|
|
)
|
|
.await
|
|
}
|
|
|
|
/// The `playwright` spec to install: the exact version `@playwright/cli`
|
|
/// depends on, so both halves share one `playwright-core`.
|
|
///
|
|
/// Read from the manifest npm just wrote rather than guessed, and falling back
|
|
/// to `@latest` when it can't be read — an unreadable manifest is a reason to
|
|
/// install something, not nothing.
|
|
async fn pinned_playwright_spec(container_id: &str) -> String {
|
|
let script = format!(
|
|
"try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\
|
|
process.stdout.write(d.playwright||'');}}catch(e){{}}",
|
|
INSTALL_DIR
|
|
);
|
|
let (out, _code) = exec_oneshot_as(
|
|
container_id,
|
|
"claude",
|
|
vec!["node".to_string(), "-e".to_string(), script],
|
|
Vec::new(),
|
|
)
|
|
.await
|
|
.unwrap_or_default();
|
|
|
|
let version: &str = out.trim();
|
|
// A version, not a range or a URL: anything else goes to the fallback
|
|
// rather than into an npm command line.
|
|
if !version.is_empty()
|
|
&& version
|
|
.chars()
|
|
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+'))
|
|
{
|
|
format!("playwright@{}", version)
|
|
} else {
|
|
PLAYWRIGHT_FALLBACK.to_string()
|
|
}
|
|
}
|
|
|
|
/// Keep both npm runs' output, so a failure in either is diagnosable.
|
|
fn merge_logs(first: String, second: String) -> String {
|
|
match (first.trim().is_empty(), second.trim().is_empty()) {
|
|
(true, _) => second,
|
|
(_, true) => first,
|
|
_ => format!("{}\n{}", first.trim_end(), second),
|
|
}
|
|
}
|
|
|
|
/// Install a browser: its system libraries first, then the browser, then prove
|
|
/// 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.
|
|
//
|
|
// Current base images bake these in, so on an up-to-date container there is
|
|
// nothing to do here. That is *not* a reason to drop the step: a project
|
|
// keeps the base image it was first built from until it is migrated, so
|
|
// containers without the libraries are the common case for a long while
|
|
// yet. So: ask first, skip loudly, install only when the answer is no.
|
|
//
|
|
// The question is put to Playwright rather than answered by probing for
|
|
// library names ourselves. `install-deps --dry-run` simulates the very
|
|
// `apt-get install` that `install-deps` would run and exits non-zero if
|
|
// anything is missing, which means the check and the fix can never disagree
|
|
// about what "the libraries" means — including after a Playwright release
|
|
// adds one.
|
|
//
|
|
// Installing runs `install-deps` directly *as root*: that is what `playwright
|
|
// install --with-deps` does internally, minus 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 going through the same check
|
|
// first makes the two paths behave identically.
|
|
emit_progress(
|
|
app,
|
|
project_id,
|
|
"Step 1/3 — checking whether this container already has the browser system libraries…",
|
|
);
|
|
let state = check_libraries(container_id, &cli, target).await;
|
|
match &state {
|
|
LibraryState::Present => {
|
|
emit_progress(
|
|
app,
|
|
project_id,
|
|
"Step 1/3 — already there: this image ships the browser system libraries. \
|
|
Skipping the apt install.",
|
|
);
|
|
push_section(&mut log, "Browser system libraries: already installed, apt skipped.");
|
|
}
|
|
LibraryState::Missing(_) | LibraryState::Unknown(_) => {
|
|
emit_progress(
|
|
app,
|
|
project_id,
|
|
&format!(
|
|
"Step 1/3 — {} Installing browser system libraries with apt (needs root; a \
|
|
minute or two)…",
|
|
state.detail()
|
|
),
|
|
);
|
|
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?;
|
|
push_section(&mut log, &deps.log);
|
|
if deps.exit_code != 0 {
|
|
// Not fatal on its own — some of 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,
|
|
})
|
|
}
|
|
|
|
/// Phrases `install-deps --dry-run` prints. Matching on Playwright's own words
|
|
/// is load-bearing: the exit code alone cannot tell "everything is installed"
|
|
/// (0) apart from "this platform isn't in my table, so I did nothing" (also 0).
|
|
const DEPS_OK_MARKER: &str = "All system dependencies are installed";
|
|
const DEPS_MISSING_MARKER: &str = "Missing system dependencies";
|
|
const DEPS_UNKNOWN_PLATFORM_MARKER: &str = "Cannot install dependencies for";
|
|
|
|
/// Whether this container already has the libraries a browser links against.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
enum LibraryState {
|
|
/// Playwright confirms every package it would install is present. Current
|
|
/// base images bake them, so this is the answer on an up-to-date container.
|
|
Present,
|
|
/// Playwright named packages that are absent.
|
|
Missing(String),
|
|
/// The check could not answer. Always installs — an unnecessary apt run
|
|
/// costs a minute, a skipped one costs a browser that will not start.
|
|
Unknown(String),
|
|
}
|
|
|
|
impl LibraryState {
|
|
/// What the user sees on the progress line, and why.
|
|
fn detail(&self) -> &str {
|
|
match self {
|
|
Self::Present => "",
|
|
Self::Missing(d) | Self::Unknown(d) => d,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Ask Playwright whether the libraries are already installed.
|
|
///
|
|
/// `--dry-run` simulates the same `apt-get install` that `install-deps` would
|
|
/// perform and exits non-zero if any package is missing, so the check can never
|
|
/// disagree with the fix about what the dependency set is — including after a
|
|
/// Playwright release changes it.
|
|
///
|
|
/// Note that the simulation works on an image whose `/var/lib/apt/lists` has
|
|
/// been cleaned (every base image's has): apt knows installed packages from
|
|
/// dpkg's status file. A *missing* package on such an image is simply not in
|
|
/// any index, so apt fails, Playwright reports the failure, and this returns
|
|
/// [`LibraryState::Unknown`] — which installs, which is the right answer.
|
|
async fn check_libraries(container_id: &str, cli: &str, target: BrowserTarget) -> LibraryState {
|
|
let run = exec_oneshot_as(
|
|
container_id,
|
|
"root",
|
|
vec![
|
|
"node".to_string(),
|
|
cli.to_string(),
|
|
"install-deps".to_string(),
|
|
"--dry-run".to_string(),
|
|
target.cli_name().to_string(),
|
|
],
|
|
vec![],
|
|
);
|
|
match tokio::time::timeout(DEPS_CHECK_TIMEOUT, run).await {
|
|
Ok(Ok((output, code))) => classify_library_check(&output, code),
|
|
Ok(Err(e)) => LibraryState::Unknown(format!(
|
|
"Couldn't ask Playwright whether they're already there ({}), so installing them to be \
|
|
sure.",
|
|
e
|
|
)),
|
|
Err(_) => LibraryState::Unknown(
|
|
"The check for them didn't finish in time, so installing them to be sure.".to_string(),
|
|
),
|
|
}
|
|
}
|
|
|
|
/// Read the verdict out of `install-deps --dry-run`'s output.
|
|
fn classify_library_check(output: &str, exit_code: i64) -> LibraryState {
|
|
// Checked before the success marker, not after: this branch also exits 0.
|
|
if output.contains(DEPS_UNKNOWN_PLATFORM_MARKER) {
|
|
return LibraryState::Unknown(
|
|
"Playwright doesn't have a dependency list for this container's platform, so it \
|
|
can't say — installing them to be sure."
|
|
.to_string(),
|
|
);
|
|
}
|
|
if exit_code == 0 && output.contains(DEPS_OK_MARKER) {
|
|
return LibraryState::Present;
|
|
}
|
|
if let Some(idx) = output.find(DEPS_MISSING_MARKER) {
|
|
let summary = output[idx..]
|
|
.lines()
|
|
.next()
|
|
.unwrap_or(DEPS_MISSING_MARKER)
|
|
.trim();
|
|
return LibraryState::Missing(format!("Playwright reports {}", summary.to_lowercase()));
|
|
}
|
|
LibraryState::Unknown(format!(
|
|
"Couldn't tell whether they're already there (the check exited {}), so installing them to \
|
|
be sure.",
|
|
exit_code
|
|
))
|
|
}
|
|
|
|
/// 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!(VIEWER_PACKAGE.starts_with("@playwright/cli@"));
|
|
assert!(PLAYWRIGHT_FALLBACK.starts_with("playwright@"));
|
|
assert!(!VIEWER_PACKAGE.contains("@playwright/mcp"));
|
|
}
|
|
|
|
#[test]
|
|
fn playwright_is_not_installed_at_latest_alongside_the_viewer() {
|
|
// `@latest` for both is exactly what splits the tree into two
|
|
// `playwright-core`s wanting different browser revisions — the viewer
|
|
// green, every `require("playwright")` dead. The version comes from the
|
|
// viewer's own manifest instead; `@latest` is only the fallback for an
|
|
// unreadable one.
|
|
assert!(!VIEWER_PACKAGE.contains("playwright@latest"));
|
|
assert_eq!(PLAYWRIGHT_FALLBACK, "playwright@latest");
|
|
}
|
|
|
|
#[test]
|
|
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 baked_in_libraries_are_detected_and_the_apt_step_is_skipped() {
|
|
// What a container built from a current base image says. The whole
|
|
// point of baking them: this must not re-run apt.
|
|
assert_eq!(
|
|
classify_library_check("All system dependencies are installed.\n", 0),
|
|
LibraryState::Present
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_older_image_without_them_is_detected_and_named() {
|
|
let v = classify_library_check(
|
|
"Missing system dependencies (12):\n libnss3\n libgbm1\n",
|
|
1,
|
|
);
|
|
match v {
|
|
LibraryState::Missing(d) => assert!(d.contains("(12)"), "{}", d),
|
|
other => panic!("expected Missing, got {:?}", other),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn an_unrecognised_platform_installs_rather_than_reporting_success() {
|
|
// Playwright prints this and exits **0** having installed nothing, so a
|
|
// check that trusted the exit code would skip the apt step on exactly
|
|
// the container that needs it.
|
|
let v = classify_library_check(
|
|
"Cannot install dependencies for ubuntu24.04-riscv64 with Playwright 1.62.1!\n",
|
|
0,
|
|
);
|
|
assert!(matches!(v, LibraryState::Unknown(_)), "{:?}", v);
|
|
}
|
|
|
|
#[test]
|
|
fn a_check_that_could_not_run_installs_rather_than_guessing() {
|
|
// e.g. apt cannot resolve a package because the image's package index
|
|
// was cleaned and the package is genuinely absent.
|
|
let v = classify_library_check("E: Unable to locate package libgbm1\n", 100);
|
|
match v {
|
|
LibraryState::Unknown(d) => assert!(d.contains("100"), "{}", d),
|
|
other => panic!("expected Unknown, got {:?}", other),
|
|
}
|
|
// Present contributes nothing to the progress line; the other two must
|
|
// explain themselves, because the reason is shown to the user.
|
|
assert_eq!(LibraryState::Present.detail(), "");
|
|
assert!(!LibraryState::Unknown("why".into()).detail().is_empty());
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|