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
@@ -283,7 +283,9 @@ export default function BrowserTab({ project, active }: Props) {
// 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;
probed !== null &&
probed.chrome_channel === null &&
(probed.browsers.length === 0 || revisionSkew(probed));
const needsSetup = probed !== null && (!ready || needsBrowser);
return (
@@ -422,6 +424,47 @@ function isUsable(d: PlaywrightDetection | null): boolean {
return d !== null && d.playwright_version !== null && d.has_bind && d.cli_entry !== null;
}
/**
* Mirrors Rust `PlaywrightDetection::revision_skew`.
*
* Browsers are installed, but not the revision one of the two Playwright copies
* would launch — so the cache looks full and launches fail. A probe that didn't
* answer leaves the executable null, and "unknown" must not read as "broken".
*/
function revisionSkew(d: PlaywrightDetection | null): boolean {
if (!d || d.browsers.length === 0) return false;
// `!= null`, not `!== null`: a probe from a container that predates these
// fields omits them entirely, and `undefined` is "didn't answer" — which must
// never render as "your browsers are wrong".
const viewerBroken = d.chromium_executable != null && !d.chromium_executable_exists;
const scriptsBroken =
d.script_chromium_executable != null && !d.script_chromium_executable_exists;
return viewerBroken || scriptsBroken;
}
/**
* The skew sentence, naming both halves.
*
* "Install a browser" over a cache that visibly already holds one reads as
* nonsense, so the copy has to say which copy of Playwright wants what.
*/
function skewText(d: PlaywrightDetection | null): string {
if (!d) return "";
const scriptsBroken =
d.script_chromium_executable !== null && !d.script_chromium_executable_exists;
const [version, wanted] = scriptsBroken
? [d.script_playwright_version, d.script_chromium_executable]
: [d.playwright_version, d.chromium_executable];
return (
`This container has ${d.browsers.join(", ")}, but ` +
`${scriptsBroken ? 'the Playwright a script gets from require("playwright")' : "the Playwright serving the viewer"}` +
`${version ?? "?"} — launches ${wanted ?? "?"}, which isnt there. ` +
(scriptsBroken
? "Two copies ended up in one tree, each pinning its own browser revision, so the viewer works and every script Claude writes fails. Re-run “Set up Playwright” to reinstall them as one consistent set."
: "Install Chromium below: it runs that builds own installer, so it fetches exactly the revision that is missing.")
);
}
/** What the container is short of, as a list rather than as prose. */
function missingParts(d: PlaywrightDetection | null): string[] {
if (!d) return [];
@@ -469,6 +512,10 @@ function Setup({
const browsers = detection?.browsers ?? [];
const chrome = detection?.chrome_channel ?? null;
const noBrowser = browsers.length === 0 && chrome === null;
// Installed browsers that cannot be launched. Handled apart from `noBrowser`
// because the fix is the same button but the sentence must not be "install a
// browser" over a cache that visibly has one.
const skew = revisionSkew(detection) && chrome === null;
return (
<div className="p-4 max-w-[46rem] space-y-4">
@@ -476,17 +523,21 @@ function Setup({
<h2 className="text-[13px] font-semibold text-[var(--text-primary)]">
{!havePackages
? "This container cant serve a browser view yet"
: noBrowser
? "Playwright is ready — but theres no browser to drive yet"
: "This container is set up"}
: skew
? "The installed browser isnt the one Playwright launches"
: noBrowser
? "Playwright is ready — but theres 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.")}
: skew
? skewText(detection)
: 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>