Files
Triple-C/app/src/components/projects/home/BrowserTab.test.tsx
T
shadow-testandClaude Opus 5 7e1f8df1ff Reconcile the frontend with the round-1 backend contracts
Five backend branches merged and the TypeScript still compiled, because
none of this is a type error: a field that arrives `undefined`, a variant
nothing emits any more, a prompt whose loop never closes. Six things.

**Orphaned volumes are destructive now, not safe.** `ReclaimTarget::
OrphanVolume` is gone from Rust; the object is a `DestructiveTarget::
OrphanVolume { name, project_id }` confirmed against the *volume's* name,
there being no project to name. The TS union still listed it under
`ReclaimTarget`, and — worse — `DiskProjectTable` keys destructive items
off `project_id`, which an orphan's never matches. So the item existed in
the plan and appeared nowhere on screen. `DiskSettings` now splits the
plan's destructive list and gives orphans their own section with a
per-volume `TypedConfirmModal`. The copy says what a
`triple-c-claude-config-*` volume actually is — a Claude login
credential, every plugin and skill, every transcript that project had —
and keeps the sentence explaining that "no matching project" is a lookup
against the project list and is never inferred from a project being
stopped or having no image, which is the inference that once flagged two
live projects.

`TypedConfirmModal` grew a `subject` prop: asking a user for "the exact
project name" of a volume that has no project is asking for a string that
does not exist.

**Snapshot and Total reconcile.** `ProjectDiskRow.snapshot_attributed_bytes`
is the single figure `snapshot_attribution()` exists to produce. The
column rendered `snapshot_above_base_bytes` and fell back to `—` while
the Total was `size - shared` regardless — and in that branch `size -
shared` is the whole 4.7 GB base image, charged per project and then
added again as a base-image row. One field, one rule. The one branch
where the figure *is* the whole image says so rather than passing itself
off as a share.

**The overwrite loop closes.** Traced end to end: a `FILE_EXISTS:`
refusal raises the prompt, Replace re-invokes with `overwrite: true`,
Skip advances, "…all" answers the rest without asking, and picker and
host-drop both reach `uploadFileToContainer` through `uploadPaths`. Two
gaps: a second batch's `askOverwrite` overwrote the first's resolver,
leaving that batch awaiting an answer no dialog could produce; and the
backend's written refusals — a hidden host folder, a path outside the
write roots — were passed as a toast `detail`, which `ToastHost` renders
as collapsed monospace behind a "Details" button, so the only sentence
that explained anything was the part nobody saw. `readableRefusal`
promotes it to the headline when a batch failed the same way.

**The browser pane's sandbox is pinned.** `allow-same-origin` must stay
(the proxy's gate reads `Origin`/`Referer`, and an opaque origin sends
`null`); every top-navigation grant and `allow-popups-to-escape-sandbox`
must stay absent, and the test names the offending token rather than
printing a set diff.

**`@tauri-apps/plugin-store` is gone** from `package.json` — its
capability grants were removed as a host-file-write primitive and nothing
in `app/src` imports it. The lockfile was updated with
`--package-lock-only`, deliberately: `node_modules` is a symlink shared
with other worktrees and a real install would have pulled it out from
under them.

Nothing under `src-tauri/` is touched. 663 frontend tests pass (was 635),
`tsc --noEmit` clean, `npm run build` green, `cargo test` 446 unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 12:02:34 -07:00

