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
628 lines
25 KiB
TypeScript
628 lines
25 KiB
TypeScript
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 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 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(/didn’t 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 container’s 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 wouldn’t 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",
|
||
]);
|
||
});
|
||
});
|