Secret Scan / scan (push) Successful in 10s
Build App (Preview) / compute-version (pull_request) Successful in 6s
Secret Scan / scan (pull_request) Successful in 10s
Build App (Preview) / create-release (pull_request) Successful in 3s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / build-linux (pull_request) Successful in 6m47s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Both were the defect the new hook exists for. The sidebar's Claude terminal button is disabled whenever the container is not running and never said so — its `title` names the action, so the precondition appeared nowhere in the accessibility tree at all. Add Project's submit button is disabled while an add is in flight, and its only signal is the label swapping to "Adding…" on an element a screen reader can no longer reach. The submit button needs a second guard the hook cannot supply: Enter inside a text field submits a form without touching the submit button, so `handleSubmit` now returns early while loading. Without it, swapping `disabled` for `aria-disabled` would have turned an accessibility fix into a double-submit bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_011YPqHpjV4EL6RNEwrRKqQm
173 lines
5.9 KiB
TypeScript
173 lines
5.9 KiB
TypeScript
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
import { render, screen, fireEvent } from "@testing-library/react";
|
|
import ProjectRow from "./ProjectRow";
|
|
import type { Project } from "../../lib/types";
|
|
|
|
const mockStart = vi.fn();
|
|
const mockStop = vi.fn();
|
|
const mockOpenClaudeTerminal = vi.fn();
|
|
|
|
vi.mock("../../hooks/useProjectActions", () => ({
|
|
useProjectActions: () => ({
|
|
busy: false,
|
|
backingUp: false,
|
|
handleStart: mockStart,
|
|
handleStop: mockStop,
|
|
handleReset: vi.fn(),
|
|
handleBackup: vi.fn(),
|
|
openClaudeTerminal: mockOpenClaudeTerminal,
|
|
openShell: vi.fn(),
|
|
openTerminalWithCommand: vi.fn(),
|
|
}),
|
|
}));
|
|
|
|
const mockOpenProjectHome = vi.fn();
|
|
let storeState: Record<string, unknown> = {};
|
|
|
|
vi.mock("../../store/appState", async () => {
|
|
const actual = await vi.importActual<typeof import("../../store/appState")>(
|
|
"../../store/appState",
|
|
);
|
|
return {
|
|
...actual,
|
|
useAppState: vi.fn((selector: (s: unknown) => unknown) => selector(storeState)),
|
|
};
|
|
});
|
|
|
|
const baseProject: Project = {
|
|
id: "test-1",
|
|
name: "Test Project",
|
|
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
|
|
container_id: null,
|
|
status: "stopped",
|
|
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: null,
|
|
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",
|
|
};
|
|
|
|
function setStore(overrides: Record<string, unknown> = {}) {
|
|
storeState = {
|
|
activeTabKey: null,
|
|
selectedProjectId: null,
|
|
openProjectHome: mockOpenProjectHome,
|
|
containerProgress: {},
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
describe("ProjectRow", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
setStore();
|
|
});
|
|
|
|
it("renders project name and mount path", () => {
|
|
render(<ProjectRow project={baseProject} />);
|
|
expect(screen.getByText("Test Project")).toBeInTheDocument();
|
|
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
|
|
});
|
|
|
|
it("row root has min-w-0 and overflow-hidden to contain content", () => {
|
|
const { container } = render(<ProjectRow project={baseProject} />);
|
|
const row = container.firstElementChild;
|
|
expect(row).not.toBeNull();
|
|
expect(row!.className).toContain("min-w-0");
|
|
expect(row!.className).toContain("overflow-hidden");
|
|
});
|
|
|
|
it("communicates status with a word, not colour alone", () => {
|
|
render(<ProjectRow project={baseProject} />);
|
|
expect(screen.getAllByText("Stopped").length).toBeGreaterThan(0);
|
|
|
|
render(<ProjectRow project={{ ...baseProject, status: "error" }} />);
|
|
expect(screen.getAllByText("Error").length).toBeGreaterThan(0);
|
|
});
|
|
|
|
it("selecting the row opens that project's home tab instead of expanding in place", () => {
|
|
render(<ProjectRow project={baseProject} />);
|
|
fireEvent.click(screen.getByText("Test Project"));
|
|
expect(mockOpenProjectHome).toHaveBeenCalledWith("test-1");
|
|
// No config form is rendered in the sidebar any more.
|
|
expect(screen.queryByPlaceholderText("/path/to/folder")).toBeNull();
|
|
});
|
|
|
|
it("offers start when stopped and stop when running", () => {
|
|
const { unmount } = render(<ProjectRow project={baseProject} />);
|
|
fireEvent.click(screen.getByRole("button", { name: "Start Test Project" }));
|
|
expect(mockStart).toHaveBeenCalled();
|
|
unmount();
|
|
|
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
|
fireEvent.click(screen.getByRole("button", { name: "Stop Test Project" }));
|
|
expect(mockStop).toHaveBeenCalled();
|
|
});
|
|
|
|
it("only allows opening a terminal while the container runs", () => {
|
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
|
fireEvent.click(
|
|
screen.getByRole("button", {
|
|
name: "Open a Claude terminal for Test Project",
|
|
}),
|
|
);
|
|
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
|
|
});
|
|
|
|
it("keeps the terminal button announced, and explains why, while stopped", () => {
|
|
render(<ProjectRow project={baseProject} />);
|
|
const button = screen.getByRole("button", {
|
|
name: "Open a Claude terminal for Test Project",
|
|
});
|
|
// Native `disabled` would drop the button out of the accessibility tree
|
|
// and out of the tab order, taking the reason with it.
|
|
expect(button).not.toBeDisabled();
|
|
expect(button).toHaveAttribute("aria-disabled", "true");
|
|
expect(button).toHaveAccessibleDescription(/is not running/i);
|
|
});
|
|
|
|
it("ignores clicks and Enter/Space on the terminal button while stopped", () => {
|
|
render(<ProjectRow project={baseProject} />);
|
|
const button = screen.getByRole("button", {
|
|
name: "Open a Claude terminal for Test Project",
|
|
});
|
|
fireEvent.click(button);
|
|
fireEvent.keyDown(button, { key: "Enter" });
|
|
fireEvent.keyDown(button, { key: " " });
|
|
expect(mockOpenClaudeTerminal).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it("drops aria-disabled once the container is running", () => {
|
|
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
|
|
const button = screen.getByRole("button", {
|
|
name: "Open a Claude terminal for Test Project",
|
|
});
|
|
expect(button).not.toHaveAttribute("aria-disabled");
|
|
expect(button).not.toHaveAccessibleDescription(/is not running/i);
|
|
});
|
|
|
|
it("shows container progress inline rather than in a blocking modal", () => {
|
|
setStore({ containerProgress: { "test-1": "Pulling image…" } });
|
|
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
|
|
expect(screen.getByText("Pulling image…")).toBeInTheDocument();
|
|
expect(screen.queryByRole("dialog")).toBeNull();
|
|
});
|
|
});
|