/** * @file Workspace.changesPanel.test.tsx * @description The Changes panel has two controls: Full screen (Restore at * full screen, back to the last dragged width) and Collapse; the rail reopens * it at the last dragged width, else 25/75. 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 }), // Workspace 1 has one extra folder, its own repo (`?source=`). sources: vi.fn((id: number) => Promise.resolve({ sources: id === 1 ? [ { id: 7, kind: "folder", path: "/x/shop", name: "shop", missing: false }, { id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }, ] : [], }) ), // Closing a folder's section removes it from the workspace. removeSource: r({ sources: [{ id: 8, kind: "file", path: "/d/notes.md", name: "notes.md", missing: false }], }), gitTree: r({ entries: [] }), // A file opened in the panel on a chat's request. changes: r({ available: false }), gitFile: r({ available: false }), runtime: r({ up: false, ports: {}, lastError: null }), features: { list: r({ features: [] }), show: r({ feature: null }) }, proof: { list: r({ features: [] }), imageUrl: () => "" }, }, aiProxies: { 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"; import { openInChanges } from "../../lib/openInChanges"; import { api } from "../../lib/api"; 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("offers Full screen and Collapse, then Restore and Collapse at full screen", async () => { await openPanel(); const card = () => screen.getByTestId("lane-changes-card"); const size = () => screen.getByTestId("lane-changes-resize"); expect(card().className).toContain("w-[25%]"); expect(size()).toHaveAttribute("aria-label", "Full screen"); expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument(); // Full width HIDES the console beside it — hidden, still mounted. fireEvent.click(size()); expect(card().className).toContain("w-full"); expect(screen.getByTestId("lane-detail").className).toContain("hidden"); expect(size()).toHaveAttribute("aria-label", "Restore size"); expect(screen.getByTestId("lane-changes-collapse")).toBeInTheDocument(); // Restore brings the console back beside it. fireEvent.click(size()); expect(card().className).toContain("w-[25%]"); expect(screen.getByTestId("lane-detail").className).not.toContain("hidden"); // Collapsing from full screen reopens at 25/75, not back at full width. fireEvent.click(size()); fireEvent.click(screen.getByTestId("lane-changes-collapse")); await settle(); expect(screen.queryByTestId("lane-changes-card")).toBeNull(); fireEvent.click(screen.getByTestId("lane-changes-expand")); await settle(); expect(card().className).toContain("w-[25%]"); }); 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("full"); cleanup(); await openPanel(); expect(screen.getByTestId("lane-changes-card").className).toContain("w-full"); }); it("restores, and reopens, at the width last dragged to, not the preset", async () => { window.localStorage.setItem("agenthub.workspace.changesSize", "full"); window.localStorage.setItem("agenthub.workspace.changesDragged", "0.4"); await openPanel(); fireEvent.click(screen.getByTestId("lane-changes-resize")); expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%"); fireEvent.click(screen.getByTestId("lane-changes-resize")); fireEvent.click(screen.getByTestId("lane-changes-collapse")); await settle(); fireEvent.click(screen.getByTestId("lane-changes-expand")); await settle(); expect(screen.getByTestId("lane-changes-card").style.width).toBe("40%"); }); it("opens on a chat's request for a file of the workspace it shows, not another's", async () => { window.localStorage.setItem("agenthub.workspace.changesOpen", "0"); render( ); await settle(); expect(screen.queryByTestId("lane-changes-card")).toBeNull(); act(() => { openInChanges(2, "/workspace/beta", "/workspace/beta/a.ts"); }); await settle(); expect(screen.queryByTestId("lane-changes-card")).toBeNull(); act(() => { openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/a.ts"); }); await settle(); expect(screen.getByTestId("lane-changes-card")).toBeInTheDocument(); expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/a.ts", "work"); }); it("stacks every extra folder's repo under the workspace folder's, and only its own folders", async () => { vi.mocked(api.lanes.git).mockClear(); await openPanel(); const sections = screen.getAllByTestId("panel-git-source"); // The workspace folder, then its folders — never a context file. expect(sections.map((s) => s.querySelector("button")!.textContent)).toEqual(["alpha", "shop"]); expect(api.lanes.git).toHaveBeenCalledWith(1); expect(api.lanes.git).toHaveBeenCalledWith({ lane: 1, source: 7 }); // A folded section keeps its panel mounted, only hidden. const toggle = sections[0]!.querySelector("button")!; fireEvent.click(toggle); expect(toggle).toHaveAttribute("aria-expanded", "false"); expect(sections[0]!.querySelector('[data-testid="lane-changes"]')).not.toBeNull(); // A chat's file is the workspace folder's: its section unfolds. act(() => { openInChanges(1, "/workspace/alpha", "/workspace/alpha/src/b.ts"); }); await settle(); expect(toggle).toHaveAttribute("aria-expanded", "true"); expect(api.lanes.gitFile).toHaveBeenCalledWith(1, "src/b.ts", "work"); }); it("closes an extra folder from its right-click menu, removing it from the workspace — never the workspace folder", async () => { await openPanel(); const [main, shop] = screen.getAllByTestId("panel-git-source"); fireEvent.contextMenu(main!.querySelector("button")!); expect(screen.queryByTestId("panel-git-source-close")).toBeNull(); fireEvent.keyDown(document, { key: "Escape" }); fireEvent.contextMenu(shop!.querySelector("button")!); fireEvent.click(screen.getByTestId("panel-git-source-close")); await settle(); expect(api.lanes.removeSource).toHaveBeenCalledWith(1, 7); // The last folder gone: back to the one plain panel. expect(screen.queryByTestId("panel-git-source")).toBeNull(); }); it("is one plain panel for a workspace with no extra folders", async () => { render( ); await settle(); expect(screen.queryByTestId("panel-git-source")).toBeNull(); }); it("collapses from its narrow width too, back to the rail", async () => { await openPanel(); fireEvent.click(screen.getByTestId("lane-changes-collapse")); await settle(); expect(screen.queryByTestId("lane-changes-card")).toBeNull(); expect(screen.getByTestId("lane-changes-expand")).toBeInTheDocument(); }); });