00a2aedb09
Two gaps left by the AI Router feature: a profile could be saved without ever
being reachable, and a lane's router could only be chosen at creation.
**POST /api/ai-routers/test** answers the question saving cannot. It sends one
Anthropic-style message capped at a single output token to `<baseUrl>/v1/messages`
with a 15s timeout — the wire shape a claude lane's eight env keys point at, so
what is tested is what will actually run. A router that answers is a 200 here
whatever it replies: `{ ok, status, message }` carries the router's own words,
and `status` is null when nothing answered at all. Only bad input is a 4xx.
The stored key is only ever sent to the endpoint it was stored against. A blank
`apiKey` falls back to the key of the profile named by `name`, but only while
the submitted `baseUrl` still equals the saved one — so testing a retyped
endpoint needs a retyped key (`EBADKEY`). Without that rule this route would be
a way to make the server POST a saved API key to any URL a browser names.
Network failures report `error.cause`, not `error.message`: node's fetch reports
every transport failure as the same useless "fetch failed" and hides the part
the user can act on (ECONNREFUSED, ENOTFOUND, a TLS error) one level down.
**The lane card carries an AI Router select.** The Add-lane modal was the only
place a router was chosen, and a lane outlives its creation. Switching goes
through `POST /api/lanes/:id/router/apply` — never a lane PATCH — because the
DB column and the lane's own config on disk may only move together. A failed
apply puts the select back rather than leaving the card claiming a router the
lane did not get. Hidden when `native` is the only profile: a select with one
option is a control that cannot do anything.
Adds `Lane.router_profile` to the client type (the payload already carried it).
158 lines
4.4 KiB
TypeScript
158 lines
4.4 KiB
TypeScript
/**
|
|
* @file Workspace.laneCwd.test.tsx
|
|
* @description The console's working directory must be the selected lane's own
|
|
* `cwd` — on first paint (the page auto-selects the first lane) and after every
|
|
* lane switch. A stale cwd is not cosmetic: `RunSetup` submits it verbatim, so
|
|
* the run would be started in the previously selected lane's folder.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
|
import { render, act, 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"),
|
|
};
|
|
});
|
|
|
|
vi.mock("../../lib/api", async (importOriginal) => {
|
|
const actual = await importOriginal<Record<string, unknown>>();
|
|
const r = (value: unknown) => vi.fn().mockResolvedValue(value);
|
|
return {
|
|
...actual,
|
|
api: {
|
|
run: {
|
|
list: r({ items: [] }),
|
|
history: 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 }),
|
|
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));
|
|
});
|
|
}
|
|
|
|
function cwdInput(): HTMLInputElement {
|
|
return screen.getByPlaceholderText(i18n.t("run:fields.cwdPlaceholder")) as HTMLInputElement;
|
|
}
|
|
|
|
beforeEach(() => {
|
|
i18n.changeLanguage("en");
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
describe("Workspace — the console cwd follows the selected lane", () => {
|
|
it("shows the auto-selected first lane's cwd on load, not the home default", async () => {
|
|
render(
|
|
<MemoryRouter initialEntries={["/run"]}>
|
|
<Workspace />
|
|
</MemoryRouter>
|
|
);
|
|
await settle();
|
|
expect(cwdInput().value).toBe(LANE_A.cwd);
|
|
});
|
|
|
|
it("swaps the cwd when another lane is selected in the strip", async () => {
|
|
render(
|
|
<MemoryRouter initialEntries={["/run"]}>
|
|
<Workspace />
|
|
</MemoryRouter>
|
|
);
|
|
await settle();
|
|
|
|
fireEvent.click(screen.getByTestId("lane-tile-2"));
|
|
await settle();
|
|
expect(cwdInput().value).toBe(LANE_B.cwd);
|
|
|
|
fireEvent.click(screen.getByTestId("lane-tile-1"));
|
|
await settle();
|
|
expect(cwdInput().value).toBe(LANE_A.cwd);
|
|
});
|
|
});
|