Install one Playwright tree, and notice when a container has two

Setup installed `playwright@latest` and `@playwright/cli@latest` together.
Verified on a real container, that produces a tree that looks right and is
broken: `@playwright/cli@0.1.18` pins `playwright-core@1.63.0-alpha`, npm
hoists it, and `playwright@latest` (1.62.1) nests its own
`playwright-core@1.62.1` beside it. The two cores want different browser
revisions.

The browser step runs the *resolved* — hoisted — CLI, so it downloads
chromium-1237. Every script Claude writes says `require("playwright")`,
gets the nested 1.62.1, and dies with:

  Executable doesn't exist at …/chromium_headless_shell-1234/…

while the pane reports a browser installed, because one is. This is
deterministic, not bad luck: every container set up through the pane
lands in it.

So the viewer package is installed first, and the `playwright` version
installed after it is the one that package pins — read from the manifest
npm just wrote, falling back to `@latest` only if it cannot be read. One
core, one browser revision, both halves agreeing. Re-running "Set up
Playwright" repairs an already-split tree.

Detection now asks the question directly rather than listing a cache: it
asks each resolved copy for `chromium.executablePath()` and whether that
file exists — the viewer's copy *and* the one `require("playwright")`
returns, since those are routinely different. `needs_browser()` covers
"installed but not launchable", and the pane names both halves instead of
saying "install a browser" over a cache that visibly has one.

An absent field is "the probe didn't answer", never "skewed": containers
predating these fields must not be told their browsers are wrong. The
Rust side gets that from Option; the TypeScript mirror needed `!= null`,
which an existing test caught.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-11 09:04:06 -07:00
co-authored by Claude Opus 5
parent 1207a21aae
commit bd72781482
5 changed files with 432 additions and 54 deletions
@@ -69,6 +69,11 @@ const NOTHING: PlaywrightDetection = {
cli_entry: null,
browsers: [],
chrome_channel: null,
chromium_executable: null,
chromium_executable_exists: false,
script_playwright_version: null,
script_chromium_executable: null,
script_chromium_executable_exists: false,
searched: [
"/workspace",
"/usr/lib/node_modules",
@@ -362,6 +367,41 @@ describe("BrowserTab", () => {
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
});
it("names both halves when the installed browser isn\u2019t the one Playwright launches", async () => {
// The cache is full and every script fails \u2014 "install a browser" alone
// would read as nonsense, so the copy has to say which copy wants what.
checkBrowserViewSupport.mockResolvedValue({
...READY,
browsers: ["chromium-1237"],
chromium_executable: "/home/claude/.cache/ms-playwright/chromium-1237/chrome-linux64/chrome",
chromium_executable_exists: true,
script_playwright_version: "1.62.1",
script_chromium_executable:
"/home/claude/.cache/ms-playwright/chromium-1234/chrome-linux64/chrome",
script_chromium_executable_exists: false,
});
render(<BrowserTab project={project} active />);
expect(await screen.findByText(/isn\u2019t the one Playwright launches/i)).toBeInTheDocument();
// Both revisions appear in the explanation: what is installed, and what
// the failing copy actually wants.
expect(screen.getAllByText(/chromium-1237/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/chromium-1234/).length).toBeGreaterThan(0);
expect(screen.getAllByText(/Set up Playwright/).length).toBeGreaterThan(0);
});
it("does not call an unanswered probe a skew", async () => {
// A container older than these fields omits them; unknown is not broken.
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
getBrowserViewStatus.mockResolvedValue(LIVE);
render(<BrowserTab project={project} active />);
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
expect(screen.queryByText(/isn\u2019t the one Playwright launches/i)).toBeNull();
});
it("only offers a window of its own once there is something to watch", async () => {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
render(<BrowserTab project={project} active />);