Browser view: find every Playwright, and set one up in two clicks
Build App / compute-version (pull_request) Successful in 14s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m18s
Build App / build-linux (pull_request) Successful in 6m42s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Build App / compute-version (pull_request) Successful in 14s
Build App / build-macos (pull_request) Successful in 2m30s
Build App / build-windows (pull_request) Successful in 5m18s
Build App / build-linux (pull_request) Successful in 6m42s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Detection missed the npx cache, so a Playwright installed through Claude
Code's MCP setup (`npx @playwright/mcp@latest`, which unpacks into
~/.npm/_npx/<hash>/node_modules and no node_modules at all) was invisible.
The probe now globs that cache alongside the existing roots and reports
every root it consulted.
It also read `has_bind` off whichever manifest resolved first. Verified
that npm does not hoist for global installs and that the `playwright`
wrapper ships no types/types.d.ts, so `npm i -g playwright` made the pane
call a current build "predates browser.bind()". The probe now hops from
the wrapper to its nested playwright-core.
The messages no longer offer `@playwright/mcp` as a way through setup: it
bundles a playwright-core that binds but never `@playwright/cli`, so that
route could not have worked. It is named only for what it does do.
New `install.rs` + two commands do the setup, streaming on the existing
`container-progress` event and re-probing on success:
* playwright + @playwright/cli into /workspace as `claude`, --no-save.
/workspace is not a bind mount (projects mount at
/workspace/{mount_name}), so nothing of the user's is touched, no sudo
is needed, and Node resolves it from scripts in the project.
* A browser, as its own action with the size stated first: apt libraries
as root, then the download, then a real headless launch to prove it
works. The base image ships none of Chromium's shared libraries, which
is why a download could succeed and the browser still not start.
Chromium and the Chrome channel are both offered — @playwright/mcp
asks for `chrome` specifically. A certificate failure is reported as a
container trust-store problem rather than a broken install.
Installing is always user-initiated; opening the tab only probes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KSP2KNPhuWKQ4DL5TZEn3k
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user