/** * @file Workspace.changesPanel.test.tsx * @description The Changes panel is ONE control: it steps 30/70 → 50/50 → * full → closed, and the rail reopens it at 30/70. The width survives a reload. * All of it lives in localStorage and reaches no server — the same rule as the * lane strip's order. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { render, act, cleanup, screen, fireEvent } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import i18n from "i18next"; // Hoisted: `vi.mock`'s factory is lifted above the module body, so the // fixtures it reads have to be lifted with it. const { HOME, LANE_A, LANE_B } = vi.hoisted(() => { const laneStub = (id: number, cwd: string) => ({ id, title: `lane-${id}`, cwd, branch: null, kind: "adopted", source_repo: null, pipeline: "default", session_id: null, run_id: null, stage: "idle", stage_since: null, status: "idle", gate_decision: null, ci_status: null, needs_action: null, links: {}, stages: {}, notes: null, pipeline_name: "Default", pipeline_nodes: [], progress: 0, stage_seconds: null, last_event_seconds: null, liveness: "idle", detected_stage: null, detected_signal: null, slot: null, ports: {}, active_feature_id: null, }); return { HOME: { kind: "home", path: "/Users/tester", label: "Home" }, LANE_A: laneStub(1, "/workspace/alpha"), LANE_B: laneStub(2, "/workspace/beta"), }; }); // The workspace's home (its own suite) fetches sources and memory; these // tests are about the session view it hides, so it is a stub here. vi.mock("../../components/lanes/WorkspaceHome", () => ({ WorkspaceHome: () =>
, paneRunId: (id: number, slot: number) => slot ? `agenthub-lane-${id}-${slot}` : `agenthub-lane-${id}`, freeSlot: () => 0, })); vi.mock("../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); const r = (value: unknown) => vi.fn().mockResolvedValue(value); return { ...actual, api: { run: { list: r({ items: [] }), binary: r({ found: true, path: "/usr/bin/claude" }), cwds: r({ items: [HOME] }), files: r({ items: [] }), start: r({ id: "run-1", status: "running" }), get: r({ id: "run-1", status: "running" }), }, lanes: { list: r({ lanes: [LANE_A, LANE_B], counts: { total: 2, running: 0, needs_you: 0, dead: 0 }, }), pipelines: r({ pipelines: [] }), git: r({ available: false }), gitTree: r({ entries: [] }), runtime: r({ up: false, ports: {}, lastError: null }), features: { list: r({ features: [] }), show: r({ feature: null }) }, proof: { list: r({ features: [] }), imageUrl: () => "" }, }, aiRouters: { list: r({ items: [] }) }, sessions: { list: r({ sessions: [], total: 0, limit: 50, offset: 0 }) }, }, }; }); vi.mock("../../lib/eventBus", () => ({ eventBus: { subscribe: () => () => {}, publish: () => {}, onConnection: () => () => {}, connected: true, setConnected: () => {}, }, })); import { Workspace } from "../Workspace"; class ObserverStub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver = globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver); async function settle() { await act(async () => { await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); }); } beforeEach(() => { i18n.changeLanguage("en"); }); afterEach(() => { vi.clearAllMocks(); }); describe("Workspace — the Changes panel's width", () => { beforeEach(() => window.localStorage.clear()); async function openPanel() { render( ); await settle(); // Open/closed is itself remembered, so the second mount of a test comes up // already open — clicking the rail then would close it. const rail = screen.queryByTestId("lane-changes-expand"); if (rail) { fireEvent.click(rail); await settle(); } } it("steps 30/70 → 50/50 → full → closed on one control, with no 70/30 step", async () => { await openPanel(); const card = () => screen.getByTestId("lane-changes-card"); expect(card().className).toContain("w-[30%]"); fireEvent.click(screen.getByTestId("lane-changes-resize")); expect(card().className).toContain("w-1/2"); // Full width HIDES the console beside it — hidden, still mounted. fireEvent.click(screen.getByTestId("lane-changes-resize")); expect(card().className).toContain("w-full"); expect(screen.getByTestId("lane-detail").className).toContain("hidden"); // Past the widest step the panel closes — and reopens at 30/70, not back // at full width, which is why closing resets the width. fireEvent.click(screen.getByTestId("lane-changes-resize")); await settle(); expect(screen.queryByTestId("lane-changes-card")).toBeNull(); fireEvent.click(screen.getByTestId("lane-changes-expand")); await settle(); expect(card().className).toContain("w-[30%]"); }); it("remembers the width across a reload, and never asks the server", async () => { await openPanel(); fireEvent.click(screen.getByTestId("lane-changes-resize")); expect(window.localStorage.getItem("agenthub.workspace.changesSize")).toBe("half"); cleanup(); await openPanel(); expect(screen.getByTestId("lane-changes-card").className).toContain("w-1/2"); }); it("keeps the rail as the only way back in", async () => { await openPanel(); expect(screen.queryByTestId("lane-changes-collapse")).toBeNull(); expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument(); }); });