/** * @file Workspace.laneRouter.test.tsx * @description The Model (AI Router) picker in the Workspace lane-detail * header, exercised through the page rather than through `LaneRouterRow` on its * own — one row instance serves every lane, so the bug this pins is a lane * switch carrying the previous lane's selection with it: pick a router on one * lane, come back to a lane that is on native, and see the router still * selected there. * @author Nguyễn Ngọc Trí Vĩ */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, act, screen, waitFor } from "@testing-library/react"; import { MemoryRouter } from "react-router-dom"; import userEvent from "@testing-library/user-event"; type LaneFixture = { id: number; cwd: string; title: string; pipeline: string; pipeline_name: string; pipeline_nodes: never[]; detected_signal: string | null; run_id: string | null; router_profile: string | null; }; const lane = (id: number, title: string): LaneFixture => ({ id, cwd: `/workspace/${id}`, title, pipeline: "default", pipeline_name: "default", pipeline_nodes: [], detected_signal: null, run_id: null, router_profile: null, }); const h = vi.hoisted(() => { const nativeProfile = { name: "native", label: "Native", builtin: true, baseUrl: "", hasKey: false, model: "claude-sonnet-5", models: { opus: "claude-opus-5", sonnet: "claude-sonnet-5", haiku: "claude-haiku-4-5-20251001", smallFast: "claude-haiku-4-5-20251001", subagent: "claude-haiku-4-5-20251001", }, }; return { lanes: [] as { id: number; router_profile: string | null }[], routerApply: vi.fn(), profiles: [ nativeProfile, { ...nativeProfile, name: "9router", label: "9Router", builtin: false }, ], }; }); const routerApply = h.routerApply; let lanesToReturn: LaneFixture[] = []; vi.mock("../../lib/api", async (importOriginal) => { const actual = await importOriginal>(); return { ...actual, api: { lanes: { list: vi.fn().mockImplementation(async () => ({ lanes: h.lanes, counts: { total: h.lanes.length, running: 0, needs_you: 0, dead: 0 }, })), ensure: vi.fn(), action: vi.fn(), git: vi.fn().mockResolvedValue({ available: false }), runtime: vi.fn().mockResolvedValue({ available: false }), stage: vi.fn(), update: vi.fn(), pipelines: vi .fn() .mockResolvedValue({ pipelines: [{ id: "default", name: "default", nodes: [] }] }), // The route the picker must use — never a lane PATCH. routerApply: vi.fn().mockImplementation(async (id: number, profile: string) => { h.routerApply(id, profile); // The row moving with the call is the server's broadcast, not a test // convenience: the apply route broadcasts the lane so the cached row // stops disagreeing with what was just written. Drop the broadcast // server-side and the next lane switch re-reads a stale profile. const row = h.lanes.find((l) => l.id === id); if (row) row.router_profile = profile === "native" ? null : profile; return { tool: "claude", file: `/workspace/${id}/.claude/settings.local.json`, profile }; }), features: { list: vi.fn().mockResolvedValue({ features: [] }), show: vi.fn() }, proof: { list: vi.fn().mockResolvedValue({ features: [] }), imageUrl: vi.fn(), delete: vi.fn(), }, }, aiRouters: { list: vi.fn().mockImplementation(async () => ({ items: h.profiles })), }, run: { list: vi.fn().mockResolvedValue({ runs: [], items: [] }), history: vi.fn().mockResolvedValue({ items: [] }), binary: vi.fn().mockResolvedValue({ found: true, path: "/usr/bin/claude" }), cwds: vi.fn().mockResolvedValue({ items: [] }), files: vi.fn().mockResolvedValue({ items: [] }), start: vi.fn(), get: vi.fn(), send: vi.fn(), kill: vi.fn(), }, ccConfig: { commands: vi.fn().mockResolvedValue({ items: [] }), plugins: vi.fn().mockResolvedValue({ plugins: [] }), file: vi.fn().mockResolvedValue({ text: "", content: "" }), }, sessions: { list: vi.fn().mockResolvedValue({ sessions: [], total: 0, limit: 50, offset: 0 }), transcript: vi.fn().mockResolvedValue({ messages: [] }), }, }, }; }); vi.mock("../../lib/eventBus", () => ({ eventBus: { subscribe: () => () => {}, publish: () => {}, onConnection: () => () => {}, connected: true, setConnected: () => {}, }, })); vi.mock("../../components/run/TerminalView", () => ({ TerminalView: ({ runId }: { runId: string }) => (
), })); import { Workspace } from "../Workspace"; class ObserverStub { observe() {} unobserve() {} disconnect() {} takeRecords() { return []; } } globalThis.ResizeObserver = globalThis.ResizeObserver || (ObserverStub as unknown as typeof ResizeObserver); for (const fn of ["scrollIntoView", "scrollBy", "scrollTo"] as const) { if (!(Element.prototype as unknown as Record)[fn]) { (Element.prototype as unknown as Record)[fn] = function () {}; } } async function settle() { await act(async () => { await new Promise((r) => setTimeout(r, 0)); await new Promise((r) => setTimeout(r, 0)); }); } beforeEach(() => { routerApply.mockClear(); lanesToReturn = [lane(1, "Lane A"), lane(2, "Lane B")]; h.lanes = lanesToReturn as unknown as typeof h.lanes; }); async function renderWorkspace() { const utils = render( ); await settle(); await settle(); await settle(); return utils; } /** Click the strip tile for a lane, which is how a lane is selected. */ async function selectLane(user: ReturnType, title: string) { const tile = screen.getAllByText(title)[0]; if (!tile) throw new Error(`no strip tile for ${title}`); await user.click(tile); await settle(); } describe("Workspace — the lane Model picker across a lane switch", () => { it("leaves a lane on native after another lane was pointed at a router", async () => { await renderWorkspace(); const user = userEvent.setup(); await selectLane(user, "Lane A"); expect(await screen.findByDisplayValue("Native (Claude)")).toBeInTheDocument(); await selectLane(user, "Lane B"); await user.selectOptions(screen.getByDisplayValue("Native (Claude)"), ["9router"]); await waitFor(() => expect(routerApply).toHaveBeenCalledWith(2, "9router")); await settle(); expect(screen.getByDisplayValue("9Router")).toBeInTheDocument(); // Back to the lane that was never switched. It is still native server-side, // so the picker must say so — the row is one instance shared by every lane. await selectLane(user, "Lane A"); expect(screen.getByDisplayValue("Native (Claude)")).toBeInTheDocument(); // And nothing may have been written to lane A on the way. expect(routerApply).not.toHaveBeenCalledWith(1, expect.anything()); }); });