fcfebe44f1
Settings → Dashboard gains an AI Tools section (installed tools with Enabled/Disabled and Set default, available ones with a homepage link, Refresh). useAiTools shares one catalog copy across the app and replaces LANE_TOOL_LABEL. The Add-lane modal picks Default (<tool>) or Other…; the router row reads Native (<tool>) and disables AI Router profiles for tools that cannot use one. The run form gives catalog tools resume and a red per-run Bypass permissions switch (Codex too). Claude Code / Codex settings tabs hide while that tool is disabled. The Workspace probes each lane tool's CLI fresh. Docs: CLAUDE.md tool/bypass rule, AGENTS.md, LANES.md, API.md, CLI.md, server/README.md, OpenAPI fragment + regenerated openapi.yaml.
224 lines
7.2 KiB
TypeScript
224 lines
7.2 KiB
TypeScript
/**
|
|
* @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ĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
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<Record<string, unknown>>();
|
|
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 }) => (
|
|
<div data-testid="terminal-view" data-run-id={runId} />
|
|
),
|
|
}));
|
|
|
|
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<string, unknown>)[fn]) {
|
|
(Element.prototype as unknown as Record<string, unknown>)[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(
|
|
<MemoryRouter initialEntries={["/run"]}>
|
|
<Workspace />
|
|
</MemoryRouter>
|
|
);
|
|
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<typeof userEvent.setup>, 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());
|
|
});
|
|
});
|