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:
@@ -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 />);
|
||||
|
||||
@@ -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 isn’t 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 build’s 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 can’t serve a browser view yet"
|
||||
: noBrowser
|
||||
? "Playwright is ready — but there’s no browser to drive yet"
|
||||
: "This container is set up"}
|
||||
: skew
|
||||
? "The installed browser isn’t the one Playwright launches"
|
||||
: 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.")}
|
||||
: 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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user