Merge remote-tracking branch 'origin/main' into feature/corporate-ca
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 5m20s
Build Container / build-container (pull_request) Successful in 9m58s
Build App / build-linux (pull_request) Successful in 5m12s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
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 5m20s
Build Container / build-container (pull_request) Successful in 9m58s
Build App / build-linux (pull_request) Successful in 5m12s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
# Conflicts: # app/src/lib/tauri-commands.ts
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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: {}.",
|
||||
if self.searched.is_empty() {
|
||||
"the container's default module paths".to_string()
|
||||
} else {
|
||||
self.searched.join(", ")
|
||||
}
|
||||
"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()`). Upgrade with `npm i -D playwright@latest` and restart \
|
||||
the browser Claude is driving.",
|
||||
self.playwright_version.as_deref().unwrap_or("?")
|
||||
"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(
|
||||
"Playwright is installed, but the viewer UI package isn't. Install it with \
|
||||
`npm i -D @playwright/cli`, then reopen this tab."
|
||||
.to_string(),
|
||||
);
|
||||
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(", ")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
@@ -63,6 +63,7 @@
|
||||
|
||||
pub mod commands;
|
||||
pub mod detect;
|
||||
pub mod install;
|
||||
pub mod proxy;
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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 isn’t 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,
|
||||
|
||||
@@ -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 isn’t 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 didn’t 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">
|
||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
||||
This container can’t serve a browser view yet
|
||||
</h2>
|
||||
<p className="text-[13px] text-[var(--text-secondary)] leading-relaxed">
|
||||
{status.message}
|
||||
</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 className="p-4 max-w-[46rem] space-y-4">
|
||||
<div>
|
||||
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
|
||||
{!havePackages
|
||||
? "This container can’t serve a browser view yet"
|
||||
: noBrowser
|
||||
? "Playwright is ready — but there’s no browser to drive yet"
|
||||
: "This container is set up"}
|
||||
</h2>
|
||||
<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>
|
||||
</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> — Playwright’s
|
||||
own build, used by <Code>chromium.launch()</Code> with no channel. Several
|
||||
hundred MB.
|
||||
</li>
|
||||
<li>
|
||||
<strong className="text-[var(--text-primary)]">Chrome channel</strong> — Google
|
||||
Chrome from apt, which is what <Code>@playwright/mcp</Code> asks for. Install
|
||||
this one if Claude drives the browser through the MCP plugin. Roughly 150 MB.
|
||||
</li>
|
||||
</ul>
|
||||
</Step>
|
||||
|
||||
{busy && (
|
||||
<p
|
||||
className="text-xs font-mono text-[var(--text-secondary)] break-all"
|
||||
aria-live="polite"
|
||||
>
|
||||
{progress ?? "Working…"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="text-xs text-[var(--error)]">
|
||||
<p className="font-semibold">That didn’t work.</p>
|
||||
<pre className="mt-1 whitespace-pre-wrap font-mono break-words text-[var(--text-secondary)]">
|
||||
{error}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{outcome?.warning && (
|
||||
<div className="text-xs text-[var(--text-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-3">
|
||||
<p className="font-semibold">Worth knowing</p>
|
||||
<p className="mt-1 whitespace-pre-wrap text-[var(--text-secondary)] leading-relaxed">
|
||||
{outcome.warning}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{outcome?.log && (
|
||||
<AccordionSection
|
||||
id="browser-view-install-log"
|
||||
title="Install output"
|
||||
defaultOpen={false}
|
||||
>
|
||||
<pre className="p-3 text-xs font-mono whitespace-pre-wrap break-words text-[var(--text-secondary)] max-h-64 overflow-y-auto">
|
||||
{outcome.log}
|
||||
</pre>
|
||||
</AccordionSection>
|
||||
)}
|
||||
|
||||
{detection && (
|
||||
<dl className="text-xs grid grid-cols-[auto_1fr] gap-x-3 gap-y-1 pt-3 border-t border-[var(--border-color)]">
|
||||
<Detail label="Node.js" value={detection.node_version} />
|
||||
<Detail label="Playwright" value={detection.playwright_version} />
|
||||
<Detail label="Resolved from" value={detection.playwright_path} />
|
||||
<Detail
|
||||
label="browser.bind()"
|
||||
value={detection.has_bind ? "available" : "not in this build"}
|
||||
/>
|
||||
<Detail label="@playwright/cli" value={detection.cli_version} />
|
||||
<Detail
|
||||
label="Browsers"
|
||||
value={browsers.length > 0 ? browsers.join(", ") : null}
|
||||
/>
|
||||
<Detail label="Chrome channel" value={chrome} />
|
||||
{detection.searched.length > 0 && (
|
||||
<Detail label="Searched" value={detection.searched.join(", ")} />
|
||||
)}
|
||||
</dl>
|
||||
)}
|
||||
@@ -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 (
|
||||
<>
|
||||
|
||||
@@ -31,6 +31,22 @@ vi.mock("@tauri-apps/api/event", () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
/** Every event the hook subscribes to, so the unmount test counts the right
|
||||
* number of teardowns instead of a magic number that drifts. */
|
||||
const EVENT_NAMES = [
|
||||
"claude-token-progress",
|
||||
"claude-token-output",
|
||||
"claude-token-link",
|
||||
"claude-token-code-rejected",
|
||||
];
|
||||
|
||||
/** The sign-in URL at its real length (346 characters, measured against
|
||||
* Claude Code 2.1.226) and the 80-column slice of it that is all the visible
|
||||
* transcript ever contains. */
|
||||
const FULL_URL =
|
||||
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
|
||||
const TRUNCATED_URL = FULL_URL.slice(0, 80);
|
||||
|
||||
function emitOutput(chunk: string, projectId = "p1") {
|
||||
act(() => {
|
||||
handlers.get("claude-token-output")?.({
|
||||
@@ -39,6 +55,26 @@ function emitOutput(chunk: string, projectId = "p1") {
|
||||
});
|
||||
}
|
||||
|
||||
function emitLink(url: string, projectId = "p1") {
|
||||
act(() => {
|
||||
handlers.get("claude-token-link")?.({
|
||||
payload: { project_id: projectId, url },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function emitCodeRejected(message: string, attemptsRemaining: number) {
|
||||
act(() => {
|
||||
handlers.get("claude-token-code-rejected")?.({
|
||||
payload: {
|
||||
project_id: "p1",
|
||||
message,
|
||||
attempts_remaining: attemptsRemaining,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderModal(
|
||||
overrides: { onClose?: () => void; onAuthenticated?: () => void } = {},
|
||||
) {
|
||||
@@ -200,6 +236,93 @@ describe("ClaudeAuthModal", () => {
|
||||
const { unmount } = renderModal();
|
||||
await flowStarted();
|
||||
unmount();
|
||||
await waitFor(() => expect(unlisten).toHaveBeenCalledTimes(2));
|
||||
await waitFor(() =>
|
||||
expect(unlisten).toHaveBeenCalledTimes(EVENT_NAMES.length),
|
||||
);
|
||||
});
|
||||
|
||||
// ── The hyperlink target, not the wrapped display text ────────────────
|
||||
//
|
||||
// `claude setup-token` slices the *visible* text of its OSC 8 hyperlink to
|
||||
// the terminal width, so the transcript holds five 80-character pieces of a
|
||||
// 346-character URL. The backend lifts the whole thing out of the hyperlink
|
||||
// parameter and sends it on `claude-token-link`.
|
||||
|
||||
it("prefers the hyperlink target over the wrapped copy in the transcript", async () => {
|
||||
renderModal();
|
||||
await flowStarted();
|
||||
|
||||
// What the transcript holds: the first slice only.
|
||||
emitOutput(`Browser didn't open? Use the url below to sign in\n${TRUNCATED_URL}\n`);
|
||||
// What the hyperlink parameter holds: all of it.
|
||||
emitLink(FULL_URL);
|
||||
|
||||
const link = await screen.findByRole("link", { name: FULL_URL });
|
||||
fireEvent.click(link);
|
||||
await waitFor(() => expect(openUrl).toHaveBeenCalledWith(FULL_URL));
|
||||
expect(openUrl).not.toHaveBeenCalledWith(TRUNCATED_URL);
|
||||
});
|
||||
|
||||
it("refuses a hyperlink target that is not an Anthropic sign-in address", async () => {
|
||||
renderModal();
|
||||
await flowStarted();
|
||||
|
||||
emitLink("https://evil.tld/cai/oauth/authorize?code=true");
|
||||
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
expect(openUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("ignores a hyperlink belonging to a different project", async () => {
|
||||
renderModal();
|
||||
await flowStarted();
|
||||
|
||||
emitLink(FULL_URL, "p2");
|
||||
expect(screen.queryByRole("link")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// ── A refused code is recoverable, not a hang ─────────────────────────
|
||||
|
||||
it("reports a rejected code and lets another one be submitted", async () => {
|
||||
renderModal();
|
||||
await flowStarted();
|
||||
|
||||
const input = screen.getByLabelText("Authentication code");
|
||||
fireEvent.change(input, { target: { value: "truncated" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
||||
await waitFor(() =>
|
||||
expect(submitClaudeTokenCode).toHaveBeenCalledWith("truncated"),
|
||||
);
|
||||
// Before the rejection arrives the UI claims the sign-in is completing.
|
||||
expect(screen.getByText("Finishing sign-in")).toBeInTheDocument();
|
||||
|
||||
emitCodeRejected(
|
||||
"That code was rejected — `claude setup-token` reports the full code was not copied. Copy it again from the Anthropic page and submit it; 2 attempts left.",
|
||||
2,
|
||||
);
|
||||
|
||||
// Reported, not waited out — and the flow is still live.
|
||||
await screen.findByText(/That code was rejected/);
|
||||
expect(screen.getByText("Code rejected — try again")).toBeInTheDocument();
|
||||
expect(screen.queryByText("Finishing sign-in")).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId("claude-auth-error")).not.toBeInTheDocument();
|
||||
|
||||
// A second code goes through without restarting the whole flow.
|
||||
fireEvent.change(input, { target: { value: "the-whole-code" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Submit code" }));
|
||||
await waitFor(() =>
|
||||
expect(submitClaudeTokenCode).toHaveBeenLastCalledWith("the-whole-code"),
|
||||
);
|
||||
expect(acquireClaudeToken).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("ends with a reported failure when the retries run out", async () => {
|
||||
acquireClaudeToken.mockRejectedValue(
|
||||
"`claude setup-token` rejected the code 3 times, so the sign-in was abandoned. No token was stored.",
|
||||
);
|
||||
renderModal();
|
||||
|
||||
const banner = await screen.findByTestId("claude-auth-error");
|
||||
expect(banner).toHaveTextContent(/rejected the code 3 times/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -27,6 +27,9 @@ interface Props {
|
||||
const PHASE_STATUS: Record<string, { tone: StatusTone; label: string }> = {
|
||||
waiting: { tone: "busy", label: "Waiting for sign-in" },
|
||||
finishing: { tone: "busy", label: "Finishing sign-in" },
|
||||
// The CLI refused a code and is back at its prompt. Distinct from "failed":
|
||||
// the flow is still live and another code will be accepted.
|
||||
rejected: { tone: "error", label: "Code rejected — try again" },
|
||||
succeeded: { tone: "ok", label: "Token stored" },
|
||||
failed: { tone: "error", label: "Authentication failed" },
|
||||
};
|
||||
@@ -88,7 +91,9 @@ export default function ClaudeAuthModal({
|
||||
? PHASE_STATUS.failed
|
||||
: flow.codeSubmitted
|
||||
? PHASE_STATUS.finishing
|
||||
: PHASE_STATUS.waiting;
|
||||
: flow.codeRejections > 0
|
||||
? PHASE_STATUS.rejected
|
||||
: PHASE_STATUS.waiting;
|
||||
|
||||
// Split for display only. `flow.signInUrl` has already passed the host
|
||||
// allowlist; this decides which half of it an ellipsis is allowed to eat.
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { authErrorMessage, extractSignInUrl } from "./useClaudeAuth";
|
||||
import {
|
||||
authErrorMessage,
|
||||
extractSignInUrl,
|
||||
pickSignInUrl,
|
||||
} from "./useClaudeAuth";
|
||||
|
||||
describe("extractSignInUrl", () => {
|
||||
it("finds the authorize URL in realistic setup-token output", () => {
|
||||
@@ -79,6 +83,64 @@ describe("extractSignInUrl", () => {
|
||||
const second = "https://platform.claude.com/oauth/authorize?code=true&more=1";
|
||||
expect(extractSignInUrl(`${first}\n${second}\n`)).toBe(first);
|
||||
});
|
||||
|
||||
// ── Why the scraper is only the fallback ─────────────────────────────────
|
||||
// `claude setup-token` emits the URL as an OSC 8 hyperlink and slices the
|
||||
// *visible* text of it to the terminal width, so the transcript holds five
|
||||
// 80-character pieces of a 346-character URL. Each piece is a valid,
|
||||
// Anthropic-hosted, oauth-looking URL — and none of them authorises
|
||||
// anything.
|
||||
|
||||
it("cannot recover a URL the CLI sliced across lines, which is why the hyperlink wins", () => {
|
||||
const slices = [
|
||||
FULL_URL.slice(0, 80),
|
||||
FULL_URL.slice(80, 160),
|
||||
FULL_URL.slice(160, 240),
|
||||
FULL_URL.slice(240, 320),
|
||||
FULL_URL.slice(320),
|
||||
];
|
||||
const scraped = extractSignInUrl(slices.join("\n"));
|
||||
|
||||
// Documenting the limit, not endorsing it: the pieces share no prefix, so
|
||||
// the "extends the current pick" rule cannot join them, and guessing at
|
||||
// line joins on an untrusted stream is not on the table.
|
||||
expect(scraped).toBe(slices[0]);
|
||||
expect(scraped).not.toBe(FULL_URL);
|
||||
|
||||
// The hyperlink parameter carries the whole thing, and that is what the
|
||||
// hook prefers.
|
||||
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
|
||||
});
|
||||
});
|
||||
|
||||
/** The real sign-in URL, at its measured length (346 characters, Claude Code
|
||||
* 2.1.226). */
|
||||
const FULL_URL =
|
||||
"https://claude.com/cai/oauth/authorize?code=true&client_id=9d1c250a-e61b-44d9-88ed-5944d1962f5e&response_type=code&redirect_uri=https%3A%2F%2Fplatform.claude.com%2Foauth%2Fcode%2Fcallback&scope=user%3Ainference&code_challenge=RUX5MlWvwld1dmpvF_aPIJQWMBmffuJt4dOdL13zWAg&code_challenge_method=S256&state=su-x9PgZzvkBd3-um6G1llLNDgxptyO6HERvvCSrTbg";
|
||||
|
||||
describe("pickSignInUrl", () => {
|
||||
it("keeps a 346-character authorize URL intact", () => {
|
||||
expect(FULL_URL).toHaveLength(346);
|
||||
expect(pickSignInUrl([FULL_URL])).toBe(FULL_URL);
|
||||
});
|
||||
|
||||
it("applies the same host allowlist to a hyperlink target", () => {
|
||||
// An OSC 8 parameter is container output like anything else, and it is
|
||||
// never displayed — so it is the *easier* place to hide a hostile host.
|
||||
expect(pickSignInUrl(["https://evil.tld/cai/oauth/authorize"])).toBeNull();
|
||||
expect(
|
||||
pickSignInUrl(["https://claude.ai@evil.tld/oauth/authorize"]),
|
||||
).toBeNull();
|
||||
expect(pickSignInUrl(["javascript:alert(1)"])).toBeNull();
|
||||
expect(pickSignInUrl([])).toBeNull();
|
||||
});
|
||||
|
||||
it("does not let a later hyperlink displace the one already shown", () => {
|
||||
const real = `${FULL_URL}`;
|
||||
const spoof = "https://claude.com.evil.tld/cai/oauth/authorize?code=true";
|
||||
expect(pickSignInUrl([real, spoof])).toBe(real);
|
||||
expect(pickSignInUrl([spoof, real])).toBe(real);
|
||||
});
|
||||
});
|
||||
|
||||
describe("authErrorMessage", () => {
|
||||
|
||||
@@ -3,6 +3,8 @@ import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { ANTHROPIC_SIGN_IN_HOSTS, sanitizeRelayUrl } from "../lib/urlRelay";
|
||||
import type {
|
||||
ClaudeTokenCodeRejectedEvent,
|
||||
ClaudeTokenLinkEvent,
|
||||
ClaudeTokenOutputEvent,
|
||||
ClaudeTokenProgressEvent,
|
||||
} from "../lib/types";
|
||||
@@ -19,10 +21,17 @@ import type {
|
||||
/** Emitted by `auth_token_commands.rs`; payload shapes live in `lib/types.ts`. */
|
||||
const PROGRESS_EVENT = "claude-token-progress";
|
||||
const OUTPUT_EVENT = "claude-token-output";
|
||||
const LINK_EVENT = "claude-token-link";
|
||||
const CODE_REJECTED_EVENT = "claude-token-code-rejected";
|
||||
|
||||
/** Bound on the retained transcript. The tail is the interesting part. */
|
||||
const MAX_OUTPUT = 64 * 1024;
|
||||
|
||||
/** Bound on retained sign-in candidates. The backend already deduplicates
|
||||
* consecutive repeats; this stops a container that prints a fresh hyperlink
|
||||
* every frame from growing state without limit. */
|
||||
const MAX_LINKS = 16;
|
||||
|
||||
/**
|
||||
* Tauri rejects an `invoke` with the Rust `Err(String)` itself, and this
|
||||
* backend writes its errors as complete, actionable sentences ("The container
|
||||
@@ -38,13 +47,13 @@ export function authErrorMessage(e: unknown, fallback: string): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the sign-in URL out of `claude setup-token`'s transcript.
|
||||
* Choose one sign-in URL from a list of candidates.
|
||||
*
|
||||
* **The transcript is container output, so every candidate here is
|
||||
* attacker-controlled if the sandboxed agent misbehaves.** It is then rendered
|
||||
* under a heading that says "Sign in with Anthropic" and handed to the host
|
||||
* browser, which makes this the highest-value URL in the app to spoof: a user
|
||||
* who follows it types their real Anthropic credentials into whatever it
|
||||
* **Every candidate is container output, so all of them are
|
||||
* attacker-controlled if the sandboxed agent misbehaves.** The winner is
|
||||
* rendered under a heading that says "Sign in with Anthropic" and handed to the
|
||||
* host browser, which makes this the highest-value URL in the app to spoof: a
|
||||
* user who follows it types their real Anthropic credentials into whatever it
|
||||
* resolves to. Three rules follow, and none of them are optional:
|
||||
*
|
||||
* - Every candidate goes through the shared {@link sanitizeRelayUrl}, with a
|
||||
@@ -60,14 +69,8 @@ export function authErrorMessage(e: unknown, fallback: string): string {
|
||||
* the complete one — and it cannot swap the origin, because a longer string
|
||||
* with the same prefix has the same host.
|
||||
*/
|
||||
export function extractSignInUrl(text: string): string | null {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const matches = text.match(/https?:\/\/[^\s"'`<>\x00-\x20\x7f]+/g);
|
||||
if (!matches) return null;
|
||||
|
||||
const cleaned = matches
|
||||
// Trailing punctuation belongs to the prose, not the URL.
|
||||
.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, ""))
|
||||
export function pickSignInUrl(candidates: readonly string[]): string | null {
|
||||
const cleaned = candidates
|
||||
.map((url) => sanitizeRelayUrl(url, { allowHosts: ANTHROPIC_SIGN_IN_HOSTS }))
|
||||
.filter((url): url is string => url !== null);
|
||||
|
||||
@@ -81,6 +84,33 @@ export function extractSignInUrl(text: string): string | null {
|
||||
return best;
|
||||
}
|
||||
|
||||
/**
|
||||
* Scrape a sign-in URL out of `claude setup-token`'s visible transcript.
|
||||
*
|
||||
* **This is the fallback, not the primary route.** The CLI emits the URL as an
|
||||
* OSC 8 hyperlink and slices the *visible* text of that hyperlink to the
|
||||
* terminal width — measured at 80 columns, a 346-character URL arrives as five
|
||||
* 80-character pieces on five lines. Nothing scraping the visible text can put
|
||||
* those back together: the pieces share no prefix, so the "extends the current
|
||||
* pick" rule cannot join them, and joining adjacent lines by guesswork on an
|
||||
* untrusted stream is exactly the sort of thing the rules above exist to
|
||||
* forbid. What comes out is the first 80 characters — a URL that parses, that
|
||||
* points at claude.com, and that cannot authorise anything.
|
||||
*
|
||||
* So the backend lifts the whole URL out of the hyperlink parameter and sends
|
||||
* it on `claude-token-link`, and {@link useClaudeTokenAcquisition} prefers that.
|
||||
* This remains for CLI versions that print a bare URL with no hyperlink at all,
|
||||
* where a URL narrow enough not to wrap is recovered correctly.
|
||||
*/
|
||||
export function extractSignInUrl(text: string): string | null {
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const matches = text.match(/https?:\/\/[^\s"'`<>\x00-\x20\x7f]+/g);
|
||||
if (!matches) return null;
|
||||
|
||||
// Trailing punctuation belongs to the prose, not the URL.
|
||||
return pickSignInUrl(matches.map((url) => url.replace(/[.,;:!?)\]}>'"]+$/, "")));
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Token presence
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -133,6 +163,12 @@ export interface ClaudeTokenAcquisition {
|
||||
submitting: boolean;
|
||||
codeSubmitted: boolean;
|
||||
submitError: string | null;
|
||||
/**
|
||||
* How many codes `claude setup-token` has refused. Non-zero means the CLI is
|
||||
* still alive and waiting for another one — a recoverable state, not the end
|
||||
* of the flow.
|
||||
*/
|
||||
codeRejections: number;
|
||||
submitCode: (code: string) => Promise<boolean>;
|
||||
}
|
||||
|
||||
@@ -154,6 +190,13 @@ export function useClaudeTokenAcquisition(
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [codeSubmitted, setCodeSubmitted] = useState(false);
|
||||
const [submitError, setSubmitError] = useState<string | null>(null);
|
||||
const [codeRejections, setCodeRejections] = useState(0);
|
||||
// Candidates from `claude-token-link`, in arrival order. Kept as a list
|
||||
// rather than a single value so `pickSignInUrl` applies the same first-wins
|
||||
// rule here as it does to the scraped transcript — the CLI reprints the same
|
||||
// hyperlink after every retry, and a *different* one arriving later must not
|
||||
// be able to displace the one the user was already shown.
|
||||
const [links, setLinks] = useState<string[]>([]);
|
||||
|
||||
// Held in a ref so a fresh callback identity cannot restart the flow.
|
||||
const succeededRef = useRef(onSucceeded);
|
||||
@@ -193,6 +236,26 @@ export function useClaudeTokenAcquisition(
|
||||
: next;
|
||||
});
|
||||
});
|
||||
await register<ClaudeTokenLinkEvent>(LINK_EVENT, (payload) => {
|
||||
if (payload.project_id !== projectId) return;
|
||||
setLinks((prev) =>
|
||||
prev.includes(payload.url) || prev.length >= MAX_LINKS
|
||||
? prev
|
||||
: [...prev, payload.url],
|
||||
);
|
||||
});
|
||||
await register<ClaudeTokenCodeRejectedEvent>(
|
||||
CODE_REJECTED_EVENT,
|
||||
(payload) => {
|
||||
if (payload.project_id !== projectId) return;
|
||||
// The CLI is alive and back at its prompt, so this is a correction
|
||||
// the user can act on — not a failure. Re-open the input and say
|
||||
// why, rather than leaving "Finishing sign-in" on screen forever.
|
||||
setCodeRejections((n) => n + 1);
|
||||
setCodeSubmitted(false);
|
||||
setSubmitError(payload.message);
|
||||
},
|
||||
);
|
||||
} catch (e) {
|
||||
if (cancelled) return;
|
||||
setPhase("failed");
|
||||
@@ -261,7 +324,13 @@ export function useClaudeTokenAcquisition(
|
||||
}
|
||||
}, []);
|
||||
|
||||
const signInUrl = useMemo(() => extractSignInUrl(output), [output]);
|
||||
// The hyperlink parameter wins whenever there is one: it is the only place
|
||||
// the CLI emits the URL contiguously. Scraping the visible text is the
|
||||
// fallback for versions that print a bare URL — see `extractSignInUrl`.
|
||||
const signInUrl = useMemo(
|
||||
() => pickSignInUrl(links) ?? extractSignInUrl(output),
|
||||
[links, output],
|
||||
);
|
||||
|
||||
return {
|
||||
phase,
|
||||
@@ -272,6 +341,7 @@ export function useClaudeTokenAcquisition(
|
||||
submitting,
|
||||
codeSubmitted,
|
||||
submitError,
|
||||
codeRejections,
|
||||
submitCode,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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, CaCertInfo } 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, CaCertInfo } from "./types";
|
||||
|
||||
// Docker
|
||||
export const checkDocker = () => invoke<boolean>("check_docker");
|
||||
@@ -182,6 +182,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
|
||||
|
||||
+47
-1
@@ -458,14 +458,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";
|
||||
|
||||
@@ -526,6 +552,26 @@ export interface ClaudeTokenOutputEvent {
|
||||
chunk: string;
|
||||
}
|
||||
|
||||
/** Payload of the `claude-token-link`: a sign-in URL taken from an OSC 8
|
||||
* hyperlink parameter, which is the only place the CLI emits it whole — the
|
||||
* visible text is sliced to the terminal width. **Untrusted**: it is container
|
||||
* output, so it goes through `sanitizeRelayUrl` with the
|
||||
* `ANTHROPIC_SIGN_IN_HOSTS` allowlist before it is shown or opened. */
|
||||
export interface ClaudeTokenLinkEvent {
|
||||
project_id: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
/** Payload of `claude-token-code-rejected`: `claude setup-token` refused the
|
||||
* submitted code and is parked waiting for another one. The flow is still
|
||||
* alive, so this is recoverable — `attempts_remaining` is how many more codes
|
||||
* the backend will pass on before giving up. */
|
||||
export interface ClaudeTokenCodeRejectedEvent {
|
||||
project_id: string;
|
||||
message: string;
|
||||
attempts_remaining: number;
|
||||
}
|
||||
|
||||
// ── Container base-image migration ───────────────────────────────────────────
|
||||
//
|
||||
// A project's container is created from its own `triple-c-snapshot-<id>:latest`
|
||||
|
||||
Reference in New Issue
Block a user