Files
Triple-C/app/src/components/projects/home/BrowserTab.test.tsx
T
shadow-testandClaude Opus 5 cc5f691677 Add llama.cpp backend, model gateway, URL relay and browser view
Four features, plus a latent bug fix.

llama.cpp backend. Claude Code only ever speaks the Anthropic Messages
API — confirmed empirically by pointing it at a logging server, which
received POST /v1/messages?beta=true. llama-server implements that
natively (verified in its README, alongside --port default 8080), so
this is a plain base-URL backend with no translation shim, the same
shape as Ollama. Its --api-key defaults to none, so the auth token is a
placeholder Claude Code requires and llama-server ignores.

Model alias fix. ANTHROPIC_DEFAULT_HAIKU_MODEL is documented as "also
used for background functionality", and Triple-C set none of the alias
vars. So on every custom-endpoint backend, Claude Code resolved `haiku`
to an Anthropic model id and sent it to a local server that does not
have it — background features failed silently. All four
ANTHROPIC_DEFAULT_{OPUS,SONNET,HAIKU,FABLE}_MODEL vars are now pinned to
the backend's configured model, with an optional Haiku override, and
blanked for Anthropic and Bedrock so those keep Claude Code's defaults.
The deprecated ANTHROPIC_SMALL_FAST_MODEL is never emitted. Existing
Ollama and OpenAI-Compatible containers are recreated once so the new
env reaches them; the snapshot is preserved.

Model gateway. Optional LiteLLM sibling container, off by default,
mirroring stt.rs — this is what makes real OpenAI usable, since
api.openai.com has no /v1/messages. Pinned to v1.96.0 by tag and digest:
the 1.82.7/1.82.8 malware was PyPI-only and never affected the official
images, which is precisely why this builds FROM the image rather than
pip-installing, but 1.84.0 is still the floor for proxy CVEs (API-key
SQLi, Host-header auth bypass, MCP auth bypass). Binds 0.0.0.0 because
project containers consume it, and therefore always sets a master_key —
LiteLLM without one accepts any key. The provider key lives in the OS
keychain and is uploaded into a volume, never an image layer or label.

URL relay. A container-side xdg-open/BROWSER shim opens URLs in the
host's browser. Uses an OSC sequence to /dev/tty rather than a printed
sentinel, because the shim usually runs as a grandchild of a process
capturing its children's output. Degrades to printing the URL when no
terminal is attached, so scheduled tasks do not hang. Only http/https,
with control characters rejected before new URL() — which strips
newlines, so java\nscript: would otherwise parse as javascript:. Nothing
auto-opens; the user confirms. The web terminal shows a tap-to-open
banner instead, since that browser may be a phone across a tunnel.

Browser view. A Project Home tab that watches and takes over the browser
Claude drives with Playwright, using Playwright's own dashboard. Zero
image cost — Playwright stays user-installed. It does not reuse the auth
bridge's PortForward, which binds an unauthenticated port: correct for a
throwaway OAuth listener, wrong for mouse and keyboard control of a
browser in a passwordless-sudo container. Instead a token-gated loopback
proxy checks Host, then token or a forbidden-header origin signal,
before a byte reaches the container. Host ports are confined to
47820..=47827 so CSP frame-src can enumerate them rather than widening
to a wildcard, with a test asserting the two agree.

188 frontend tests, 107 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 16:55:28 -07:00

179 lines
6.1 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 { BrowserViewStatus, Project } from "../../../lib/types";
const getBrowserViewStatus = vi.fn<() => Promise<BrowserViewStatus>>();
const setBrowserViewEnabled = vi.fn<() => Promise<BrowserViewStatus>>();
const pushToast = vi.fn();
vi.mock("../../../lib/tauri-commands", () => ({
getBrowserViewStatus: () => getBrowserViewStatus(),
setBrowserViewEnabled: () => setBrowserViewEnabled(),
}));
vi.mock("@tauri-apps/api/event", () => ({
listen: vi.fn(async () => () => {}),
}));
vi.mock("../../../store/appState", () => ({
useAppState: (selector: (s: unknown) => unknown) => selector({ pushToast }),
}));
const OFF: BrowserViewStatus = {
enabled: false,
state: "off",
url: null,
host_port: null,
container_port: null,
started_at: null,
detection: null,
message: null,
};
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();
getBrowserViewStatus.mockResolvedValue(OFF);
});
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();
});
it("starts off, and never starts a view without being asked", async () => {
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();
});
it("shows the live pane, pointed at loopback with a token, once started", async () => {
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("explains precisely what is missing instead of spinning", async () => {
getBrowserViewStatus.mockResolvedValue({
...OFF,
enabled: true,
state: "unavailable",
message:
"Playwright isn't installed in this container. Install it with `npm i -D playwright`.",
detection: {
node_version: "22.11.0",
playwright_version: null,
playwright_path: null,
has_bind: false,
cli_version: null,
cli_entry: null,
searched: ["/workspace", "/usr/lib/node_modules"],
},
});
render(<BrowserTab project={project} active />);
expect(await screen.findByText(/npm i -D playwright/)).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 () => {
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 () => {
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();
});
});