628 lines
25 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import BrowserTab from "./BrowserTab";
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 openBrowserViewPopout = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
const closeBrowserViewPopout = vi.fn<(id: string) => Promise<void>>();
const getBrowserViewPopoutState =
vi.fn<() => Promise<{ open: boolean; always_on_top: boolean }>>();
const setBrowserViewPopoutAlwaysOnTop = vi.fn<(id: string, onTop: boolean) => Promise<void>>();
const openPageInContainerBrowser =
vi.fn<(id: string, url: string, w: number, h: number) => Promise<{ error: string | null }>>();
const setBrowserViewMatchWindow = vi.fn<(id: string, on: boolean) => Promise<void>>();
const getBrowserViewMatchWindow = vi.fn<() => Promise<boolean>>();
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),
openBrowserViewPopout: (id: string, onTop: boolean) => openBrowserViewPopout(id, onTop),
closeBrowserViewPopout: (id: string) => closeBrowserViewPopout(id),
getBrowserViewPopoutState: () => getBrowserViewPopoutState(),
setBrowserViewPopoutAlwaysOnTop: (id: string, onTop: boolean) =>
setBrowserViewPopoutAlwaysOnTop(id, onTop),
openPageInContainerBrowser: (id: string, url: string, w: number, h: number) =>
openPageInContainerBrowser(id, url, w, h),
setBrowserViewMatchWindow: (id: string, on: boolean) => setBrowserViewMatchWindow(id, on),
getBrowserViewMatchWindow: () => getBrowserViewMatchWindow(),
}));
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(storeState),
}));
const OFF: BrowserViewStatus = {
enabled: false,
state: "off",
url: null,
host_port: null,
container_port: null,
started_at: null,
detection: null,
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,
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",
"/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",
paths: [{ host_path: "/home/user/api", mount_name: "api" }],
container_id: "c1",
status: "running",
backend: "anthropic",
bedrock_config: null,
ollama_config: null,
openai_compatible_config: null,
allow_docker_access: false,
sandbox_mode_enabled: true,
mission_control_enabled: false,
auth_bridge_enabled: false,
use_shared_auth_token: true,
full_permissions: false,
permission_mode: "bypass",
ssh_key_path: null,
git_token: null,
git_user_name: null,
git_user_email: null,
custom_env_vars: [],
port_mappings: [],
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
} as unknown as Project;
beforeEach(() => {
vi.clearAllMocks();
storeState.containerProgress = {};
getBrowserViewStatus.mockResolvedValue(OFF);
checkBrowserViewSupport.mockResolvedValue(READY);
getBrowserViewPopoutState.mockResolvedValue({ open: false, always_on_top: false });
openBrowserViewPopout.mockResolvedValue(undefined);
closeBrowserViewPopout.mockResolvedValue(undefined);
setBrowserViewPopoutAlwaysOnTop.mockResolvedValue(undefined);
setBrowserViewMatchWindow.mockResolvedValue(undefined);
getBrowserViewMatchWindow.mockResolvedValue(false);
openPageInContainerBrowser.mockResolvedValue({ error: null });
});
const LIVE: BrowserViewStatus = {
...OFF,
enabled: true,
state: "running",
url: "http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
host_port: 47820,
container_port: 39321,
started_at: "2026-08-09T10:00:00Z",
};
/** Render with the view already live, which is the only state that pops out. */
async function renderLive() {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
setBrowserViewEnabled.mockResolvedValue(LIVE);
render(<BrowserTab project={project} active />);
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
});
await screen.findByTitle("Playwright browser view for api-server");
}
describe("BrowserTab", () => {
it("does not offer to start anything while the container is stopped", async () => {
render(<BrowserTab project={{ ...project, status: "stopped" }} active />);
expect(await screen.findByText(/container isnt 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 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,
state: "running",
url: "http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
host_port: 47820,
container_port: 39321,
started_at: "2026-08-09T10:00:00Z",
});
render(<BrowserTab project={project} active />);
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
});
const frame = await screen.findByTitle("Playwright browser view for api-server");
expect(frame).toHaveAttribute(
"src",
"http://127.0.0.1:47820/index.html?ws=abc&token=SEKRIT",
);
expect(screen.getByText("Live")).toBeInTheDocument();
expect(screen.getByText(/127\.0\.0\.1:47820 → container :39321/)).toBeInTheDocument();
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. Two packages are needed: `playwright` and `@playwright/cli`.",
detection: NOTHING,
});
render(<BrowserTab project={project} active />);
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();
expect(screen.getByText("not in this build")).toBeInTheDocument();
expect(screen.getByText(/usr\/lib\/node_modules/)).toBeInTheDocument();
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
});
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 />);
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /start browser view/i }));
});
expect(await screen.findByText(/didnt start/i)).toBeInTheDocument();
expect(screen.getByText(/container went away/)).toBeInTheDocument();
expect(pushToast).toHaveBeenCalledWith(
expect.objectContaining({ kind: "error" }),
);
});
it("stops the view when asked", async () => {
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
getBrowserViewStatus.mockResolvedValue({
...OFF,
enabled: true,
state: "running",
url: "http://127.0.0.1:47821/?token=T",
host_port: 47821,
container_port: 39321,
});
setBrowserViewEnabled.mockResolvedValue(OFF);
render(<BrowserTab project={project} active />);
const stop = await screen.findByRole("button", { name: "Stop" });
await act(async () => {
fireEvent.click(stop);
});
await waitFor(() => expect(setBrowserViewEnabled).toHaveBeenCalled());
expect(await screen.findByText("Off")).toBeInTheDocument();
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 />);
await waitFor(() => expect(getBrowserViewStatus).toHaveBeenCalled());
expect(screen.queryByRole("button", { name: /own window/i })).toBeNull();
});
it("pops the live view out, and drops the iframe so only one viewer drives", async () => {
await renderLive();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
});
expect(openBrowserViewPopout).toHaveBeenCalledWith("p1", false);
// The window is showing it now — a second copy here would be a second
// cursor on the same page.
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
// Still live, and still stoppable from the tab.
expect(screen.getByText("Live")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Stop" })).toBeInTheDocument();
});
it("puts the view back in the tab when the window is closed from here", async () => {
await renderLive();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
});
await act(async () => {
fireEvent.click(screen.getAllByRole("button", { name: /put back in tab/i })[0]);
});
expect(closeBrowserViewPopout).toHaveBeenCalledWith("p1");
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
});
it("pins the window on top on request", async () => {
await renderLive();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
});
await act(async () => {
// The accessible name is the visible text, as with every other Toggle.
fireEvent.click(screen.getByRole("switch", { name: "Keep on top" }));
});
expect(setBrowserViewPopoutAlwaysOnTop).toHaveBeenCalledWith("p1", true);
});
it("keeps a pop-out that outlived the tab, rather than showing an empty pane", async () => {
// The window belongs to the backend, so remounting the pane has to read its
// state back — otherwise the pane would render an iframe alongside it.
getBrowserViewPopoutState.mockResolvedValue({ open: true, always_on_top: true });
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
getBrowserViewStatus.mockResolvedValue(LIVE);
render(<BrowserTab project={project} active />);
expect(await screen.findByText(/in its own window/i)).toBeInTheDocument();
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
// The pin is read from the window too — the pane is unmounted every time
// another sub-tab is selected, so remembering it would show Off over a
// window that is still floating on top.
expect(screen.getByRole("switch", { name: "Keep on top" })).toBeChecked();
});
it("never mounts the iframe before the window's state is known", async () => {
// The status and the pop-out state are two separate round trips. If the
// status wins the race, guessing "not popped out" would flash a second
// viewer onto a browser the window is already driving.
checkBrowserViewSupport.mockResolvedValue({ ...READY, browsers: ["chromium-1200"] });
getBrowserViewStatus.mockResolvedValue(LIVE);
let answer: (s: { open: boolean; always_on_top: boolean }) => void = () => {};
getBrowserViewPopoutState.mockReturnValue(
new Promise((resolve) => {
answer = resolve;
}),
);
render(<BrowserTab project={project} active />);
await waitFor(() => expect(screen.getByText("Live")).toBeInTheDocument());
expect(screen.queryByTitle(/browser view for/i)).toBeNull();
await act(async () => {
answer({ open: false, always_on_top: false });
});
expect(await screen.findByTitle("Playwright browser view for api-server")).toBeInTheDocument();
});
it("opens a page in the containers browser at the chosen viewport", async () => {
await renderLive();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
});
fireEvent.change(screen.getByLabelText(/^URL$/i), {
target: { value: "http://localhost:5173" },
});
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "1920 × 1080" }));
});
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /open page/i }));
});
expect(openPageInContainerBrowser).toHaveBeenCalledWith(
"p1",
"http://localhost:5173",
1920,
1080,
);
});
it("refuses a URL scheme the backend would reject, before the round trip", async () => {
await renderLive();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /open a page/i }));
});
fireEvent.change(screen.getByLabelText(/^URL$/i), {
target: { value: "file:///etc/passwd" },
});
expect(screen.getByRole("button", { name: /open page/i })).toBeDisabled();
expect(screen.getByText(/Only http:\/\/ and https:\/\//)).toBeInTheDocument();
expect(openPageInContainerBrowser).not.toHaveBeenCalled();
});
it("offers match-window only once the view is in its own window", async () => {
await renderLive();
expect(screen.queryByRole("switch", { name: "Match window" })).toBeNull();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
});
await act(async () => {
fireEvent.click(screen.getByRole("switch", { name: "Match window" }));
});
expect(setBrowserViewMatchWindow).toHaveBeenCalledWith("p1", true);
});
it("says why the window wouldnt open instead of pretending it did", async () => {
await renderLive();
openBrowserViewPopout.mockRejectedValue("no display");
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: /own window/i }));
});
expect(pushToast).toHaveBeenCalledWith(
expect.objectContaining({ kind: "error", detail: "no display" }),
);
// The view is still in the tab, where it was.
expect(screen.getByTitle("Playwright browser view for api-server")).toBeInTheDocument();
});
it("pins the pane's sandbox: same-origin kept, top-navigation never granted", async () => {
await renderLive();
const frame = await screen.findByTitle("Playwright browser view for api-server");
const tokens = new Set(
(frame.getAttribute("sandbox") ?? "").split(/\s+/).filter(Boolean),
);
// What is framed here is served by a process *inside* the container, which
// is the untrusted side of this app. A top-navigation grant would let that
// page set `top.location` and steer the whole Triple-C app window away from
// itself — a sandbox escape from the app's point of view — and
// `allow-popups-to-escape-sandbox` is the same hole one step removed: it
// hands a popup an entirely unsandboxed context. Checked token by token so
// the failure names the one that was added.
for (const forbidden of [
"allow-top-navigation",
"allow-top-navigation-by-user-activation",
"allow-top-navigation-to-custom-protocols",
"allow-popups-to-escape-sandbox",
]) {
expect(
tokens.has(forbidden),
`FORBIDDEN iframe sandbox token "${forbidden}" on the browser view pane. ` +
"A page served from inside the container could then navigate the whole " +
"Triple-C app window away from itself (or run a popup unsandboxed) — a " +
"sandbox escape. Remove it from the iframe in BrowserTab.tsx.",
).toBe(false);
}
// `allow-same-origin` must stay. The browser_view proxy's token gate
// recognises the pane's own sub-resource requests by their `Origin`/
// `Referer` header; dropping this token gives the frame an opaque origin,
// which sends `null`, so the proxy refuses those requests and the pane
// renders blank.
expect(
tokens.has("allow-same-origin"),
'REQUIRED iframe sandbox token "allow-same-origin" is missing from the ' +
"browser view pane. Without it the frame has an opaque origin and sends " +
"`Origin: null`, which the browser_view proxy's token gate refuses — the " +
"pane goes blank.",
).toBe(true);
// And the exact set, so any *other* new grant is a deliberate edit here too.
expect([...tokens].sort()).toEqual([
"allow-downloads",
"allow-forms",
"allow-modals",
"allow-popups",
"allow-same-origin",
"allow-scripts",
]);
});
});