map: tickets 06+07 resolved; 05 partial (live spawn pending gitea token)
This commit is contained in:
+3
-3
@@ -41,9 +41,9 @@ type GitLabRepo struct {
|
|||||||
// giteaRepo is the subset of the upstream Gitea /api/v1/user/repos item we
|
// giteaRepo is the subset of the upstream Gitea /api/v1/user/repos item we
|
||||||
// map from.
|
// map from.
|
||||||
type giteaRepo struct {
|
type giteaRepo struct {
|
||||||
FullName string `json:"full_name"`
|
FullName string `json:"full_name"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Owner struct {
|
Owner struct {
|
||||||
Login string `json:"login"`
|
Login string `json:"login"`
|
||||||
} `json:"owner"`
|
} `json:"owner"`
|
||||||
UpdatedAt string `json:"updated_at"`
|
UpdatedAt string `json:"updated_at"`
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ func TestGitLabStatusStoreFailure(t *testing.T) {
|
|||||||
|
|
||||||
func TestGitLabConnectStoreFailures(t *testing.T) {
|
func TestGitLabConnectStoreFailures(t *testing.T) {
|
||||||
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
up := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
_, _ = w.Write([]byte(`{"login":"alice"}`))
|
_, _ = w.Write([]byte(`{"login":"alice"}`))
|
||||||
}))
|
}))
|
||||||
t.Cleanup(up.Close)
|
t.Cleanup(up.Close)
|
||||||
dead := openTestStore(t)
|
dead := openTestStore(t)
|
||||||
|
|||||||
+9
-9
@@ -7,22 +7,22 @@ REMOTE="${1:-alarm}"
|
|||||||
REMOTE_DIR="${2:-/zdata/root/dockerFiles/lvmh}"
|
REMOTE_DIR="${2:-/zdata/root/dockerFiles/lvmh}"
|
||||||
|
|
||||||
if ! ssh "$REMOTE" "test -f $REMOTE_DIR/.env" 2>/dev/null; then
|
if ! ssh "$REMOTE" "test -f $REMOTE_DIR/.env" 2>/dev/null; then
|
||||||
echo "ERROR: $REMOTE:$REMOTE_DIR/.env missing. Create it first (see .env.example):"
|
echo "ERROR: $REMOTE:$REMOTE_DIR/.env missing. Create it first (see .env.example):"
|
||||||
echo " ssh $REMOTE 'mkdir -p $REMOTE_DIR && nano $REMOTE_DIR/.env'"
|
echo " ssh $REMOTE 'mkdir -p $REMOTE_DIR && nano $REMOTE_DIR/.env'"
|
||||||
echo " # LVMH_TOKEN=<random>, ZAI_RENAUD_API_KEY=<key>"
|
echo " # LVMH_TOKEN=<random>, ZAI_RENAUD_API_KEY=<key>"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# web/dist must exist (daemon serves it)
|
# web/dist must exist (daemon serves it)
|
||||||
if [ ! -f web/dist/index.html ]; then
|
if [ ! -f web/dist/index.html ]; then
|
||||||
echo "building web/dist first..."
|
echo "building web/dist first..."
|
||||||
(cd web && bun install && bun run build)
|
(cd web && bun install && bun run build)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
rsync -az --delete \
|
rsync -az --delete \
|
||||||
--exclude .git --exclude node_modules --exclude web/node_modules \
|
--exclude .git --exclude node_modules --exclude web/node_modules \
|
||||||
--exclude .env --exclude .scratch --exclude .pi --exclude coverage \
|
--exclude .env --exclude .scratch --exclude .pi --exclude coverage \
|
||||||
./ "$REMOTE:$REMOTE_DIR/"
|
./ "$REMOTE:$REMOTE_DIR/"
|
||||||
|
|
||||||
echo "building daemon image + worker image on $REMOTE..."
|
echo "building daemon image + worker image on $REMOTE..."
|
||||||
ssh "$REMOTE" "cd $REMOTE_DIR \
|
ssh "$REMOTE" "cd $REMOTE_DIR \
|
||||||
|
|||||||
+51
-50
@@ -13,54 +13,55 @@ const HTTP_UNAUTHORIZED = 401;
|
|||||||
const HTTP_NOT_FOUND = 404;
|
const HTTP_NOT_FOUND = 404;
|
||||||
|
|
||||||
export function startFakeGitLab() {
|
export function startFakeGitLab() {
|
||||||
const seen = { userAuths: [], repoAuths: [] };
|
const seen = { userAuths: [], repoAuths: [] };
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
const auth = String(req.headers["authorization"] ?? "");
|
const auth = String(req.headers["authorization"] ?? "");
|
||||||
const send = (code, obj) => {
|
const send = (code, obj) => {
|
||||||
res.writeHead(code, { "Content-Type": "application/json" });
|
res.writeHead(code, { "Content-Type": "application/json" });
|
||||||
res.end(JSON.stringify(obj));
|
res.end(JSON.stringify(obj));
|
||||||
};
|
};
|
||||||
if (req.url.startsWith("/api/v1/user/repos")) {
|
if (req.url.startsWith("/api/v1/user/repos")) {
|
||||||
seen.repoAuths.push(auth);
|
seen.repoAuths.push(auth);
|
||||||
if (auth !== `token ${GOOD_PAT}`) return send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" });
|
if (auth !== `token ${GOOD_PAT}`)
|
||||||
const base = `http://127.0.0.1:${server.address().port}`;
|
return send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" });
|
||||||
return send(HTTP_OK, [
|
const base = `http://127.0.0.1:${server.address().port}`;
|
||||||
{
|
return send(HTTP_OK, [
|
||||||
full_name: "lvmh/beta",
|
{
|
||||||
name: "beta",
|
full_name: "lvmh/beta",
|
||||||
owner: { login: "lvmh" },
|
name: "beta",
|
||||||
updated_at: "2025-07-01T10:00:00Z",
|
owner: { login: "lvmh" },
|
||||||
html_url: `${base}/lvmh/beta`,
|
updated_at: "2025-07-01T10:00:00Z",
|
||||||
default_branch: "main",
|
html_url: `${base}/lvmh/beta`,
|
||||||
},
|
default_branch: "main",
|
||||||
{
|
},
|
||||||
full_name: "lvmh/alpha",
|
{
|
||||||
name: "alpha",
|
full_name: "lvmh/alpha",
|
||||||
owner: { login: "lvmh" },
|
name: "alpha",
|
||||||
updated_at: "2025-08-01T10:00:00Z",
|
owner: { login: "lvmh" },
|
||||||
html_url: `${base}/lvmh/alpha`,
|
updated_at: "2025-08-01T10:00:00Z",
|
||||||
default_branch: "trunk",
|
html_url: `${base}/lvmh/alpha`,
|
||||||
},
|
default_branch: "trunk",
|
||||||
]);
|
},
|
||||||
}
|
]);
|
||||||
if (req.url.startsWith("/api/v1/user")) {
|
}
|
||||||
seen.userAuths.push(auth);
|
if (req.url.startsWith("/api/v1/user")) {
|
||||||
return auth === `token ${GOOD_PAT}`
|
seen.userAuths.push(auth);
|
||||||
? send(HTTP_OK, { id: 1, login: "e2e-user", name: "E2E User" })
|
return auth === `token ${GOOD_PAT}`
|
||||||
: send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" });
|
? send(HTTP_OK, { id: 1, login: "e2e-user", name: "E2E User" })
|
||||||
}
|
: send(HTTP_UNAUTHORIZED, { message: "401 Unauthorized" });
|
||||||
return send(HTTP_NOT_FOUND, { message: "404 Not Found" });
|
}
|
||||||
});
|
return send(HTTP_NOT_FOUND, { message: "404 Not Found" });
|
||||||
return new Promise((resolve) => {
|
});
|
||||||
server.listen(0, "127.0.0.1", () => {
|
return new Promise((resolve) => {
|
||||||
resolve({
|
server.listen(0, "127.0.0.1", () => {
|
||||||
url: `http://127.0.0.1:${server.address().port}`,
|
resolve({
|
||||||
pat: GOOD_PAT,
|
url: `http://127.0.0.1:${server.address().port}`,
|
||||||
seen,
|
pat: GOOD_PAT,
|
||||||
close() {
|
seen,
|
||||||
server.close();
|
close() {
|
||||||
},
|
server.close();
|
||||||
});
|
},
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
+71
-47
@@ -5,56 +5,80 @@
|
|||||||
import { rest } from "../lib.mjs";
|
import { rest } from "../lib.mjs";
|
||||||
|
|
||||||
export async function run(ctx) {
|
export async function run(ctx) {
|
||||||
const { r, base, token, gitlab } = ctx;
|
const { r, base, token, gitlab } = ctx;
|
||||||
|
|
||||||
const connect = await rest(base, token, "/api/gitlab/connect", {
|
const connect = await rest(base, token, "/api/gitlab/connect", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { token: gitlab.pat },
|
body: { token: gitlab.pat },
|
||||||
});
|
});
|
||||||
r.check(
|
r.check(
|
||||||
"POST /api/gitlab/connect → 200 {username}",
|
"POST /api/gitlab/connect → 200 {username}",
|
||||||
connect.status === 200 && connect.json?.username === "e2e-user",
|
connect.status === 200 && connect.json?.username === "e2e-user",
|
||||||
`got ${connect.status} ${connect.text}`,
|
`got ${connect.status} ${connect.text}`,
|
||||||
);
|
);
|
||||||
r.check(
|
r.check(
|
||||||
"daemon validated token against fake /api/v1/user",
|
"daemon validated token against fake /api/v1/user",
|
||||||
gitlab.seen.userAuths.length === 1 && gitlab.seen.userAuths[0] === `token ${gitlab.pat}`,
|
gitlab.seen.userAuths.length === 1 &&
|
||||||
JSON.stringify(gitlab.seen.userAuths),
|
gitlab.seen.userAuths[0] === `token ${gitlab.pat}`,
|
||||||
);
|
JSON.stringify(gitlab.seen.userAuths),
|
||||||
|
);
|
||||||
|
|
||||||
const status = await rest(base, token, "/api/gitlab/status");
|
const status = await rest(base, token, "/api/gitlab/status");
|
||||||
r.check(
|
r.check(
|
||||||
"status shows connected with username + baseUrl",
|
"status shows connected with username + baseUrl",
|
||||||
status.json?.connected === true && status.json?.username === "e2e-user" && status.json?.baseUrl === gitlab.url,
|
status.json?.connected === true &&
|
||||||
JSON.stringify(status.json),
|
status.json?.username === "e2e-user" &&
|
||||||
);
|
status.json?.baseUrl === gitlab.url,
|
||||||
|
JSON.stringify(status.json),
|
||||||
|
);
|
||||||
|
|
||||||
const repos = await rest(base, token, "/api/gitlab/repos");
|
const repos = await rest(base, token, "/api/gitlab/repos");
|
||||||
r.check(
|
r.check(
|
||||||
"repos mapped to protocol shape",
|
"repos mapped to protocol shape",
|
||||||
Array.isArray(repos.json) && repos.json.length === 2 &&
|
Array.isArray(repos.json) &&
|
||||||
repos.json.every((p) => typeof p.path === "string" && typeof p.name === "string" && typeof p.namespace === "string" && typeof p.lastActivityAt === "string" && typeof p.webUrl === "string" && typeof p.defaultBranch === "string"),
|
repos.json.length === 2 &&
|
||||||
JSON.stringify(repos.json ?? null),
|
repos.json.every(
|
||||||
);
|
(p) =>
|
||||||
r.check(
|
typeof p.path === "string" &&
|
||||||
"repos sorted by lastActivityAt desc (daemon-side sort)",
|
typeof p.name === "string" &&
|
||||||
repos.json?.[0]?.path === "lvmh/alpha" && repos.json?.[1]?.path === "lvmh/beta" &&
|
typeof p.namespace === "string" &&
|
||||||
repos.json?.[0]?.defaultBranch === "trunk",
|
typeof p.lastActivityAt === "string" &&
|
||||||
JSON.stringify((repos.json ?? []).map((p) => p.path)),
|
typeof p.webUrl === "string" &&
|
||||||
);
|
typeof p.defaultBranch === "string",
|
||||||
|
),
|
||||||
|
JSON.stringify(repos.json ?? null),
|
||||||
|
);
|
||||||
|
r.check(
|
||||||
|
"repos sorted by lastActivityAt desc (daemon-side sort)",
|
||||||
|
repos.json?.[0]?.path === "lvmh/alpha" &&
|
||||||
|
repos.json?.[1]?.path === "lvmh/beta" &&
|
||||||
|
repos.json?.[0]?.defaultBranch === "trunk",
|
||||||
|
JSON.stringify((repos.json ?? []).map((p) => p.path)),
|
||||||
|
);
|
||||||
|
|
||||||
const leaky = [connect.text, status.text, repos.text].filter((t) => t.includes(gitlab.pat));
|
const leaky = [connect.text, status.text, repos.text].filter((t) =>
|
||||||
r.check("PAT never appears in any daemon response", leaky.length === 0, `leaked in ${leaky.length} response(s)`);
|
t.includes(gitlab.pat),
|
||||||
|
);
|
||||||
|
r.check(
|
||||||
|
"PAT never appears in any daemon response",
|
||||||
|
leaky.length === 0,
|
||||||
|
`leaked in ${leaky.length} response(s)`,
|
||||||
|
);
|
||||||
|
|
||||||
const badConnect = await rest(base, token, "/api/gitlab/connect", {
|
const badConnect = await rest(base, token, "/api/gitlab/connect", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
body: { token: "glpat-wrong" },
|
body: { token: "glpat-wrong" },
|
||||||
});
|
});
|
||||||
r.check("connect with bad PAT → error status", badConnect.status >= 400, `got ${badConnect.status}`);
|
r.check(
|
||||||
const statusAfter = await rest(base, token, "/api/gitlab/status");
|
"connect with bad PAT → error status",
|
||||||
r.check(
|
badConnect.status >= 400,
|
||||||
"failed connect does not clobber the stored PAT",
|
`got ${badConnect.status}`,
|
||||||
statusAfter.json?.connected === true && statusAfter.json?.username === "e2e-user",
|
);
|
||||||
JSON.stringify(statusAfter.json),
|
const statusAfter = await rest(base, token, "/api/gitlab/status");
|
||||||
);
|
r.check(
|
||||||
|
"failed connect does not clobber the stored PAT",
|
||||||
|
statusAfter.json?.connected === true &&
|
||||||
|
statusAfter.json?.username === "e2e-user",
|
||||||
|
JSON.stringify(statusAfter.json),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+183
-113
@@ -1,155 +1,225 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
act,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
import { MemoryRouter } from "react-router-dom";
|
import { MemoryRouter } from "react-router-dom";
|
||||||
import { afterEach, describe, expect, it } from "vitest";
|
import { afterEach, describe, expect, it } from "vitest";
|
||||||
import App from "./App";
|
import App from "./App";
|
||||||
import { clearSettings } from "./settings";
|
import { clearSettings } from "./settings";
|
||||||
import { FakeWebSocket, jsonResponse, mockFetchJson, seedSettings, stubReload } from "./test/setup";
|
import {
|
||||||
|
FakeWebSocket,
|
||||||
|
jsonResponse,
|
||||||
|
mockFetchJson,
|
||||||
|
seedSettings,
|
||||||
|
stubReload,
|
||||||
|
} from "./test/setup";
|
||||||
import type { SessionListItem } from "./protocol";
|
import type { SessionListItem } from "./protocol";
|
||||||
|
|
||||||
const sessions: SessionListItem[] = [
|
const sessions: SessionListItem[] = [
|
||||||
{ id: "s1", name: "alpha", cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: false, repo: "g/a", startedAt: 10, online: true, lastEventAt: null },
|
{
|
||||||
{ id: "s2", name: null, cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: true, repo: "g/b", startedAt: 20, online: false, lastEventAt: null },
|
id: "s1",
|
||||||
// bare session: exercises null-name/null-repo fallbacks
|
name: "alpha",
|
||||||
{ id: "s3", name: null, cwd: "/w", model: "glm-5.3", provider: "zai-renaud", agent: false, repo: null, startedAt: 30, online: false, lastEventAt: null },
|
cwd: "/w",
|
||||||
|
model: "glm-5.3",
|
||||||
|
provider: "zai-renaud",
|
||||||
|
agent: false,
|
||||||
|
repo: "g/a",
|
||||||
|
startedAt: 10,
|
||||||
|
online: true,
|
||||||
|
lastEventAt: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "s2",
|
||||||
|
name: null,
|
||||||
|
cwd: "/w",
|
||||||
|
model: "glm-5.3",
|
||||||
|
provider: "zai-renaud",
|
||||||
|
agent: true,
|
||||||
|
repo: "g/b",
|
||||||
|
startedAt: 20,
|
||||||
|
online: false,
|
||||||
|
lastEventAt: null,
|
||||||
|
},
|
||||||
|
// bare session: exercises null-name/null-repo fallbacks
|
||||||
|
{
|
||||||
|
id: "s3",
|
||||||
|
name: null,
|
||||||
|
cwd: "/w",
|
||||||
|
model: "glm-5.3",
|
||||||
|
provider: "zai-renaud",
|
||||||
|
agent: false,
|
||||||
|
repo: null,
|
||||||
|
startedAt: 30,
|
||||||
|
online: false,
|
||||||
|
lastEventAt: null,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function seedApi(): void {
|
function seedApi(): void {
|
||||||
mockFetchJson((url) => {
|
mockFetchJson((url) => {
|
||||||
if (url.includes("/api/sessions")) return url.includes("/events") ? [] : sessions;
|
if (url.includes("/api/sessions"))
|
||||||
if (url.includes("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" };
|
return url.includes("/events") ? [] : sessions;
|
||||||
return [];
|
if (url.includes("/api/gitlab/status"))
|
||||||
});
|
return { connected: false, baseUrl: "https://gl" };
|
||||||
|
return [];
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderApp(path = "/"): ReturnType<typeof render> {
|
function renderApp(path = "/"): ReturnType<typeof render> {
|
||||||
return render(
|
return render(
|
||||||
<MemoryRouter initialEntries={[path]}>
|
<MemoryRouter initialEntries={[path]}>
|
||||||
<App />
|
<App />
|
||||||
</MemoryRouter>
|
</MemoryRouter>,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
clearSettings();
|
clearSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("App gate", () => {
|
describe("App gate", () => {
|
||||||
it("shows the settings gate when unconfigured", () => {
|
it("shows the settings gate when unconfigured", () => {
|
||||||
renderApp();
|
renderApp();
|
||||||
expect(screen.getByText("Connect to your lvmh daemon.")).toBeInTheDocument();
|
expect(
|
||||||
expect(screen.queryByLabelText("Sessions")).toBeNull();
|
screen.getByText("Connect to your lvmh daemon."),
|
||||||
});
|
).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Sessions")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("after saving settings the shell renders", async () => {
|
it("after saving settings the shell renders", async () => {
|
||||||
seedApi();
|
seedApi();
|
||||||
renderApp();
|
renderApp();
|
||||||
fireEvent.input(screen.getByLabelText("Bearer token"), { target: { value: "tok" } });
|
fireEvent.input(screen.getByLabelText("Bearer token"), {
|
||||||
fireEvent.submit(screen.getByRole("button", { name: "Connect" }).closest("form") as HTMLFormElement);
|
target: { value: "tok" },
|
||||||
await screen.findByLabelText("Sessions");
|
});
|
||||||
});
|
fireEvent.submit(
|
||||||
|
screen
|
||||||
|
.getByRole("button", { name: "Connect" })
|
||||||
|
.closest("form") as HTMLFormElement,
|
||||||
|
);
|
||||||
|
await screen.findByLabelText("Sessions");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("App shell", () => {
|
describe("App shell", () => {
|
||||||
it("renders sidebar sessions sorted by activity, conn state, routes and toasts", async () => {
|
it("renders sidebar sessions sorted by activity, conn state, routes and toasts", async () => {
|
||||||
seedApi();
|
seedApi();
|
||||||
seedSettings();
|
seedSettings();
|
||||||
renderApp("/");
|
renderApp("/");
|
||||||
|
|
||||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||||
const sock = FakeWebSocket.last();
|
const sock = FakeWebSocket.last();
|
||||||
act(() => sock.serverOpen());
|
act(() => sock.serverOpen());
|
||||||
expect(screen.getByRole("img", { name: "connection open" })).toBeInTheDocument();
|
expect(
|
||||||
|
screen.getByRole("img", { name: "connection open" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
const sidebar = screen.getByLabelText("Sessions");
|
const sidebar = screen.getByLabelText("Sessions");
|
||||||
const links = Array.from(sidebar.querySelectorAll("a.session-link")).map((a) => a.getAttribute("href"));
|
const links = Array.from(sidebar.querySelectorAll("a.session-link")).map(
|
||||||
expect(links).toEqual(["/s/s3", "/s/s2", "/s/s1"]); // all null lastEventAt -> startedAt desc
|
(a) => a.getAttribute("href"),
|
||||||
expect(screen.getByTitle("ws open")).toBeInTheDocument();
|
);
|
||||||
|
expect(links).toEqual(["/s/s3", "/s/s2", "/s/s1"]); // all null lastEventAt -> startedAt desc
|
||||||
|
expect(screen.getByTitle("ws open")).toBeInTheDocument();
|
||||||
|
|
||||||
// root route lists sessions
|
// root route lists sessions
|
||||||
expect(screen.getByRole("heading", { name: "Sessions" })).toBeInTheDocument();
|
expect(
|
||||||
expect(screen.getByLabelText("Open session alpha")).toBeInTheDocument();
|
screen.getByRole("heading", { name: "Sessions" }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Open session alpha")).toBeInTheDocument();
|
||||||
|
|
||||||
// spawn link navigates
|
// spawn link navigates
|
||||||
fireEvent.click(screen.getByText("+ Spawn session"));
|
fireEvent.click(screen.getByText("+ Spawn session"));
|
||||||
await screen.findByRole("heading", { name: "Spawn" });
|
await screen.findByRole("heading", { name: "Spawn" });
|
||||||
|
|
||||||
// unknown route redirects to sessions
|
// unknown route redirects to sessions
|
||||||
const { container: c2 } = renderApp("/nowhere");
|
const { container: c2 } = renderApp("/nowhere");
|
||||||
await waitFor(() => expect(c2.querySelector(".session-card, .empty")).not.toBeNull());
|
await waitFor(() =>
|
||||||
});
|
expect(c2.querySelector(".session-card, .empty")).not.toBeNull(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("navigates to a session chat route", async () => {
|
it("navigates to a session chat route", async () => {
|
||||||
seedApi();
|
seedApi();
|
||||||
seedSettings();
|
seedSettings();
|
||||||
renderApp("/s/s1");
|
renderApp("/s/s1");
|
||||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||||
act(() => FakeWebSocket.last().serverOpen());
|
act(() => FakeWebSocket.last().serverOpen());
|
||||||
await screen.findByLabelText("Message");
|
await screen.findByLabelText("Message");
|
||||||
expect(screen.getAllByText("alpha").length).toBeGreaterThan(0);
|
expect(screen.getAllByText("alpha").length).toBeGreaterThan(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("menu button toggles the sidebar and it closes on navigation", async () => {
|
it("menu button toggles the sidebar and it closes on navigation", async () => {
|
||||||
seedApi();
|
seedApi();
|
||||||
seedSettings();
|
seedSettings();
|
||||||
renderApp("/");
|
renderApp("/");
|
||||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||||
act(() => FakeWebSocket.last().serverOpen());
|
act(() => FakeWebSocket.last().serverOpen());
|
||||||
await screen.findByRole("heading", { name: "Sessions" });
|
await screen.findByRole("heading", { name: "Sessions" });
|
||||||
|
|
||||||
const menu = screen.getByLabelText("Open menu");
|
const menu = screen.getByLabelText("Open menu");
|
||||||
fireEvent.click(menu);
|
fireEvent.click(menu);
|
||||||
expect(screen.getByLabelText("Sessions").className).toContain("open");
|
expect(screen.getByLabelText("Sessions").className).toContain("open");
|
||||||
|
|
||||||
// backdrop closes it
|
// backdrop closes it
|
||||||
fireEvent.click(container_backdrop()!);
|
fireEvent.click(container_backdrop()!);
|
||||||
expect(screen.getByLabelText("Sessions").className).not.toContain("open");
|
expect(screen.getByLabelText("Sessions").className).not.toContain("open");
|
||||||
|
|
||||||
fireEvent.click(menu);
|
fireEvent.click(menu);
|
||||||
fireEvent.click(screen.getByText("+ Spawn session"));
|
fireEvent.click(screen.getByText("+ Spawn session"));
|
||||||
await waitFor(() => expect(screen.getByLabelText("Sessions").className).not.toContain("open"));
|
await waitFor(() =>
|
||||||
});
|
expect(screen.getByLabelText("Sessions").className).not.toContain("open"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("disconnect clears settings and reloads", async () => {
|
it("disconnect clears settings and reloads", async () => {
|
||||||
const loc = stubReload();
|
const loc = stubReload();
|
||||||
seedApi();
|
seedApi();
|
||||||
seedSettings();
|
seedSettings();
|
||||||
renderApp("/");
|
renderApp("/");
|
||||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||||
act(() => FakeWebSocket.last().serverOpen());
|
act(() => FakeWebSocket.last().serverOpen());
|
||||||
await screen.findByRole("heading", { name: "Sessions" });
|
await screen.findByRole("heading", { name: "Sessions" });
|
||||||
|
|
||||||
fireEvent.click(screen.getByLabelText("Disconnect and clear settings"));
|
fireEvent.click(screen.getByLabelText("Disconnect and clear settings"));
|
||||||
expect(loc.reload).toHaveBeenCalled();
|
expect(loc.reload).toHaveBeenCalled();
|
||||||
expect(localStorage.getItem("lvmh.settings")).toBeNull();
|
expect(localStorage.getItem("lvmh.settings")).toBeNull();
|
||||||
loc.restore();
|
loc.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ws auth failure clears settings and re-gates", async () => {
|
it("ws auth failure clears settings and re-gates", async () => {
|
||||||
const loc = stubReload();
|
const loc = stubReload();
|
||||||
seedApi();
|
seedApi();
|
||||||
seedSettings();
|
seedSettings();
|
||||||
renderApp("/");
|
renderApp("/");
|
||||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||||
act(() => FakeWebSocket.last().serverOpen());
|
act(() => FakeWebSocket.last().serverOpen());
|
||||||
await screen.findByRole("heading", { name: "Sessions" });
|
await screen.findByRole("heading", { name: "Sessions" });
|
||||||
|
|
||||||
act(() => FakeWebSocket.last().serverClose(1008));
|
act(() => FakeWebSocket.last().serverClose(1008));
|
||||||
expect(loc.reload).toHaveBeenCalled();
|
expect(loc.reload).toHaveBeenCalled();
|
||||||
await waitFor(() => expect(screen.getByText("Connect to your lvmh daemon.")).toBeInTheDocument());
|
await waitFor(() =>
|
||||||
loc.restore();
|
expect(
|
||||||
});
|
screen.getByText("Connect to your lvmh daemon."),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
loc.restore();
|
||||||
|
});
|
||||||
|
|
||||||
it("REST seed failure surfaces a toast", async () => {
|
it("REST seed failure surfaces a toast", async () => {
|
||||||
mockFetchJson((url) => {
|
mockFetchJson((url) => {
|
||||||
if (url.includes("/api/sessions") && !url.includes("/events")) return jsonResponse({ error: "seed fail" }, 500);
|
if (url.includes("/api/sessions") && !url.includes("/events"))
|
||||||
return [];
|
return jsonResponse({ error: "seed fail" }, 500);
|
||||||
});
|
return [];
|
||||||
seedSettings();
|
});
|
||||||
renderApp("/");
|
seedSettings();
|
||||||
await screen.findByText("sessions: seed fail");
|
renderApp("/");
|
||||||
});
|
await screen.findByText("sessions: seed fail");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function container_backdrop(): HTMLElement | null {
|
function container_backdrop(): HTMLElement | null {
|
||||||
return document.querySelector(".sidebar-backdrop");
|
return document.querySelector(".sidebar-backdrop");
|
||||||
}
|
}
|
||||||
|
|||||||
+266
-143
@@ -5,175 +5,298 @@ import type { ChatMessage, ToolState } from "./derive";
|
|||||||
import ChatStream, { Bubble, TypingIndicator } from "./ChatStream";
|
import ChatStream, { Bubble, TypingIndicator } from "./ChatStream";
|
||||||
|
|
||||||
function msg(partial: Partial<ChatMessage>): ChatMessage {
|
function msg(partial: Partial<ChatMessage>): ChatMessage {
|
||||||
return {
|
return {
|
||||||
key: `k-${Math.random()}`,
|
key: `k-${Math.random()}`,
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
text: "",
|
text: "",
|
||||||
thinking: null,
|
thinking: null,
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
toolCallId: null,
|
toolCallId: null,
|
||||||
streaming: false,
|
streaming: false,
|
||||||
...partial,
|
...partial,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const tool = (p: Partial<ToolState>): ToolState => ({
|
const tool = (p: Partial<ToolState>): ToolState => ({
|
||||||
id: "c1",
|
id: "c1",
|
||||||
name: "bash",
|
name: "bash",
|
||||||
args: "",
|
args: "",
|
||||||
running: false,
|
running: false,
|
||||||
isError: false,
|
isError: false,
|
||||||
preview: "",
|
preview: "",
|
||||||
...p,
|
...p,
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("Bubble", () => {
|
describe("Bubble", () => {
|
||||||
it("renders plain text per role class", () => {
|
it("renders plain text per role class", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<Bubble msg={msg({ role: "user", text: "hi there" })} tools={new Map()} />
|
<Bubble
|
||||||
);
|
msg={msg({ role: "user", text: "hi there" })}
|
||||||
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
|
tools={new Map()}
|
||||||
expect(container.textContent).toContain("hi there");
|
/>,
|
||||||
});
|
);
|
||||||
|
expect(container.querySelector(".bubble-row.user")).not.toBeNull();
|
||||||
|
expect(container.textContent).toContain("hi there");
|
||||||
|
});
|
||||||
|
|
||||||
it("renders empty assistant text as nothing but shows nothing when empty", () => {
|
it("renders empty assistant text as nothing but shows nothing when empty", () => {
|
||||||
const { container } = render(<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />);
|
const { container } = render(
|
||||||
expect(container.querySelector(".bubble")?.children).toHaveLength(0);
|
<Bubble msg={msg({ role: "assistant", text: "" })} tools={new Map()} />,
|
||||||
});
|
);
|
||||||
|
expect(container.querySelector(".bubble")?.children).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("toolResult collapses to a one-line preview, expands to full text", async () => {
|
it("toolResult collapses to a one-line preview, expands to full text", async () => {
|
||||||
const long = `${"x".repeat(200)}`;
|
const long = `${"x".repeat(200)}`;
|
||||||
render(<Bubble msg={msg({ role: "toolResult", text: long })} tools={new Map()} />);
|
render(
|
||||||
const details = screen.getByText("result").closest("details") as HTMLDetailsElement;
|
<Bubble
|
||||||
expect(details.open).toBe(false);
|
msg={msg({ role: "toolResult", text: long })}
|
||||||
expect(details.textContent).toContain("…");
|
tools={new Map()}
|
||||||
await userEvent.click(screen.getByText("result"));
|
/>,
|
||||||
expect(details.open).toBe(true);
|
);
|
||||||
expect(details.textContent).toContain(long);
|
const details = screen
|
||||||
});
|
.getByText("result")
|
||||||
|
.closest("details") as HTMLDetailsElement;
|
||||||
|
expect(details.open).toBe(false);
|
||||||
|
expect(details.textContent).toContain("…");
|
||||||
|
await userEvent.click(screen.getByText("result"));
|
||||||
|
expect(details.open).toBe(true);
|
||||||
|
expect(details.textContent).toContain(long);
|
||||||
|
});
|
||||||
|
|
||||||
it("toolResult with short text keeps full one-line preview", () => {
|
it("toolResult with short text keeps full one-line preview", () => {
|
||||||
render(<Bubble msg={msg({ role: "toolResult", text: "short out" })} tools={new Map()} />);
|
render(
|
||||||
const details = screen.getByText("result").closest("details") as HTMLDetailsElement;
|
<Bubble
|
||||||
expect(details.textContent).toContain("short out");
|
msg={msg({ role: "toolResult", text: "short out" })}
|
||||||
expect(details.textContent).not.toContain("…");
|
tools={new Map()}
|
||||||
});
|
/>,
|
||||||
|
);
|
||||||
|
const details = screen
|
||||||
|
.getByText("result")
|
||||||
|
.closest("details") as HTMLDetailsElement;
|
||||||
|
expect(details.textContent).toContain("short out");
|
||||||
|
expect(details.textContent).not.toContain("…");
|
||||||
|
});
|
||||||
|
|
||||||
it("flattens whitespace in previews", () => {
|
it("flattens whitespace in previews", () => {
|
||||||
render(<Bubble msg={msg({ role: "toolResult", text: "a\n\n b c" })} tools={new Map()} />);
|
render(
|
||||||
expect(screen.getAllByText("a b c").length).toBeGreaterThan(0);
|
<Bubble
|
||||||
});
|
msg={msg({ role: "toolResult", text: "a\n\n b c" })}
|
||||||
|
tools={new Map()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getAllByText("a b c").length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("assistant tool calls attach tool cards", async () => {
|
it("assistant tool calls attach tool cards", async () => {
|
||||||
const tools = new Map<string, ToolState>([
|
const tools = new Map<string, ToolState>([
|
||||||
["c1", tool({ id: "c1", name: "bash", args: "ls -la", running: false, isError: false, preview: "file" })],
|
[
|
||||||
]);
|
"c1",
|
||||||
render(
|
tool({
|
||||||
<Bubble msg={msg({ role: "assistant", text: "finished", toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }] })} tools={tools} />
|
id: "c1",
|
||||||
);
|
name: "bash",
|
||||||
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
|
args: "ls -la",
|
||||||
expect(screen.getByText("finished")).toBeInTheDocument();
|
running: false,
|
||||||
|
isError: false,
|
||||||
|
preview: "file",
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
render(
|
||||||
|
<Bubble
|
||||||
|
msg={msg({
|
||||||
|
role: "assistant",
|
||||||
|
text: "finished",
|
||||||
|
toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }],
|
||||||
|
})}
|
||||||
|
tools={tools}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("🛠 bash")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("finished")).toBeInTheDocument();
|
||||||
|
|
||||||
const summary = screen.getByText("🛠 bash").closest("summary") as HTMLElement;
|
const summary = screen
|
||||||
const card = summary.closest("details") as HTMLDetailsElement;
|
.getByText("🛠 bash")
|
||||||
expect(card.open).toBe(false);
|
.closest("summary") as HTMLElement;
|
||||||
await userEvent.click(summary);
|
const card = summary.closest("details") as HTMLDetailsElement;
|
||||||
expect(card.open).toBe(true);
|
expect(card.open).toBe(false);
|
||||||
expect(card.textContent).toContain("ls -la");
|
await userEvent.click(summary);
|
||||||
expect(card.textContent).toContain("file");
|
expect(card.open).toBe(true);
|
||||||
});
|
expect(card.textContent).toContain("ls -la");
|
||||||
|
expect(card.textContent).toContain("file");
|
||||||
|
});
|
||||||
|
|
||||||
it("tool card status variants: running, error, done", () => {
|
it("tool card status variants: running, error, done", () => {
|
||||||
const tools = new Map<string, ToolState>([
|
const tools = new Map<string, ToolState>([
|
||||||
["c1", tool({ id: "c1", running: true })],
|
["c1", tool({ id: "c1", running: true })],
|
||||||
["c2", tool({ id: "c2", running: false, isError: true })],
|
["c2", tool({ id: "c2", running: false, isError: true })],
|
||||||
["c3", tool({ id: "c3", running: false, isError: false })],
|
["c3", tool({ id: "c3", running: false, isError: false })],
|
||||||
]);
|
]);
|
||||||
render(
|
render(
|
||||||
<Bubble
|
<Bubble
|
||||||
msg={msg({
|
msg={msg({
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
toolCalls: ["c1", "c2", "c3"].map((id) => ({ id, name: `t-${id}`, argsJson: "{}" })),
|
toolCalls: ["c1", "c2", "c3"].map((id) => ({
|
||||||
})}
|
id,
|
||||||
tools={tools}
|
name: `t-${id}`,
|
||||||
/>
|
argsJson: "{}",
|
||||||
);
|
})),
|
||||||
expect(screen.getByText("working…")).toBeInTheDocument();
|
})}
|
||||||
expect(screen.getByText("error")).toBeInTheDocument();
|
tools={tools}
|
||||||
expect(screen.getByText("done")).toBeInTheDocument();
|
/>,
|
||||||
});
|
);
|
||||||
|
expect(screen.getByText("working…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("error")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("done")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("tool call with no matching state renders no card", () => {
|
it("tool call with no matching state renders no card", () => {
|
||||||
const { container } = render(
|
const { container } = render(
|
||||||
<Bubble msg={msg({ role: "assistant", toolCalls: [{ id: "ghost", name: "x", argsJson: "{}" }] })} tools={new Map()} />
|
<Bubble
|
||||||
);
|
msg={msg({
|
||||||
expect(container.querySelectorAll(".tool-card")).toHaveLength(0);
|
role: "assistant",
|
||||||
});
|
toolCalls: [{ id: "ghost", name: "x", argsJson: "{}" }],
|
||||||
|
})}
|
||||||
|
tools={new Map()}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.querySelectorAll(".tool-card")).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("thinking block only for non-empty thinking", async () => {
|
it("thinking block only for non-empty thinking", async () => {
|
||||||
render(<Bubble msg={msg({ thinking: "because" })} tools={new Map()} />);
|
render(<Bubble msg={msg({ thinking: "because" })} tools={new Map()} />);
|
||||||
const details = screen.getByText("thinking").closest("details") as HTMLDetailsElement;
|
const details = screen
|
||||||
await userEvent.click(screen.getByText("thinking"));
|
.getByText("thinking")
|
||||||
expect(details.open).toBe(true);
|
.closest("details") as HTMLDetailsElement;
|
||||||
expect(details.textContent).toContain("because");
|
await userEvent.click(screen.getByText("thinking"));
|
||||||
|
expect(details.open).toBe(true);
|
||||||
|
expect(details.textContent).toContain("because");
|
||||||
|
|
||||||
const { container } = render(<Bubble msg={msg({ thinking: null })} tools={new Map()} />);
|
const { container } = render(
|
||||||
expect(container.querySelector(".thinking")).toBeNull();
|
<Bubble msg={msg({ thinking: null })} tools={new Map()} />,
|
||||||
});
|
);
|
||||||
|
expect(container.querySelector(".thinking")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("streaming bubble shows the caret", () => {
|
it("streaming bubble shows the caret", () => {
|
||||||
const { container } = render(<Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />);
|
const { container } = render(
|
||||||
expect(container.querySelector(".stream-caret")).not.toBeNull();
|
<Bubble msg={msg({ text: "par", streaming: true })} tools={new Map()} />,
|
||||||
});
|
);
|
||||||
|
expect(container.querySelector(".stream-caret")).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("TypingIndicator", () => {
|
describe("TypingIndicator", () => {
|
||||||
it("renders three dots with aria-live", () => {
|
it("renders three dots with aria-live", () => {
|
||||||
const { container } = render(<TypingIndicator />);
|
const { container } = render(<TypingIndicator />);
|
||||||
expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
|
expect(container.querySelector('[aria-live="polite"]')).not.toBeNull();
|
||||||
expect(container.querySelectorAll(".dot")).toHaveLength(3);
|
expect(container.querySelectorAll(".dot")).toHaveLength(3);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ChatStream", () => {
|
describe("ChatStream", () => {
|
||||||
it("renders messages and typing indicator while busy with no open stream", () => {
|
it("renders messages and typing indicator while busy with no open stream", () => {
|
||||||
const { container, rerender } = render(
|
const { container, rerender } = render(
|
||||||
<ChatStream messages={[msg({ key: "a", text: "one" })]} tools={new Map()} busy />
|
<ChatStream
|
||||||
);
|
messages={[msg({ key: "a", text: "one" })]}
|
||||||
expect(container.querySelector(".typing")).not.toBeNull();
|
tools={new Map()}
|
||||||
|
busy
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".typing")).not.toBeNull();
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<ChatStream messages={[msg({ key: "a", text: "one" }), msg({ key: "b", streaming: true })]} tools={new Map()} busy />
|
<ChatStream
|
||||||
);
|
messages={[
|
||||||
expect(container.querySelector(".typing")).toBeNull();
|
msg({ key: "a", text: "one" }),
|
||||||
rerender(<ChatStream messages={[msg({ key: "a", text: "one" })]} tools={new Map()} busy={false} />);
|
msg({ key: "b", streaming: true }),
|
||||||
expect(container.querySelector(".typing")).toBeNull();
|
]}
|
||||||
});
|
tools={new Map()}
|
||||||
|
busy
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".typing")).toBeNull();
|
||||||
|
rerender(
|
||||||
|
<ChatStream
|
||||||
|
messages={[msg({ key: "a", text: "one" })]}
|
||||||
|
tools={new Map()}
|
||||||
|
busy={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(container.querySelector(".typing")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
|
it("sticks to bottom on new messages, un-pins on user scroll up, re-pins near bottom", () => {
|
||||||
const { container, rerender } = render(<ChatStream messages={[msg({ key: "a" })]} tools={new Map()} busy={false} />);
|
const { container, rerender } = render(
|
||||||
const scroller = container.querySelector(".chat-scroll") as HTMLElement;
|
<ChatStream
|
||||||
Object.defineProperty(scroller, "scrollHeight", { configurable: true, value: 1000 });
|
messages={[msg({ key: "a" })]}
|
||||||
Object.defineProperty(scroller, "clientHeight", { configurable: true, value: 300 });
|
tools={new Map()}
|
||||||
|
busy={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const scroller = container.querySelector(".chat-scroll") as HTMLElement;
|
||||||
|
Object.defineProperty(scroller, "scrollHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: 1000,
|
||||||
|
});
|
||||||
|
Object.defineProperty(scroller, "clientHeight", {
|
||||||
|
configurable: true,
|
||||||
|
value: 300,
|
||||||
|
});
|
||||||
|
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
// pinned: scrollTop at bottom
|
// pinned: scrollTop at bottom
|
||||||
scroller.scrollTop = 700;
|
scroller.scrollTop = 700;
|
||||||
Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 700 });
|
Object.defineProperty(scroller, "scrollTop", {
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
configurable: true,
|
||||||
rerender(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" })]} tools={new Map()} busy={false} />);
|
writable: true,
|
||||||
expect(scroller.scrollTop).toBe(1000);
|
value: 700,
|
||||||
|
});
|
||||||
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
|
rerender(
|
||||||
|
<ChatStream
|
||||||
|
messages={[msg({ key: "a" }), msg({ key: "b" })]}
|
||||||
|
tools={new Map()}
|
||||||
|
busy={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(scroller.scrollTop).toBe(1000);
|
||||||
|
|
||||||
// scroll far up -> unpin
|
// scroll far up -> unpin
|
||||||
Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 0 });
|
Object.defineProperty(scroller, "scrollTop", {
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
configurable: true,
|
||||||
rerender(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })]} tools={new Map()} busy={false} />);
|
writable: true,
|
||||||
expect(scroller.scrollTop).toBe(0);
|
value: 0,
|
||||||
|
});
|
||||||
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
|
rerender(
|
||||||
|
<ChatStream
|
||||||
|
messages={[msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" })]}
|
||||||
|
tools={new Map()}
|
||||||
|
busy={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(scroller.scrollTop).toBe(0);
|
||||||
|
|
||||||
// scroll near bottom (within 80px) -> pinned again
|
// scroll near bottom (within 80px) -> pinned again
|
||||||
Object.defineProperty(scroller, "scrollTop", { configurable: true, writable: true, value: 940 });
|
Object.defineProperty(scroller, "scrollTop", {
|
||||||
scroller.dispatchEvent(new Event("scroll"));
|
configurable: true,
|
||||||
rerender(<ChatStream messages={[msg({ key: "a" }), msg({ key: "b" }), msg({ key: "c" }), msg({ key: "d" })]} tools={new Map()} busy={false} />);
|
writable: true,
|
||||||
expect(scroller.scrollTop).toBe(1000);
|
value: 940,
|
||||||
});
|
});
|
||||||
|
scroller.dispatchEvent(new Event("scroll"));
|
||||||
|
rerender(
|
||||||
|
<ChatStream
|
||||||
|
messages={[
|
||||||
|
msg({ key: "a" }),
|
||||||
|
msg({ key: "b" }),
|
||||||
|
msg({ key: "c" }),
|
||||||
|
msg({ key: "d" }),
|
||||||
|
]}
|
||||||
|
tools={new Map()}
|
||||||
|
busy={false}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(scroller.scrollTop).toBe(1000);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+336
-233
@@ -1,4 +1,10 @@
|
|||||||
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import {
|
||||||
|
act,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
import userEvent from "@testing-library/user-event";
|
import userEvent from "@testing-library/user-event";
|
||||||
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
import { MemoryRouter, Route, Routes } from "react-router-dom";
|
||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
@@ -10,288 +16,385 @@ import type { SessionListItem } from "./protocol";
|
|||||||
|
|
||||||
let seq: number = 0;
|
let seq: number = 0;
|
||||||
function ev(type: string, extra: Partial<EventFrame> = {}): EventFrame {
|
function ev(type: string, extra: Partial<EventFrame> = {}): EventFrame {
|
||||||
seq += 1;
|
seq += 1;
|
||||||
return { v: 1, sessionId: "s1", seq, ts: 0, type, ...extra } as EventFrame;
|
return { v: 1, sessionId: "s1", seq, ts: 0, type, ...extra } as EventFrame;
|
||||||
}
|
}
|
||||||
|
|
||||||
const sessions: SessionListItem[] = [
|
const sessions: SessionListItem[] = [
|
||||||
{
|
{
|
||||||
id: "s1",
|
id: "s1",
|
||||||
name: "worker",
|
name: "worker",
|
||||||
cwd: "/w",
|
cwd: "/w",
|
||||||
model: "glm-5.3",
|
model: "glm-5.3",
|
||||||
provider: "zai-renaud",
|
provider: "zai-renaud",
|
||||||
agent: false,
|
agent: false,
|
||||||
repo: null,
|
repo: null,
|
||||||
startedAt: 0,
|
startedAt: 0,
|
||||||
online: true,
|
online: true,
|
||||||
lastEventAt: 1,
|
lastEventAt: 1,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
|
function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
|
||||||
return {
|
return {
|
||||||
sessions,
|
sessions,
|
||||||
state: "open",
|
state: "open",
|
||||||
spawnJobs: [],
|
spawnJobs: [],
|
||||||
refresh: async () => undefined,
|
refresh: async () => undefined,
|
||||||
subscribe: (sessionId: string, onEvents: (events: EventFrame[]) => void): (() => void) => {
|
subscribe: (
|
||||||
currentSub = { sessionId, onEvents };
|
sessionId: string,
|
||||||
return () => {
|
onEvents: (events: EventFrame[]) => void,
|
||||||
if (currentSub !== null && currentSub.sessionId === sessionId) currentSub = null;
|
): (() => void) => {
|
||||||
};
|
currentSub = { sessionId, onEvents };
|
||||||
},
|
return () => {
|
||||||
...over,
|
if (currentSub !== null && currentSub.sessionId === sessionId)
|
||||||
};
|
currentSub = null;
|
||||||
|
};
|
||||||
|
},
|
||||||
|
...over,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ActiveSub {
|
interface ActiveSub {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
onEvents: (events: EventFrame[]) => void;
|
onEvents: (events: EventFrame[]) => void;
|
||||||
}
|
}
|
||||||
let currentSub: ActiveSub | null = null;
|
let currentSub: ActiveSub | null = null;
|
||||||
|
|
||||||
function push(events: EventFrame[]): void {
|
function push(events: EventFrame[]): void {
|
||||||
act(() => currentSub?.onEvents(events));
|
act(() => currentSub?.onEvents(events));
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderChat(store: SessionsStore, path = "/s/s1"): ReturnType<typeof render> {
|
function renderChat(
|
||||||
return render(
|
store: SessionsStore,
|
||||||
<MemoryRouter initialEntries={[path]}>
|
path = "/s/s1",
|
||||||
<Routes>
|
): ReturnType<typeof render> {
|
||||||
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
|
return render(
|
||||||
<Route path="*" element={<div>OTHER</div>} />
|
<MemoryRouter initialEntries={[path]}>
|
||||||
</Routes>
|
<Routes>
|
||||||
</MemoryRouter>
|
<Route
|
||||||
);
|
path="/s/:id"
|
||||||
|
element={<ChatView store={store} pushToast={pushToast} />}
|
||||||
|
/>
|
||||||
|
<Route path="*" element={<div>OTHER</div>} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const pushToast = vi.fn();
|
const pushToast = vi.fn();
|
||||||
|
|
||||||
function historyEvents(): EventFrame[] {
|
function historyEvents(): EventFrame[] {
|
||||||
seq = 0;
|
seq = 0;
|
||||||
return [
|
return [
|
||||||
ev("message_end", { message: { role: "user", id: "u1", text: "hello there", thinking: null, toolCalls: [], toolCallId: null } }),
|
ev("message_end", {
|
||||||
ev("agent_start"),
|
message: {
|
||||||
ev("message_end", { message: { role: "assistant", id: "a1", text: "hi!", thinking: "hmm", toolCalls: [], toolCallId: null } }),
|
role: "user",
|
||||||
ev("agent_settled"),
|
id: "u1",
|
||||||
];
|
text: "hello there",
|
||||||
|
thinking: null,
|
||||||
|
toolCalls: [],
|
||||||
|
toolCallId: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ev("agent_start"),
|
||||||
|
ev("message_end", {
|
||||||
|
message: {
|
||||||
|
role: "assistant",
|
||||||
|
id: "a1",
|
||||||
|
text: "hi!",
|
||||||
|
thinking: "hmm",
|
||||||
|
toolCalls: [],
|
||||||
|
toolCallId: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ev("agent_settled"),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
seq = 0;
|
seq = 0;
|
||||||
currentSub = null;
|
currentSub = null;
|
||||||
pushToast.mockClear();
|
pushToast.mockClear();
|
||||||
seedSettings();
|
seedSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("ChatView", () => {
|
describe("ChatView", () => {
|
||||||
it("renders history from REST on mount and subscribes to the WS stream", async () => {
|
it("renders history from REST on mount and subscribes to the WS stream", async () => {
|
||||||
const fetchMock = mockFetchJson((url) => {
|
const fetchMock = mockFetchJson((url) => {
|
||||||
if (url.startsWith("http://srv/api/sessions/s1/events")) return historyEvents();
|
if (url.startsWith("http://srv/api/sessions/s1/events"))
|
||||||
return [];
|
return historyEvents();
|
||||||
});
|
return [];
|
||||||
const store = makeStore();
|
});
|
||||||
const { rerender } = renderChat(store);
|
const store = makeStore();
|
||||||
|
const { rerender } = renderChat(store);
|
||||||
|
|
||||||
await screen.findByText("hello there");
|
await screen.findByText("hello there");
|
||||||
expect(screen.getByText("hi!")).toBeInTheDocument();
|
expect(screen.getByText("hi!")).toBeInTheDocument();
|
||||||
|
|
||||||
await waitFor(() => expect(currentSub).not.toBeNull());
|
await waitFor(() => expect(currentSub).not.toBeNull());
|
||||||
expect(currentSub?.sessionId).toBe("s1");
|
expect(currentSub?.sessionId).toBe("s1");
|
||||||
expect(fetchMock).toHaveBeenCalled();
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
|
|
||||||
// header shows session name + model + online dot
|
// header shows session name + model + online dot
|
||||||
expect(screen.getByText("worker")).toBeInTheDocument();
|
expect(screen.getByText("worker")).toBeInTheDocument();
|
||||||
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
|
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
|
||||||
expect(screen.getByTitle("online")).toBeInTheDocument();
|
expect(screen.getByTitle("online")).toBeInTheDocument();
|
||||||
|
|
||||||
rerender(
|
rerender(
|
||||||
<MemoryRouter initialEntries={["/s/s1"]}>
|
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
|
<Route
|
||||||
<Route path="*" element={<div>OTHER</div>} />
|
path="/s/:id"
|
||||||
</Routes>
|
element={<ChatView store={store} pushToast={pushToast} />}
|
||||||
</MemoryRouter>
|
/>
|
||||||
);
|
<Route path="*" element={<div>OTHER</div>} />
|
||||||
});
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("live delta streaming appends into a streaming bubble and ends it", async () => {
|
it("live delta streaming appends into a streaming bubble and ends it", async () => {
|
||||||
mockFetchJson(() => []);
|
mockFetchJson(() => []);
|
||||||
renderChat(makeStore());
|
renderChat(makeStore());
|
||||||
await waitFor(() => expect(currentSub).not.toBeNull());
|
await waitFor(() => expect(currentSub).not.toBeNull());
|
||||||
|
|
||||||
push([
|
push([
|
||||||
ev("message_start", { message: { role: "assistant", id: "a9", text: "", thinking: null, toolCalls: [], toolCallId: null } }),
|
ev("message_start", {
|
||||||
]);
|
message: {
|
||||||
push([ev("message_update", { delta: "Hel" })]);
|
role: "assistant",
|
||||||
push([ev("message_update", { delta: "lo" })]);
|
id: "a9",
|
||||||
expect(screen.getByText("Hello")).toBeInTheDocument();
|
text: "",
|
||||||
expect(screen.queryByLabelText("Send message")).toBeNull();
|
thinking: null,
|
||||||
expect(screen.getByLabelText("Abort current run")).toBeInTheDocument();
|
toolCalls: [],
|
||||||
|
toolCallId: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
push([ev("message_update", { delta: "Hel" })]);
|
||||||
|
push([ev("message_update", { delta: "lo" })]);
|
||||||
|
expect(screen.getByText("Hello")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByLabelText("Send message")).toBeNull();
|
||||||
|
expect(screen.getByLabelText("Abort current run")).toBeInTheDocument();
|
||||||
|
|
||||||
push([
|
push([
|
||||||
ev("message_end", { message: { role: "assistant", id: "a9", text: "Hello world", thinking: null, toolCalls: [], toolCallId: null } }),
|
ev("message_end", {
|
||||||
ev("agent_settled"),
|
message: {
|
||||||
]);
|
role: "assistant",
|
||||||
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
id: "a9",
|
||||||
expect(screen.getByLabelText("Send message")).toBeInTheDocument();
|
text: "Hello world",
|
||||||
});
|
thinking: null,
|
||||||
|
toolCalls: [],
|
||||||
|
toolCallId: null,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
ev("agent_settled"),
|
||||||
|
]);
|
||||||
|
expect(screen.getByText("Hello world")).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText("Send message")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("typing indicator shows while busy with no open stream", async () => {
|
it("typing indicator shows while busy with no open stream", async () => {
|
||||||
mockFetchJson(() => []);
|
mockFetchJson(() => []);
|
||||||
const { container } = renderChat(makeStore());
|
const { container } = renderChat(makeStore());
|
||||||
await waitFor(() => expect(currentSub).not.toBeNull());
|
await waitFor(() => expect(currentSub).not.toBeNull());
|
||||||
|
|
||||||
push([ev("agent_start")]);
|
push([ev("agent_start")]);
|
||||||
expect(container.querySelector(".typing")).not.toBeNull();
|
expect(container.querySelector(".typing")).not.toBeNull();
|
||||||
|
|
||||||
push([ev("agent_settled")]);
|
push([ev("agent_settled")]);
|
||||||
expect(container.querySelector(".typing")).toBeNull();
|
expect(container.querySelector(".typing")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("409 on send toasts 'session offline'", async () => {
|
it("409 on send toasts 'session offline'", async () => {
|
||||||
let n = 0;
|
let n = 0;
|
||||||
mockFetchJson((_url, init) => {
|
mockFetchJson((_url, init) => {
|
||||||
if (init?.method === "POST") {
|
if (init?.method === "POST") {
|
||||||
n += 1;
|
n += 1;
|
||||||
return jsonResponse({ error: "session offline" }, n === 1 ? 409 : 200);
|
return jsonResponse({ error: "session offline" }, n === 1 ? 409 : 200);
|
||||||
}
|
}
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
renderChat(makeStore());
|
renderChat(makeStore());
|
||||||
const ta = screen.getByLabelText("Message");
|
const ta = screen.getByLabelText("Message");
|
||||||
await userEvent.type(ta, "go");
|
await userEvent.type(ta, "go");
|
||||||
await userEvent.click(screen.getByLabelText("Send message"));
|
await userEvent.click(screen.getByLabelText("Send message"));
|
||||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("session offline"));
|
await vi.waitFor(() =>
|
||||||
});
|
expect(pushToast).toHaveBeenCalledWith("session offline"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("non-409 send failure toasts the error message", async () => {
|
it("non-409 send failure toasts the error message", async () => {
|
||||||
mockFetchJson((_url, init) => {
|
mockFetchJson((_url, init) => {
|
||||||
if (init?.method === "POST") return jsonResponse({ error: "nope" }, 500);
|
if (init?.method === "POST") return jsonResponse({ error: "nope" }, 500);
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
renderChat(makeStore());
|
renderChat(makeStore());
|
||||||
await userEvent.type(screen.getByLabelText("Message"), "go");
|
await userEvent.type(screen.getByLabelText("Message"), "go");
|
||||||
await userEvent.click(screen.getByLabelText("Send message"));
|
await userEvent.click(screen.getByLabelText("Send message"));
|
||||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope"));
|
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("nope"));
|
||||||
});
|
});
|
||||||
|
|
||||||
it("Enter sends, Shift+Enter adds a newline, send disabled while empty or sending", async () => {
|
it("Enter sends, Shift+Enter adds a newline, send disabled while empty or sending", async () => {
|
||||||
const fetchMock = mockFetchJson((_url, init) => (init?.method === "POST" ? { ok: true } : []));
|
const fetchMock = mockFetchJson((_url, init) =>
|
||||||
renderChat(makeStore());
|
init?.method === "POST" ? { ok: true } : [],
|
||||||
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
|
);
|
||||||
const send = screen.getByLabelText("Send message") as HTMLButtonElement;
|
renderChat(makeStore());
|
||||||
expect(send).toBeDisabled();
|
const ta = screen.getByLabelText("Message") as HTMLTextAreaElement;
|
||||||
|
const send = screen.getByLabelText("Send message") as HTMLButtonElement;
|
||||||
|
expect(send).toBeDisabled();
|
||||||
|
|
||||||
await userEvent.type(ta, "hello");
|
await userEvent.type(ta, "hello");
|
||||||
expect(send).not.toBeDisabled();
|
expect(send).not.toBeDisabled();
|
||||||
|
|
||||||
fireEvent.keyDown(ta, { key: "Enter", shiftKey: true });
|
fireEvent.keyDown(ta, { key: "Enter", shiftKey: true });
|
||||||
fireEvent.click(send);
|
fireEvent.click(send);
|
||||||
await userEvent.clear(ta);
|
await userEvent.clear(ta);
|
||||||
fireEvent.keyDown(ta, { key: "Enter" }); // empty draft: send() early-returns
|
fireEvent.keyDown(ta, { key: "Enter" }); // empty draft: send() early-returns
|
||||||
await vi.waitFor(() => {
|
await vi.waitFor(() => {
|
||||||
const post = fetchMock.mock.calls.find((c) => (c[1] as RequestInit | undefined)?.method === "POST");
|
const post = fetchMock.mock.calls.find(
|
||||||
expect(post).toBeDefined();
|
(c) => (c[1] as RequestInit | undefined)?.method === "POST",
|
||||||
expect((post?.[1] as RequestInit).body).toBe(JSON.stringify({ message: "hello" }));
|
);
|
||||||
});
|
expect(post).toBeDefined();
|
||||||
expect(ta.value).toBe("");
|
expect((post?.[1] as RequestInit).body).toBe(
|
||||||
});
|
JSON.stringify({ message: "hello" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
expect(ta.value).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
it("abort posts to the abort route", async () => {
|
it("abort posts to the abort route", async () => {
|
||||||
const fetchMock = mockFetchJson((_url, init) => (init?.method === "POST" ? { ok: true } : []));
|
const fetchMock = mockFetchJson((_url, init) =>
|
||||||
renderChat(makeStore());
|
init?.method === "POST" ? { ok: true } : [],
|
||||||
push([ev("agent_start")]);
|
);
|
||||||
await userEvent.click(screen.getByLabelText("Abort current run"));
|
renderChat(makeStore());
|
||||||
await vi.waitFor(() => {
|
push([ev("agent_start")]);
|
||||||
const abortCall = fetchMock.mock.calls.find(
|
await userEvent.click(screen.getByLabelText("Abort current run"));
|
||||||
(c) => (c[1] as RequestInit | undefined)?.method === "POST" && String(c[0]).endsWith("/abort")
|
await vi.waitFor(() => {
|
||||||
);
|
const abortCall = fetchMock.mock.calls.find(
|
||||||
expect(abortCall).toBeDefined();
|
(c) =>
|
||||||
});
|
(c[1] as RequestInit | undefined)?.method === "POST" &&
|
||||||
});
|
String(c[0]).endsWith("/abort"),
|
||||||
|
);
|
||||||
|
expect(abortCall).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("abort failure toasts", async () => {
|
it("abort failure toasts", async () => {
|
||||||
mockFetchJson((url, init) => {
|
mockFetchJson((url, init) => {
|
||||||
if (init?.method === "POST" && url.endsWith("/abort")) return jsonResponse({ error: "abort failed" }, 500);
|
if (init?.method === "POST" && url.endsWith("/abort"))
|
||||||
return [];
|
return jsonResponse({ error: "abort failed" }, 500);
|
||||||
});
|
return [];
|
||||||
renderChat(makeStore());
|
});
|
||||||
push([ev("agent_start")]);
|
renderChat(makeStore());
|
||||||
await userEvent.click(screen.getByLabelText("Abort current run"));
|
push([ev("agent_start")]);
|
||||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("abort failed"));
|
await userEvent.click(screen.getByLabelText("Abort current run"));
|
||||||
});
|
await vi.waitFor(() =>
|
||||||
|
expect(pushToast).toHaveBeenCalledWith("abort failed"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("history load failure shows the error page with a back link", async () => {
|
it("history load failure shows the error page with a back link", async () => {
|
||||||
mockFetchJson((url) => {
|
mockFetchJson((url) => {
|
||||||
if (url.includes("/events")) return jsonResponse({ error: "db gone" }, 500);
|
if (url.includes("/events"))
|
||||||
return [];
|
return jsonResponse({ error: "db gone" }, 500);
|
||||||
});
|
return [];
|
||||||
renderChat(makeStore());
|
});
|
||||||
expect(await screen.findByText(/Failed to load history: db gone/)).toBeInTheDocument();
|
renderChat(makeStore());
|
||||||
expect(screen.getByText("Back to sessions")).toBeInTheDocument();
|
expect(
|
||||||
});
|
await screen.findByText(/Failed to load history: db gone/),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Back to sessions")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("refetches missed persisted events when the ws (re)opens", async () => {
|
it("refetches missed persisted events when the ws (re)opens", async () => {
|
||||||
let after = "";
|
let after = "";
|
||||||
mockFetchJson((url) => {
|
mockFetchJson((url) => {
|
||||||
const m = /[?&]after=(\d+)/.exec(url);
|
const m = /[?&]after=(\d+)/.exec(url);
|
||||||
if (m !== null) after = m[1] ?? "";
|
if (m !== null) after = m[1] ?? "";
|
||||||
if (url.includes("/events")) return [ev("message_end", { message: { role: "user", id: "u2", text: "caught up", thinking: null, toolCalls: [], toolCallId: null } })];
|
if (url.includes("/events"))
|
||||||
return [];
|
return [
|
||||||
});
|
ev("message_end", {
|
||||||
const store = makeStore({ state: "closed" });
|
message: {
|
||||||
const { rerender } = render(
|
role: "user",
|
||||||
<MemoryRouter initialEntries={["/s/s1"]}>
|
id: "u2",
|
||||||
<Routes>
|
text: "caught up",
|
||||||
<Route path="/s/:id" element={<ChatView store={store} pushToast={pushToast} />} />
|
thinking: null,
|
||||||
</Routes>
|
toolCalls: [],
|
||||||
</MemoryRouter>
|
toolCallId: null,
|
||||||
);
|
},
|
||||||
await screen.findByText("caught up");
|
}),
|
||||||
expect(after).toBe("0");
|
];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
const store = makeStore({ state: "closed" });
|
||||||
|
const { rerender } = render(
|
||||||
|
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||||
|
<Routes>
|
||||||
|
<Route
|
||||||
|
path="/s/:id"
|
||||||
|
element={<ChatView store={store} pushToast={pushToast} />}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
await screen.findByText("caught up");
|
||||||
|
expect(after).toBe("0");
|
||||||
|
|
||||||
// reconnect: state closed -> open triggers the after=N refetch
|
// reconnect: state closed -> open triggers the after=N refetch
|
||||||
act(() => {
|
act(() => {
|
||||||
rerender(
|
rerender(
|
||||||
<MemoryRouter initialEntries={["/s/s1"]}>
|
<MemoryRouter initialEntries={["/s/s1"]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/s/:id" element={<ChatView store={makeStore({ state: "open" })} pushToast={pushToast} />} />
|
<Route
|
||||||
</Routes>
|
path="/s/:id"
|
||||||
</MemoryRouter>
|
element={
|
||||||
);
|
<ChatView
|
||||||
});
|
store={makeStore({ state: "open" })}
|
||||||
await vi.waitFor(() => expect(after).toBe("1"));
|
pushToast={pushToast}
|
||||||
});
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
await vi.waitFor(() => expect(after).toBe("1"));
|
||||||
|
});
|
||||||
|
|
||||||
it("task panel toggle and tool render in both the aside and mobile view", async () => {
|
it("task panel toggle and tool render in both the aside and mobile view", async () => {
|
||||||
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
|
mockFetchJson((url) => (url.includes("/events") ? historyEvents() : []));
|
||||||
const { container } = renderChat(makeStore());
|
const { container } = renderChat(makeStore());
|
||||||
|
|
||||||
// running tool start with no end shows in the Working section
|
// running tool start with no end shows in the Working section
|
||||||
await screen.findByText("hello there");
|
await screen.findByText("hello there");
|
||||||
const toggle = screen.getByLabelText("Toggle task panel");
|
const toggle = screen.getByLabelText("Toggle task panel");
|
||||||
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
expect(toggle).toHaveAttribute("aria-expanded", "false");
|
||||||
await userEvent.click(toggle);
|
await userEvent.click(toggle);
|
||||||
expect(screen.getByLabelText("Toggle task panel")).toHaveAttribute("aria-expanded", "true");
|
expect(screen.getByLabelText("Toggle task panel")).toHaveAttribute(
|
||||||
expect(container.querySelectorAll(".task-panel")).toHaveLength(2); // aside + mobile
|
"aria-expanded",
|
||||||
expect(screen.getAllByText("No tasks yet.")).toHaveLength(2);
|
"true",
|
||||||
});
|
);
|
||||||
|
expect(container.querySelectorAll(".task-panel")).toHaveLength(2); // aside + mobile
|
||||||
|
expect(screen.getAllByText("No tasks yet.")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("no session id param renders the empty page", () => {
|
it("no session id param renders the empty page", () => {
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/s/"]}>
|
<MemoryRouter initialEntries={["/s/"]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/s/" element={<ChatView store={makeStore()} pushToast={pushToast} />} />
|
<Route
|
||||||
<Route path="/s/:id" element={<div>CHAT</div>} />
|
path="/s/"
|
||||||
</Routes>
|
element={<ChatView store={makeStore()} pushToast={pushToast} />}
|
||||||
</MemoryRouter>
|
/>
|
||||||
);
|
<Route path="/s/:id" element={<div>CHAT</div>} />
|
||||||
expect(screen.getByText("No session selected.")).toBeInTheDocument();
|
</Routes>
|
||||||
});
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText("No session selected.")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+205
-125
@@ -6,146 +6,226 @@ import SessionsView from "./SessionsView";
|
|||||||
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
||||||
|
|
||||||
function session(p: Partial<SessionListItem>): SessionListItem {
|
function session(p: Partial<SessionListItem>): SessionListItem {
|
||||||
return {
|
return {
|
||||||
id: "s1",
|
id: "s1",
|
||||||
name: null,
|
name: null,
|
||||||
cwd: "/w",
|
cwd: "/w",
|
||||||
model: "glm-5.3",
|
model: "glm-5.3",
|
||||||
provider: "zai-renaud",
|
provider: "zai-renaud",
|
||||||
agent: false,
|
agent: false,
|
||||||
repo: null,
|
repo: null,
|
||||||
startedAt: 100,
|
startedAt: 100,
|
||||||
online: false,
|
online: false,
|
||||||
lastEventAt: null,
|
lastEventAt: null,
|
||||||
...p,
|
...p,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderView(props: Partial<Parameters<typeof SessionsView>[0]> = {}): ReturnType<typeof render> {
|
function renderView(
|
||||||
return render(
|
props: Partial<Parameters<typeof SessionsView>[0]> = {},
|
||||||
<MemoryRouter>
|
): ReturnType<typeof render> {
|
||||||
<SessionsView sessions={[]} onChanged={() => undefined} pushToast={() => undefined} {...props} />
|
return render(
|
||||||
</MemoryRouter>
|
<MemoryRouter>
|
||||||
);
|
<SessionsView
|
||||||
|
sessions={[]}
|
||||||
|
onChanged={() => undefined}
|
||||||
|
pushToast={() => undefined}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Probe({ onVisit }: { onVisit: (path: string) => void }): null {
|
function Probe({ onVisit }: { onVisit: (path: string) => void }): null {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
onVisit(`/s/${id ?? ""}`);
|
onVisit(`/s/${id ?? ""}`);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("SessionsView", () => {
|
describe("SessionsView", () => {
|
||||||
it("empty state message", () => {
|
it("empty state message", () => {
|
||||||
renderView();
|
renderView();
|
||||||
expect(screen.getByText(/No sessions yet/i)).toBeInTheDocument();
|
expect(screen.getByText(/No sessions yet/i)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders cards sorted by last activity with fallbacks", () => {
|
it("renders cards sorted by last activity with fallbacks", () => {
|
||||||
renderView({
|
renderView({
|
||||||
sessions: [
|
sessions: [
|
||||||
session({ id: "a", name: null, repo: "g/p", lastEventAt: 5, startedAt: 1 }),
|
session({
|
||||||
session({ id: "b", name: "named", lastEventAt: null, startedAt: 10 }),
|
id: "a",
|
||||||
session({ id: "c", name: null, repo: null, cwd: "/fallback", startedAt: 100, online: true }),
|
name: null,
|
||||||
],
|
repo: "g/p",
|
||||||
});
|
lastEventAt: 5,
|
||||||
const cards = screen.getAllByRole("button", { name: /^Open session/ });
|
startedAt: 1,
|
||||||
expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([
|
}),
|
||||||
"Open session c",
|
session({ id: "b", name: "named", lastEventAt: null, startedAt: 10 }),
|
||||||
"Open session named",
|
session({
|
||||||
"Open session a",
|
id: "c",
|
||||||
]);
|
name: null,
|
||||||
expect(cards[0]?.textContent).toContain("/fallback");
|
repo: null,
|
||||||
expect(screen.getAllByTitle("online")).toHaveLength(1);
|
cwd: "/fallback",
|
||||||
expect(screen.getAllByTitle("offline")).toHaveLength(2);
|
startedAt: 100,
|
||||||
});
|
online: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const cards = screen.getAllByRole("button", { name: /^Open session/ });
|
||||||
|
expect(cards.map((c) => c.getAttribute("aria-label"))).toEqual([
|
||||||
|
"Open session c",
|
||||||
|
"Open session named",
|
||||||
|
"Open session a",
|
||||||
|
]);
|
||||||
|
expect(cards[0]?.textContent).toContain("/fallback");
|
||||||
|
expect(screen.getAllByTitle("online")).toHaveLength(1);
|
||||||
|
expect(screen.getAllByTitle("offline")).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("shows repo, model, relative time and agent badge", () => {
|
it("shows repo, model, relative time and agent badge", () => {
|
||||||
renderView({ sessions: [session({ id: "s1", name: "named", repo: "g/p", lastEventAt: Date.now() - 5000, agent: true })] });
|
renderView({
|
||||||
expect(screen.getAllByText("g/p")).toHaveLength(1);
|
sessions: [
|
||||||
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
|
session({
|
||||||
expect(screen.getByText("just now")).toBeInTheDocument();
|
id: "s1",
|
||||||
expect(screen.getByText("agent")).toBeInTheDocument();
|
name: "named",
|
||||||
expect(screen.getByLabelText("Stop container for named")).toBeInTheDocument();
|
repo: "g/p",
|
||||||
});
|
lastEventAt: Date.now() - 5000,
|
||||||
|
agent: true,
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(screen.getAllByText("g/p")).toHaveLength(1);
|
||||||
|
expect(screen.getByText("glm-5.3")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("just now")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("agent")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByLabelText("Stop container for named"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("no badge/stop for non-agent sessions", () => {
|
it("no badge/stop for non-agent sessions", () => {
|
||||||
renderView({ sessions: [session({ id: "s1", name: "local", repo: "g/p" })] });
|
renderView({
|
||||||
expect(screen.queryByText("agent")).toBeNull();
|
sessions: [session({ id: "s1", name: "local", repo: "g/p" })],
|
||||||
expect(screen.queryByLabelText(/Stop container/)).toBeNull();
|
});
|
||||||
});
|
expect(screen.queryByText("agent")).toBeNull();
|
||||||
|
expect(screen.queryByLabelText(/Stop container/)).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("keyboard Enter and Space open the session; other keys ignored", () => {
|
it("keyboard Enter and Space open the session; other keys ignored", () => {
|
||||||
const probe = vi.fn();
|
const probe = vi.fn();
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/"]}>
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<SessionsView sessions={[session({ id: "s9", name: "kb" })]} onChanged={() => undefined} pushToast={() => undefined} />} />
|
<Route
|
||||||
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
|
path="/"
|
||||||
</Routes>
|
element={
|
||||||
</MemoryRouter>
|
<SessionsView
|
||||||
);
|
sessions={[session({ id: "s9", name: "kb" })]}
|
||||||
const card = screen.getByRole("button", { name: "Open session kb" });
|
onChanged={() => undefined}
|
||||||
fireEvent.keyDown(card, { key: "Tab" });
|
pushToast={() => undefined}
|
||||||
expect(probe).not.toHaveBeenCalled();
|
/>
|
||||||
fireEvent.keyDown(card, { key: "Enter" });
|
}
|
||||||
expect(probe).toHaveBeenCalledWith("/s/s9");
|
/>
|
||||||
});
|
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
const card = screen.getByRole("button", { name: "Open session kb" });
|
||||||
|
fireEvent.keyDown(card, { key: "Tab" });
|
||||||
|
expect(probe).not.toHaveBeenCalled();
|
||||||
|
fireEvent.keyDown(card, { key: "Enter" });
|
||||||
|
expect(probe).toHaveBeenCalledWith("/s/s9");
|
||||||
|
});
|
||||||
|
|
||||||
it("Space key opens the session", () => {
|
it("Space key opens the session", () => {
|
||||||
const probe = vi.fn();
|
const probe = vi.fn();
|
||||||
render(
|
render(
|
||||||
<MemoryRouter initialEntries={["/"]}>
|
<MemoryRouter initialEntries={["/"]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/" element={<SessionsView sessions={[session({ id: "s8" })]} onChanged={() => undefined} pushToast={() => undefined} />} />
|
<Route
|
||||||
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
|
path="/"
|
||||||
</Routes>
|
element={
|
||||||
</MemoryRouter>
|
<SessionsView
|
||||||
);
|
sessions={[session({ id: "s8" })]}
|
||||||
fireEvent.keyDown(screen.getByRole("button", { name: "Open session s8" }), { key: " " });
|
onChanged={() => undefined}
|
||||||
expect(probe).toHaveBeenCalledWith("/s/s8");
|
pushToast={() => undefined}
|
||||||
});
|
/>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Route path="/s/:id" element={<Probe onVisit={probe} />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(screen.getByRole("button", { name: "Open session s8" }), {
|
||||||
|
key: " ",
|
||||||
|
});
|
||||||
|
expect(probe).toHaveBeenCalledWith("/s/s8");
|
||||||
|
});
|
||||||
|
|
||||||
it("stop with unnamed session toasts the id", async () => {
|
it("stop with unnamed session toasts the id", async () => {
|
||||||
seedSettings();
|
seedSettings();
|
||||||
mockFetchJson(() => ({ ok: true }));
|
mockFetchJson(() => ({ ok: true }));
|
||||||
const pushToast = vi.fn();
|
const pushToast = vi.fn();
|
||||||
renderView({ sessions: [session({ id: "s1", name: null, agent: true })], pushToast, onChanged: () => undefined });
|
renderView({
|
||||||
fireEvent.click(screen.getByLabelText("Stop container for s1"));
|
sessions: [session({ id: "s1", name: null, agent: true })],
|
||||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stopped s1"));
|
pushToast,
|
||||||
});
|
onChanged: () => undefined,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByLabelText("Stop container for s1"));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(pushToast).toHaveBeenCalledWith("stopped s1"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("stop button deletes container, toasts and refreshes", async () => {
|
it("stop button deletes container, toasts and refreshes", async () => {
|
||||||
seedSettings();
|
seedSettings();
|
||||||
const fetchMock = mockFetchJson(() => ({ ok: true }));
|
const fetchMock = mockFetchJson(() => ({ ok: true }));
|
||||||
const onChanged = vi.fn();
|
const onChanged = vi.fn();
|
||||||
const pushToast = vi.fn();
|
const pushToast = vi.fn();
|
||||||
renderView({ sessions: [session({ id: "s1", name: "worker", agent: true })], onChanged, pushToast });
|
renderView({
|
||||||
fireEvent.click(screen.getByLabelText("Stop container for worker"));
|
sessions: [session({ id: "s1", name: "worker", agent: true })],
|
||||||
await vi.waitFor(() => {
|
onChanged,
|
||||||
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
pushToast,
|
||||||
expect(call[0]).toBe("http://srv/api/sessions/s1/container");
|
});
|
||||||
expect(call[1].method).toBe("DELETE");
|
fireEvent.click(screen.getByLabelText("Stop container for worker"));
|
||||||
});
|
await vi.waitFor(() => {
|
||||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stopped worker"));
|
const call = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
||||||
expect(onChanged).toHaveBeenCalled();
|
expect(call[0]).toBe("http://srv/api/sessions/s1/container");
|
||||||
});
|
expect(call[1].method).toBe("DELETE");
|
||||||
|
});
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(pushToast).toHaveBeenCalledWith("stopped worker"),
|
||||||
|
);
|
||||||
|
expect(onChanged).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
it("stop failure toasts the error", async () => {
|
it("stop failure toasts the error", async () => {
|
||||||
seedSettings();
|
seedSettings();
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "nope" }, 500));
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
const pushToast = vi.fn();
|
jsonResponse({ error: "nope" }, 500),
|
||||||
renderView({ sessions: [session({ id: "s1", name: "w", agent: true })], pushToast, onChanged: () => undefined });
|
);
|
||||||
fireEvent.click(screen.getByLabelText("Stop container for w"));
|
const pushToast = vi.fn();
|
||||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stop failed: nope"));
|
renderView({
|
||||||
});
|
sessions: [session({ id: "s1", name: "w", agent: true })],
|
||||||
|
pushToast,
|
||||||
|
onChanged: () => undefined,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByLabelText("Stop container for w"));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(pushToast).toHaveBeenCalledWith("stop failed: nope"),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("non-error stop failure path stringifies non-Error throws", async () => {
|
it("non-error stop failure path stringifies non-Error throws", async () => {
|
||||||
seedSettings();
|
seedSettings();
|
||||||
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string");
|
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string");
|
||||||
const pushToast = vi.fn();
|
const pushToast = vi.fn();
|
||||||
renderView({ sessions: [session({ id: "s1", name: "w", agent: true })], pushToast, onChanged: () => undefined });
|
renderView({
|
||||||
fireEvent.click(screen.getByLabelText("Stop container for w"));
|
sessions: [session({ id: "s1", name: "w", agent: true })],
|
||||||
await vi.waitFor(() => expect(pushToast).toHaveBeenCalledWith("stop failed: plain-string"));
|
pushToast,
|
||||||
});
|
onChanged: () => undefined,
|
||||||
|
});
|
||||||
|
fireEvent.click(screen.getByLabelText("Stop container for w"));
|
||||||
|
await vi.waitFor(() =>
|
||||||
|
expect(pushToast).toHaveBeenCalledWith("stop failed: plain-string"),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -6,82 +6,104 @@ import SettingsGate from "./SettingsGate";
|
|||||||
import { jsonResponse } from "./test/setup";
|
import { jsonResponse } from "./test/setup";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
function setup(): { connect: () => Promise<void> } {
|
function setup(): { connect: () => Promise<void> } {
|
||||||
render(<SettingsGate onSaved={() => undefined} />);
|
render(<SettingsGate onSaved={() => undefined} />);
|
||||||
return {
|
return {
|
||||||
connect: async (): Promise<void> => {
|
connect: async (): Promise<void> => {
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("SettingsGate", () => {
|
describe("SettingsGate", () => {
|
||||||
it("defaults the server url to the current origin", () => {
|
it("defaults the server url to the current origin", () => {
|
||||||
setup();
|
setup();
|
||||||
const input = screen.getByLabelText("Server URL") as HTMLInputElement;
|
const input = screen.getByLabelText("Server URL") as HTMLInputElement;
|
||||||
expect(input.value).toBe("http://localhost:3000");
|
expect(input.value).toBe("http://localhost:3000");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("requires a token", async () => {
|
it("requires a token", async () => {
|
||||||
setup();
|
setup();
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
expect(screen.getByRole("alert")).toHaveTextContent("token required");
|
expect(screen.getByRole("alert")).toHaveTextContent("token required");
|
||||||
expect(getSettings()).toBeNull();
|
expect(getSettings()).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("validation failure shows the server error and does not save", async () => {
|
it("validation failure shows the server error and does not save", async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "unauthorized" }, 401));
|
const fetchMock = vi
|
||||||
setup();
|
.spyOn(globalThis, "fetch")
|
||||||
await userEvent.type(screen.getByLabelText("Bearer token"), "bad");
|
.mockResolvedValue(jsonResponse({ error: "unauthorized" }, 401));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
setup();
|
||||||
expect(screen.getByRole("alert")).toHaveTextContent("connection failed (401)");
|
await userEvent.type(screen.getByLabelText("Bearer token"), "bad");
|
||||||
expect(getSettings()).toBeNull();
|
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
expect(fetchMock).toHaveBeenCalledWith(
|
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||||
"http://localhost:3000/api/sessions",
|
"connection failed (401)",
|
||||||
expect.objectContaining({ headers: { Authorization: "Bearer bad" } })
|
);
|
||||||
);
|
expect(getSettings()).toBeNull();
|
||||||
});
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://localhost:3000/api/sessions",
|
||||||
|
expect.objectContaining({ headers: { Authorization: "Bearer bad" } }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("network rejection surfaces the thrown message", async () => {
|
it("network rejection surfaces the thrown message", async () => {
|
||||||
vi.spyOn(globalThis, "fetch").mockRejectedValue(new TypeError("fetch failed"));
|
vi.spyOn(globalThis, "fetch").mockRejectedValue(
|
||||||
setup();
|
new TypeError("fetch failed"),
|
||||||
await userEvent.type(screen.getByLabelText("Bearer token"), "tok");
|
);
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
setup();
|
||||||
expect(screen.getByRole("alert")).toHaveTextContent("fetch failed");
|
await userEvent.type(screen.getByLabelText("Bearer token"), "tok");
|
||||||
});
|
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
|
expect(screen.getByRole("alert")).toHaveTextContent("fetch failed");
|
||||||
|
});
|
||||||
|
|
||||||
it("saves normalized url + trimmed token and calls onSaved", async () => {
|
it("saves normalized url + trimmed token and calls onSaved", async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
|
const fetchMock = vi
|
||||||
const onSaved = vi.fn();
|
.spyOn(globalThis, "fetch")
|
||||||
render(<SettingsGate onSaved={onSaved} />);
|
.mockResolvedValue(jsonResponse([]));
|
||||||
await userEvent.clear(screen.getByLabelText("Server URL"));
|
const onSaved = vi.fn();
|
||||||
await userEvent.type(screen.getByLabelText("Server URL"), "http://daemon:8686///");
|
render(<SettingsGate onSaved={onSaved} />);
|
||||||
await userEvent.type(screen.getByLabelText("Bearer token"), " tok ");
|
await userEvent.clear(screen.getByLabelText("Server URL"));
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
await userEvent.type(
|
||||||
expect(fetchMock).toHaveBeenCalledWith("http://daemon:8686/api/sessions", expect.anything());
|
screen.getByLabelText("Server URL"),
|
||||||
expect(getSettings()).toEqual({ serverUrl: "http://daemon:8686", token: "tok" });
|
"http://daemon:8686///",
|
||||||
expect(onSaved).toHaveBeenCalledTimes(1);
|
);
|
||||||
});
|
await userEvent.type(screen.getByLabelText("Bearer token"), " tok ");
|
||||||
|
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://daemon:8686/api/sessions",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(getSettings()).toEqual({
|
||||||
|
serverUrl: "http://daemon:8686",
|
||||||
|
token: "tok",
|
||||||
|
});
|
||||||
|
expect(onSaved).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
it("non-Error rejections stringify via String(err)", async () => {
|
it("non-Error rejections stringify via String(err)", async () => {
|
||||||
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string" as never);
|
vi.spyOn(globalThis, "fetch").mockRejectedValue("plain-string" as never);
|
||||||
setup();
|
setup();
|
||||||
await userEvent.type(screen.getByLabelText("Bearer token"), "tok");
|
await userEvent.type(screen.getByLabelText("Bearer token"), "tok");
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
expect(screen.getByRole("alert")).toHaveTextContent("plain-string");
|
expect(screen.getByRole("alert")).toHaveTextContent("plain-string");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("empty server url falls back to the current origin", async () => {
|
it("empty server url falls back to the current origin", async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([]));
|
const fetchMock = vi
|
||||||
setup();
|
.spyOn(globalThis, "fetch")
|
||||||
await userEvent.clear(screen.getByLabelText("Server URL"));
|
.mockResolvedValue(jsonResponse([]));
|
||||||
await userEvent.type(screen.getByLabelText("Bearer token"), "tok");
|
setup();
|
||||||
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
await userEvent.clear(screen.getByLabelText("Server URL"));
|
||||||
expect(fetchMock).toHaveBeenCalledWith("http://localhost:3000/api/sessions", expect.anything());
|
await userEvent.type(screen.getByLabelText("Bearer token"), "tok");
|
||||||
expect(getSettings()?.serverUrl).toBe("http://localhost:3000");
|
await userEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
});
|
expect(fetchMock).toHaveBeenCalledWith(
|
||||||
|
"http://localhost:3000/api/sessions",
|
||||||
|
expect.anything(),
|
||||||
|
);
|
||||||
|
expect(getSettings()?.serverUrl).toBe("http://localhost:3000");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+314
-278
@@ -7,333 +7,369 @@ import type { SessionsStore } from "./store";
|
|||||||
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
import { jsonResponse, mockFetchJson, seedSettings } from "./test/setup";
|
||||||
|
|
||||||
const repo = (path: string, branch = "main"): Repo => ({
|
const repo = (path: string, branch = "main"): Repo => ({
|
||||||
path,
|
path,
|
||||||
name: path.split("/")[1] ?? path,
|
name: path.split("/")[1] ?? path,
|
||||||
namespace: path.split("/")[0] ?? "g",
|
namespace: path.split("/")[0] ?? "g",
|
||||||
lastActivityAt: "2024-05-01T00:00:00Z",
|
lastActivityAt: "2024-05-01T00:00:00Z",
|
||||||
webUrl: `https://gl/${path}`,
|
webUrl: `https://gl/${path}`,
|
||||||
defaultBranch: branch,
|
defaultBranch: branch,
|
||||||
});
|
});
|
||||||
|
|
||||||
const PUSH_TOAST = vi.fn();
|
const PUSH_TOAST = vi.fn();
|
||||||
|
|
||||||
function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
|
function makeStore(over: Partial<SessionsStore> = {}): SessionsStore {
|
||||||
return {
|
return {
|
||||||
sessions: [],
|
sessions: [],
|
||||||
state: "open",
|
state: "open",
|
||||||
spawnJobs: [],
|
spawnJobs: [],
|
||||||
refresh: async (): Promise<void> => undefined,
|
refresh: async (): Promise<void> => undefined,
|
||||||
subscribe: (): (() => void) => () => undefined,
|
subscribe: (): (() => void) => () => undefined,
|
||||||
...over,
|
...over,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function tree(store: SessionsStore): React.ReactElement {
|
function tree(store: SessionsStore): React.ReactElement {
|
||||||
return (
|
return (
|
||||||
<MemoryRouter initialEntries={["/new"]}>
|
<MemoryRouter initialEntries={["/new"]}>
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/new" element={<SpawnView store={store} pushToast={PUSH_TOAST} />} />
|
<Route
|
||||||
<Route path="/s/:id" element={<div data-testid="chat-route" />} />
|
path="/new"
|
||||||
</Routes>
|
element={<SpawnView store={store} pushToast={PUSH_TOAST} />}
|
||||||
</MemoryRouter>
|
/>
|
||||||
);
|
<Route path="/s/:id" element={<div data-testid="chat-route" />} />
|
||||||
|
</Routes>
|
||||||
|
</MemoryRouter>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Flush pending microtasks + React effects (works under fake timers). */
|
/** Flush pending microtasks + React effects (works under fake timers). */
|
||||||
async function flush(ticks = 4): Promise<void> {
|
async function flush(ticks = 4): Promise<void> {
|
||||||
for (let i = 0; i < ticks; i += 1) {
|
for (let i = 0; i < ticks; i += 1) {
|
||||||
// eslint-disable-next-line no-await-in-loop
|
// eslint-disable-next-line no-await-in-loop
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
PUSH_TOAST.mockClear();
|
PUSH_TOAST.mockClear();
|
||||||
seedSettings();
|
seedSettings();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SpawnView status", () => {
|
describe("SpawnView status", () => {
|
||||||
it("shows checking state, then error when gitlab status fails", async () => {
|
it("shows checking state, then error when gitlab status fails", async () => {
|
||||||
mockFetchJson((url) => {
|
mockFetchJson((url) => {
|
||||||
if (url.includes("/api/gitlab/status")) return jsonResponse({ error: "down" }, 500);
|
if (url.includes("/api/gitlab/status"))
|
||||||
return [];
|
return jsonResponse({ error: "down" }, 500);
|
||||||
});
|
return [];
|
||||||
render(tree(makeStore()));
|
});
|
||||||
expect(screen.getByText("checking gitlab…")).toBeInTheDocument();
|
render(tree(makeStore()));
|
||||||
await flush();
|
expect(screen.getByText("checking gitlab…")).toBeInTheDocument();
|
||||||
expect(screen.getByText(/gitlab status failed: down/)).toBeInTheDocument();
|
await flush();
|
||||||
});
|
expect(screen.getByText(/gitlab status failed: down/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SpawnView connect flow", () => {
|
describe("SpawnView connect flow", () => {
|
||||||
it("requires a token", async () => {
|
it("requires a token", async () => {
|
||||||
mockFetchJson((url) => {
|
mockFetchJson((url) => {
|
||||||
if (url.endsWith("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" };
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
return [];
|
return { connected: false, baseUrl: "https://gl" };
|
||||||
});
|
return [];
|
||||||
render(tree(makeStore()));
|
});
|
||||||
await flush();
|
render(tree(makeStore()));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
await flush();
|
||||||
await flush();
|
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
expect(screen.getByText("token required")).toBeInTheDocument();
|
await flush();
|
||||||
});
|
expect(screen.getByText("token required")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("connects, clears the PAT, loads repos and shows the picker", async () => {
|
it("connects, clears the PAT, loads repos and shows the picker", async () => {
|
||||||
let connected = false;
|
let connected = false;
|
||||||
mockFetchJson((url, init) => {
|
mockFetchJson((url, init) => {
|
||||||
if (url.endsWith("/api/gitlab/status")) return { connected, baseUrl: "https://gl", username: connected ? "alice" : undefined };
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") {
|
return {
|
||||||
connected = true;
|
connected,
|
||||||
return { username: "alice" };
|
baseUrl: "https://gl",
|
||||||
}
|
username: connected ? "alice" : undefined,
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/one"), repo("g/two")];
|
};
|
||||||
return [];
|
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") {
|
||||||
});
|
connected = true;
|
||||||
render(tree(makeStore()));
|
return { username: "alice" };
|
||||||
await flush();
|
}
|
||||||
expect(screen.getByText("Connect GitLab")).toBeInTheDocument();
|
if (url.endsWith("/api/gitlab/repos"))
|
||||||
|
return [repo("g/one"), repo("g/two")];
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
render(tree(makeStore()));
|
||||||
|
await flush();
|
||||||
|
expect(screen.getByText("Connect GitLab")).toBeInTheDocument();
|
||||||
|
|
||||||
const pat = screen.getByLabelText("GitLab personal access token") as HTMLInputElement;
|
const pat = screen.getByLabelText(
|
||||||
fireEvent.input(pat, { target: { value: "glpat-x" } });
|
"GitLab personal access token",
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
) as HTMLInputElement;
|
||||||
await flush();
|
fireEvent.input(pat, { target: { value: "glpat-x" } });
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
|
await flush();
|
||||||
|
|
||||||
expect(screen.getByText("g/one")).toBeInTheDocument();
|
expect(screen.getByText("g/one")).toBeInTheDocument();
|
||||||
expect(screen.getByText("g/two")).toBeInTheDocument();
|
expect(screen.getByText("g/two")).toBeInTheDocument();
|
||||||
expect(screen.queryByLabelText("GitLab personal access token")).toBeNull();
|
expect(screen.queryByLabelText("GitLab personal access token")).toBeNull();
|
||||||
expect(screen.getByText("Repository")).toBeInTheDocument();
|
expect(screen.getByText("Repository")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("connect failure shows the error and keeps the gate", async () => {
|
it("connect failure shows the error and keeps the gate", async () => {
|
||||||
mockFetchJson((url, init) => {
|
mockFetchJson((url, init) => {
|
||||||
if (url.endsWith("/api/gitlab/status")) return { connected: false, baseUrl: "https://gl" };
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST") return jsonResponse({ error: "bad pat" }, 401);
|
return { connected: false, baseUrl: "https://gl" };
|
||||||
return [];
|
if (url.endsWith("/api/gitlab/connect") && init?.method === "POST")
|
||||||
});
|
return jsonResponse({ error: "bad pat" }, 401);
|
||||||
render(tree(makeStore()));
|
return [];
|
||||||
await flush();
|
});
|
||||||
fireEvent.input(screen.getByLabelText("GitLab personal access token"), { target: { value: "glpat-bad" } });
|
render(tree(makeStore()));
|
||||||
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
await flush();
|
||||||
await flush();
|
fireEvent.input(screen.getByLabelText("GitLab personal access token"), {
|
||||||
expect(screen.getByText("bad pat")).toBeInTheDocument();
|
target: { value: "glpat-bad" },
|
||||||
expect(screen.getByText("Connect GitLab")).toBeInTheDocument();
|
});
|
||||||
});
|
fireEvent.click(screen.getByRole("button", { name: "Connect" }));
|
||||||
|
await flush();
|
||||||
|
expect(screen.getByText("bad pat")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Connect GitLab")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("connected on load fetches repos immediately", async () => {
|
it("connected on load fetches repos immediately", async () => {
|
||||||
mockFetchJson((url) => {
|
mockFetchJson((url) => {
|
||||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/quick")];
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
return [];
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/quick")];
|
||||||
});
|
return [];
|
||||||
render(tree(makeStore()));
|
});
|
||||||
await flush();
|
render(tree(makeStore()));
|
||||||
expect(screen.getByText("g/quick")).toBeInTheDocument();
|
await flush();
|
||||||
});
|
expect(screen.getByText("g/quick")).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SpawnView repo picker", () => {
|
describe("SpawnView repo picker", () => {
|
||||||
function connectedMock(): void {
|
function connectedMock(): void {
|
||||||
mockFetchJson((url) => {
|
mockFetchJson((url) => {
|
||||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/alpha"), repo("g/beta", "dev")];
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
return [];
|
if (url.endsWith("/api/gitlab/repos"))
|
||||||
});
|
return [repo("g/alpha"), repo("g/beta", "dev")];
|
||||||
}
|
return [];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
it("filters repos by query, keyboard selects, empty filter message", async () => {
|
it("filters repos by query, keyboard selects, empty filter message", async () => {
|
||||||
connectedMock();
|
connectedMock();
|
||||||
render(tree(makeStore()));
|
render(tree(makeStore()));
|
||||||
await flush();
|
await flush();
|
||||||
expect(screen.getByText("g/alpha")).toBeInTheDocument();
|
expect(screen.getByText("g/alpha")).toBeInTheDocument();
|
||||||
|
|
||||||
const filter = screen.getByLabelText("Filter repositories");
|
const filter = screen.getByLabelText("Filter repositories");
|
||||||
fireEvent.input(filter, { target: { value: "beta" } });
|
fireEvent.input(filter, { target: { value: "beta" } });
|
||||||
expect(screen.queryByText("g/alpha")).toBeNull();
|
expect(screen.queryByText("g/alpha")).toBeNull();
|
||||||
expect(screen.getByText("g/beta")).toBeInTheDocument();
|
expect(screen.getByText("g/beta")).toBeInTheDocument();
|
||||||
|
|
||||||
const item = screen.getByText("g/beta").closest(".repo-item") as HTMLElement;
|
const item = screen
|
||||||
fireEvent.keyDown(item, { key: "Enter" });
|
.getByText("g/beta")
|
||||||
expect(screen.getByLabelText("Branch")).toHaveValue("dev");
|
.closest(".repo-item") as HTMLElement;
|
||||||
|
fireEvent.keyDown(item, { key: "Enter" });
|
||||||
|
expect(screen.getByLabelText("Branch")).toHaveValue("dev");
|
||||||
|
|
||||||
fireEvent.input(filter, { target: { value: "zzz" } });
|
fireEvent.input(filter, { target: { value: "zzz" } });
|
||||||
expect(screen.getByText("no matching repos")).toBeInTheDocument();
|
expect(screen.getByText("no matching repos")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spawn without selection shows pick-a-repo error", async () => {
|
it("spawn without selection shows pick-a-repo error", async () => {
|
||||||
connectedMock();
|
connectedMock();
|
||||||
render(tree(makeStore()));
|
render(tree(makeStore()));
|
||||||
await flush();
|
await flush();
|
||||||
expect(screen.getByLabelText("Spawn container")).toBeDisabled();
|
expect(screen.getByLabelText("Spawn container")).toBeDisabled();
|
||||||
expect(screen.getByText("select a repo above")).toBeInTheDocument();
|
expect(screen.getByText("select a repo above")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SpawnView spawn+poll", () => {
|
describe("SpawnView spawn+poll", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spawns, polls until online, then navigates to the chat", async () => {
|
it("spawns, polls until online, then navigates to the chat", async () => {
|
||||||
const posts: Array<[string, RequestInit | undefined]> = [];
|
const posts: Array<[string, RequestInit | undefined]> = [];
|
||||||
mockFetchJson((url, init) => {
|
mockFetchJson((url, init) => {
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||||
posts.push([url, init]);
|
posts.push([url, init]);
|
||||||
return { sessionId: "new-1", containerId: "abc123def456" };
|
return { sessionId: "new-1", containerId: "abc123def456" };
|
||||||
}
|
}
|
||||||
if (url.endsWith("/api/spawn/status")) return [];
|
if (url.endsWith("/api/spawn/status")) return [];
|
||||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/proj")];
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
return [];
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/proj")];
|
||||||
});
|
return [];
|
||||||
const store = makeStore();
|
});
|
||||||
const { unmount } = render(tree(store));
|
const store = makeStore();
|
||||||
await flush();
|
const { unmount } = render(tree(store));
|
||||||
fireEvent.click(screen.getByText("g/proj"));
|
await flush();
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
fireEvent.click(screen.getByText("g/proj"));
|
||||||
await flush();
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
|
await flush();
|
||||||
|
|
||||||
expect(posts).toHaveLength(1);
|
expect(posts).toHaveLength(1);
|
||||||
expect((posts[0]?.[1] as RequestInit).body).toBe(JSON.stringify({ repo: "g/proj", branch: "main" }));
|
expect((posts[0]?.[1] as RequestInit).body).toBe(
|
||||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
JSON.stringify({ repo: "g/proj", branch: "main" }),
|
||||||
expect(screen.getByText(/container abc123def456/)).toBeInTheDocument();
|
);
|
||||||
expect(screen.getByText("waiting for session to come online…")).toBeInTheDocument();
|
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(/container abc123def456/)).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByText("waiting for session to come online…"),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
|
||||||
// first tick: session not online yet
|
// first tick: session not online yet
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(1500);
|
vi.advanceTimersByTime(1500);
|
||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
expect(screen.queryByTestId("chat-route")).toBeNull();
|
expect(screen.queryByTestId("chat-route")).toBeNull();
|
||||||
|
|
||||||
// session comes online -> next tick navigates
|
// session comes online -> next tick navigates
|
||||||
store.sessions = [
|
store.sessions = [
|
||||||
{
|
{
|
||||||
id: "new-1",
|
id: "new-1",
|
||||||
name: "spawned",
|
name: "spawned",
|
||||||
cwd: "/w",
|
cwd: "/w",
|
||||||
model: "m",
|
model: "m",
|
||||||
provider: "p",
|
provider: "p",
|
||||||
agent: true,
|
agent: true,
|
||||||
repo: "g/proj",
|
repo: "g/proj",
|
||||||
startedAt: 1,
|
startedAt: 1,
|
||||||
online: true,
|
online: true,
|
||||||
lastEventAt: 1,
|
lastEventAt: 1,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(1500);
|
vi.advanceTimersByTime(1500);
|
||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
expect(screen.getByTestId("chat-route")).toBeInTheDocument();
|
expect(screen.getByTestId("chat-route")).toBeInTheDocument();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("branch left blank sends repo only; poll refresh failure toasts", async () => {
|
it("branch left blank sends repo only; poll refresh failure toasts", async () => {
|
||||||
const bodies: string[] = [];
|
const bodies: string[] = [];
|
||||||
mockFetchJson((url, init) => {
|
mockFetchJson((url, init) => {
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
if (init?.method === "POST" && url.endsWith("/api/spawn")) {
|
||||||
bodies.push(String(init.body));
|
bodies.push(String(init.body));
|
||||||
return { sessionId: "new-2", containerId: "cccccccccccc" };
|
return { sessionId: "new-2", containerId: "cccccccccccc" };
|
||||||
}
|
}
|
||||||
if (url.endsWith("/api/spawn/status")) return [];
|
if (url.endsWith("/api/spawn/status")) return [];
|
||||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/blank")];
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
return [];
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/blank")];
|
||||||
});
|
return [];
|
||||||
const failingRefresh = makeStore({
|
});
|
||||||
refresh: async (): Promise<void> => {
|
const failingRefresh = makeStore({
|
||||||
throw new Error("boom");
|
refresh: async (): Promise<void> => {
|
||||||
},
|
throw new Error("boom");
|
||||||
});
|
},
|
||||||
render(tree(failingRefresh));
|
});
|
||||||
await flush();
|
render(tree(failingRefresh));
|
||||||
fireEvent.click(screen.getByText("g/blank"));
|
await flush();
|
||||||
fireEvent.change(screen.getByLabelText("Branch"), { target: { value: "" } });
|
fireEvent.click(screen.getByText("g/blank"));
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
fireEvent.change(screen.getByLabelText("Branch"), {
|
||||||
await flush();
|
target: { value: "" },
|
||||||
expect(bodies).toEqual([JSON.stringify({ repo: "g/blank" })]);
|
});
|
||||||
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
|
await flush();
|
||||||
|
expect(bodies).toEqual([JSON.stringify({ repo: "g/blank" })]);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(1500);
|
vi.advanceTimersByTime(1500);
|
||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
expect(PUSH_TOAST).toHaveBeenCalledWith("boom");
|
expect(PUSH_TOAST).toHaveBeenCalledWith("boom");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spawn POST failure shows the error", async () => {
|
it("spawn POST failure shows the error", async () => {
|
||||||
mockFetchJson((url, init) => {
|
mockFetchJson((url, init) => {
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) return jsonResponse({ error: "no docker" }, 500);
|
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
return jsonResponse({ error: "no docker" }, 500);
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")];
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
return [];
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
});
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/x")];
|
||||||
render(tree(makeStore()));
|
return [];
|
||||||
await flush();
|
});
|
||||||
fireEvent.click(screen.getByText("g/x"));
|
render(tree(makeStore()));
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
await flush();
|
||||||
await flush();
|
fireEvent.click(screen.getByText("g/x"));
|
||||||
expect(screen.getByText("no docker")).toBeInTheDocument();
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
});
|
await flush();
|
||||||
|
expect(screen.getByText("no docker")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("polling gives up after the tick cap and stays on the page", async () => {
|
it("polling gives up after the tick cap and stays on the page", async () => {
|
||||||
let statusCalls = 0;
|
let statusCalls = 0;
|
||||||
mockFetchJson((url) => {
|
mockFetchJson((url) => {
|
||||||
if (url.endsWith("/api/spawn/status")) {
|
if (url.endsWith("/api/spawn/status")) {
|
||||||
statusCalls += 1;
|
statusCalls += 1;
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")];
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
if (url.endsWith("/api/spawn")) return { sessionId: "slow-1", containerId: "d" };
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/slow")];
|
||||||
return [];
|
if (url.endsWith("/api/spawn"))
|
||||||
});
|
return { sessionId: "slow-1", containerId: "d" };
|
||||||
const { unmount } = render(tree(makeStore()));
|
return [];
|
||||||
await flush();
|
});
|
||||||
fireEvent.click(screen.getByText("g/slow"));
|
const { unmount } = render(tree(makeStore()));
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
await flush();
|
||||||
await flush();
|
fireEvent.click(screen.getByText("g/slow"));
|
||||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
|
await flush();
|
||||||
|
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(1500 * 402);
|
vi.advanceTimersByTime(1500 * 402);
|
||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
const afterCap = statusCalls;
|
const afterCap = statusCalls;
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
vi.advanceTimersByTime(1500 * 10);
|
vi.advanceTimersByTime(1500 * 10);
|
||||||
});
|
});
|
||||||
await flush();
|
await flush();
|
||||||
expect(statusCalls).toBe(afterCap);
|
expect(statusCalls).toBe(afterCap);
|
||||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||||
unmount();
|
unmount();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("spawn job line renders from store.spawnJobs", async () => {
|
it("spawn job line renders from store.spawnJobs", async () => {
|
||||||
mockFetchJson((url, init) => {
|
mockFetchJson((url, init) => {
|
||||||
if (init?.method === "POST" && url.endsWith("/api/spawn")) return { sessionId: "sj-1", containerId: "cid" };
|
if (init?.method === "POST" && url.endsWith("/api/spawn"))
|
||||||
if (url.endsWith("/api/spawn/status")) return [];
|
return { sessionId: "sj-1", containerId: "cid" };
|
||||||
if (url.endsWith("/api/gitlab/status")) return { connected: true, baseUrl: "https://gl", username: "alice" };
|
if (url.endsWith("/api/spawn/status")) return [];
|
||||||
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
if (url.endsWith("/api/gitlab/status"))
|
||||||
return [];
|
return { connected: true, baseUrl: "https://gl", username: "alice" };
|
||||||
});
|
if (url.endsWith("/api/gitlab/repos")) return [repo("g/p")];
|
||||||
const store = makeStore();
|
return [];
|
||||||
const { rerender } = render(tree(store));
|
});
|
||||||
await flush();
|
const store = makeStore();
|
||||||
fireEvent.click(screen.getByText("g/p"));
|
const { rerender } = render(tree(store));
|
||||||
fireEvent.click(screen.getByLabelText("Spawn container"));
|
await flush();
|
||||||
await flush();
|
fireEvent.click(screen.getByText("g/p"));
|
||||||
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
fireEvent.click(screen.getByLabelText("Spawn container"));
|
||||||
|
await flush();
|
||||||
|
expect(screen.getByText("Spawning…")).toBeInTheDocument();
|
||||||
|
|
||||||
store.spawnJobs = [{ repo: "g/p", state: "cloning", sessionId: "sj-1" }];
|
store.spawnJobs = [{ repo: "g/p", state: "cloning", sessionId: "sj-1" }];
|
||||||
act(() => {
|
act(() => {
|
||||||
rerender(tree(store));
|
rerender(tree(store));
|
||||||
});
|
});
|
||||||
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
expect(screen.getByText("g/p: cloning")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+89
-58
@@ -6,66 +6,97 @@ import TaskPanel from "./TaskPanel";
|
|||||||
const base: TaskDerivation = { todos: [], subagents: [], workingTools: [] };
|
const base: TaskDerivation = { todos: [], subagents: [], workingTools: [] };
|
||||||
|
|
||||||
describe("TaskPanel", () => {
|
describe("TaskPanel", () => {
|
||||||
it("empty state", () => {
|
it("empty state", () => {
|
||||||
render(<TaskPanel tasks={base} />);
|
render(<TaskPanel tasks={base} />);
|
||||||
expect(screen.getByText("No tasks yet.")).toBeInTheDocument();
|
expect(screen.getByText("No tasks yet.")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("todo rows render icons per status and deleted styling", () => {
|
it("todo rows render icons per status and deleted styling", () => {
|
||||||
const tasks: TaskDerivation = {
|
const tasks: TaskDerivation = {
|
||||||
...base,
|
...base,
|
||||||
todos: [
|
todos: [
|
||||||
{ content: "write tests", status: "pending", deleted: false },
|
{ content: "write tests", status: "pending", deleted: false },
|
||||||
{ content: "run them", status: "in-progress", deleted: false },
|
{ content: "run them", status: "in-progress", deleted: false },
|
||||||
{ content: "ship it", status: "completed", deleted: false },
|
{ content: "ship it", status: "completed", deleted: false },
|
||||||
{ content: "old task", status: "pending", deleted: true },
|
{ content: "old task", status: "pending", deleted: true },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
const { container } = render(<TaskPanel tasks={tasks} />);
|
const { container } = render(<TaskPanel tasks={tasks} />);
|
||||||
expect(container.querySelector(".todo-icon.pending")?.textContent).toBe("○");
|
expect(container.querySelector(".todo-icon.pending")?.textContent).toBe(
|
||||||
expect(container.querySelector(".todo-icon.in-progress")?.textContent).toBe("◺");
|
"○",
|
||||||
expect(container.querySelector(".todo-icon.completed")?.textContent).toBe("●");
|
);
|
||||||
const deleted = container.querySelector(".todo-item.deleted .todo-text") as HTMLElement;
|
expect(container.querySelector(".todo-icon.in-progress")?.textContent).toBe(
|
||||||
expect(deleted).not.toBeNull();
|
"◺",
|
||||||
expect(deleted.style.textDecoration).toContain("line-through");
|
);
|
||||||
});
|
expect(container.querySelector(".todo-icon.completed")?.textContent).toBe(
|
||||||
|
"●",
|
||||||
|
);
|
||||||
|
const deleted = container.querySelector(
|
||||||
|
".todo-item.deleted .todo-text",
|
||||||
|
) as HTMLElement;
|
||||||
|
expect(deleted).not.toBeNull();
|
||||||
|
expect(deleted.style.textDecoration).toContain("line-through");
|
||||||
|
});
|
||||||
|
|
||||||
it("subagent rows: running spinner, done check, failed cross", () => {
|
it("subagent rows: running spinner, done check, failed cross", () => {
|
||||||
const tasks: TaskDerivation = {
|
const tasks: TaskDerivation = {
|
||||||
...base,
|
...base,
|
||||||
subagents: [
|
subagents: [
|
||||||
{ key: "a", name: "scout", running: true, isError: false },
|
{ key: "a", name: "scout", running: true, isError: false },
|
||||||
{ key: "b", name: "worker", running: false, isError: false },
|
{ key: "b", name: "worker", running: false, isError: false },
|
||||||
{ key: "c", name: "reviewer", running: false, isError: true },
|
{ key: "c", name: "reviewer", running: false, isError: true },
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
render(<TaskPanel tasks={tasks} />);
|
render(<TaskPanel tasks={tasks} />);
|
||||||
expect(screen.getByLabelText("running")).toBeInTheDocument();
|
expect(screen.getByLabelText("running")).toBeInTheDocument();
|
||||||
expect(screen.getByText("scout")).toBeInTheDocument();
|
expect(screen.getByText("scout")).toBeInTheDocument();
|
||||||
expect(screen.getByText("running")).toBeInTheDocument();
|
expect(screen.getByText("running")).toBeInTheDocument();
|
||||||
expect(screen.getByText("✓")).toBeInTheDocument();
|
expect(screen.getByText("✓")).toBeInTheDocument();
|
||||||
expect(screen.getByText("✕")).toBeInTheDocument();
|
expect(screen.getByText("✕")).toBeInTheDocument();
|
||||||
expect(screen.getByText("done")).toBeInTheDocument();
|
expect(screen.getByText("done")).toBeInTheDocument();
|
||||||
expect(screen.getByText("failed")).toBeInTheDocument();
|
expect(screen.getByText("failed")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("working tools section", () => {
|
it("working tools section", () => {
|
||||||
const tasks: TaskDerivation = {
|
const tasks: TaskDerivation = {
|
||||||
...base,
|
...base,
|
||||||
workingTools: [{ id: "c1", name: "bash", args: "ls", running: true, isError: false, preview: "" }],
|
workingTools: [
|
||||||
};
|
{
|
||||||
render(<TaskPanel tasks={tasks} />);
|
id: "c1",
|
||||||
expect(screen.getByText("Working")).toBeInTheDocument();
|
name: "bash",
|
||||||
expect(screen.getByText("bash…")).toBeInTheDocument();
|
args: "ls",
|
||||||
});
|
running: true,
|
||||||
|
isError: false,
|
||||||
|
preview: "",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
render(<TaskPanel tasks={tasks} />);
|
||||||
|
expect(screen.getByText("Working")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("bash…")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("sections appear only when populated", () => {
|
it("sections appear only when populated", () => {
|
||||||
const { rerender, queryByText } = render(<TaskPanel tasks={{ ...base, todos: [{ content: "t", status: "pending", deleted: false }] }} />);
|
const { rerender, queryByText } = render(
|
||||||
expect(queryByText("Tasks")).not.toBeNull();
|
<TaskPanel
|
||||||
expect(queryByText("Subagents")).toBeNull();
|
tasks={{
|
||||||
expect(queryByText("Working")).toBeNull();
|
...base,
|
||||||
rerender(<TaskPanel tasks={{ ...base, subagents: [{ key: "a", name: "s", running: false, isError: false }] }} />);
|
todos: [{ content: "t", status: "pending", deleted: false }],
|
||||||
expect(queryByText("Tasks")).toBeNull();
|
}}
|
||||||
expect(queryByText("Subagents")).not.toBeNull();
|
/>,
|
||||||
});
|
);
|
||||||
|
expect(queryByText("Tasks")).not.toBeNull();
|
||||||
|
expect(queryByText("Subagents")).toBeNull();
|
||||||
|
expect(queryByText("Working")).toBeNull();
|
||||||
|
rerender(
|
||||||
|
<TaskPanel
|
||||||
|
tasks={{
|
||||||
|
...base,
|
||||||
|
subagents: [{ key: "a", name: "s", running: false, isError: false }],
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(queryByText("Tasks")).toBeNull();
|
||||||
|
expect(queryByText("Subagents")).not.toBeNull();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+85
-58
@@ -4,70 +4,97 @@ import { saveSettings } from "./settings";
|
|||||||
import { jsonResponse } from "./test/setup";
|
import { jsonResponse } from "./test/setup";
|
||||||
|
|
||||||
describe("api", () => {
|
describe("api", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
saveSettings({ serverUrl: "http://srv", token: "sekret" });
|
saveSettings({ serverUrl: "http://srv", token: "sekret" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("throws 401 when not configured", async () => {
|
it("throws 401 when not configured", async () => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
await expect(fetchJson("/api/sessions")).rejects.toMatchObject({ status: 401 });
|
await expect(fetchJson("/api/sessions")).rejects.toMatchObject({
|
||||||
});
|
status: 401,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("GET sends bearer header and parses JSON", async () => {
|
it("GET sends bearer header and parses JSON", async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse([{ id: "s1" }]));
|
const fetchMock = vi
|
||||||
const out = await fetchJson<Array<{ id: string }>>("/api/sessions");
|
.spyOn(globalThis, "fetch")
|
||||||
expect(out).toEqual([{ id: "s1" }]);
|
.mockResolvedValue(jsonResponse([{ id: "s1" }]));
|
||||||
const [input, init] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
|
const out = await fetchJson<Array<{ id: string }>>("/api/sessions");
|
||||||
expect(input).toBe("http://srv/api/sessions");
|
expect(out).toEqual([{ id: "s1" }]);
|
||||||
expect(init.headers).toEqual({ Authorization: "Bearer sekret" });
|
const [input, init] = fetchMock.mock.calls[0] as unknown as [
|
||||||
expect(init.body).toBeUndefined();
|
string,
|
||||||
});
|
RequestInit,
|
||||||
|
];
|
||||||
|
expect(input).toBe("http://srv/api/sessions");
|
||||||
|
expect(init.headers).toEqual({ Authorization: "Bearer sekret" });
|
||||||
|
expect(init.body).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("POST sends JSON content-type with body", async () => {
|
it("POST sends JSON content-type with body", async () => {
|
||||||
const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ ok: true }));
|
const fetchMock = vi
|
||||||
await fetchJson("/api/sessions/s1/prompt", { method: "POST", body: JSON.stringify({ message: "hi" }) });
|
.spyOn(globalThis, "fetch")
|
||||||
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
.mockResolvedValue(jsonResponse({ ok: true }));
|
||||||
expect(init.method).toBe("POST");
|
await fetchJson("/api/sessions/s1/prompt", {
|
||||||
expect(init.headers).toEqual({ Authorization: "Bearer sekret", "Content-Type": "application/json" });
|
method: "POST",
|
||||||
});
|
body: JSON.stringify({ message: "hi" }),
|
||||||
|
});
|
||||||
|
const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
|
||||||
|
expect(init.method).toBe("POST");
|
||||||
|
expect(init.headers).toEqual({
|
||||||
|
Authorization: "Bearer sekret",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("throws ApiError with server error message", async () => {
|
it("throws ApiError with server error message", async () => {
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ error: "boom" }, 500));
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e);
|
jsonResponse({ error: "boom" }, 500),
|
||||||
expect(err).toBeInstanceOf(ApiError);
|
);
|
||||||
expect((err as ApiError).message).toBe("boom");
|
const err: unknown = await fetchJson("/api/sessions").catch(
|
||||||
expect((err as ApiError).status).toBe(500);
|
(e: unknown) => e,
|
||||||
});
|
);
|
||||||
|
expect(err).toBeInstanceOf(ApiError);
|
||||||
|
expect((err as ApiError).message).toBe("boom");
|
||||||
|
expect((err as ApiError).status).toBe(500);
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to status text when body has no error string", async () => {
|
it("falls back to status text when body has no error string", async () => {
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse({ other: 1 }, 404));
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||||
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e);
|
jsonResponse({ other: 1 }, 404),
|
||||||
expect((err as ApiError).message).toBe("404 StatusText");
|
);
|
||||||
});
|
const err: unknown = await fetchJson("/api/sessions").catch(
|
||||||
|
(e: unknown) => e,
|
||||||
|
);
|
||||||
|
expect((err as ApiError).message).toBe("404 StatusText");
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to status text when body is not JSON", async () => {
|
it("falls back to status text when body is not JSON", async () => {
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||||
ok: false,
|
ok: false,
|
||||||
status: 502,
|
status: 502,
|
||||||
statusText: "Bad Gateway",
|
statusText: "Bad Gateway",
|
||||||
json: async () => {
|
json: async () => {
|
||||||
throw new SyntaxError("bad json");
|
throw new SyntaxError("bad json");
|
||||||
},
|
},
|
||||||
} as unknown as Response);
|
} as unknown as Response);
|
||||||
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e);
|
const err: unknown = await fetchJson("/api/sessions").catch(
|
||||||
expect((err as ApiError).message).toBe("502 Bad Gateway");
|
(e: unknown) => e,
|
||||||
});
|
);
|
||||||
|
expect((err as ApiError).message).toBe("502 Bad Gateway");
|
||||||
|
});
|
||||||
|
|
||||||
it("rejects null JSON bodies gracefully in error path", async () => {
|
it("rejects null JSON bodies gracefully in error path", async () => {
|
||||||
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse(null, 409));
|
vi.spyOn(globalThis, "fetch").mockResolvedValue(jsonResponse(null, 409));
|
||||||
const err: unknown = await fetchJson("/api/sessions").catch((e: unknown) => e);
|
const err: unknown = await fetchJson("/api/sessions").catch(
|
||||||
expect((err as ApiError).message).toBe("409 StatusText");
|
(e: unknown) => e,
|
||||||
});
|
);
|
||||||
|
expect((err as ApiError).message).toBe("409 StatusText");
|
||||||
|
});
|
||||||
|
|
||||||
it("errMessage maps Error and non-Error values", () => {
|
it("errMessage maps Error and non-Error values", () => {
|
||||||
expect(errMessage(new Error("oops"))).toBe("oops");
|
expect(errMessage(new Error("oops"))).toBe("oops");
|
||||||
expect(errMessage(42)).toBe("42");
|
expect(errMessage(42)).toBe("42");
|
||||||
expect(errMessage(null)).toBe("null");
|
expect(errMessage(null)).toBe("null");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+505
-346
@@ -1,422 +1,581 @@
|
|||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import type { EventFrame, Message } from "./protocol";
|
import type { EventFrame, Message } from "./protocol";
|
||||||
import { deriveChat, deriveTasks, lastPersistedSeq, mergeEvents } from "./derive";
|
import {
|
||||||
|
deriveChat,
|
||||||
|
deriveTasks,
|
||||||
|
lastPersistedSeq,
|
||||||
|
mergeEvents,
|
||||||
|
} from "./derive";
|
||||||
|
|
||||||
let seq: number = 0;
|
let seq: number = 0;
|
||||||
function ev(partial: Partial<EventFrame> & { type: string }): EventFrame {
|
function ev(partial: Partial<EventFrame> & { type: string }): EventFrame {
|
||||||
seq += 1;
|
seq += 1;
|
||||||
return { v: 1, sessionId: "s1", seq: partial.seq ?? seq, ts: 0, ...partial } as EventFrame;
|
return {
|
||||||
|
v: 1,
|
||||||
|
sessionId: "s1",
|
||||||
|
seq: partial.seq ?? seq,
|
||||||
|
ts: 0,
|
||||||
|
...partial,
|
||||||
|
} as EventFrame;
|
||||||
}
|
}
|
||||||
function msg(m: Partial<Message>): Message {
|
function msg(m: Partial<Message>): Message {
|
||||||
return {
|
return {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
id: "m1",
|
id: "m1",
|
||||||
text: "",
|
text: "",
|
||||||
thinking: null,
|
thinking: null,
|
||||||
toolCalls: [],
|
toolCalls: [],
|
||||||
toolCallId: null,
|
toolCallId: null,
|
||||||
...m,
|
...m,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function toolStart(id: string, name: string, args?: string): EventFrame {
|
function toolStart(id: string, name: string, args?: string): EventFrame {
|
||||||
return ev({ type: "tool_execution_start", toolCallId: id, toolName: name, args: args ?? "" });
|
return ev({
|
||||||
|
type: "tool_execution_start",
|
||||||
|
toolCallId: id,
|
||||||
|
toolName: name,
|
||||||
|
args: args ?? "",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function toolEnd(id: string, isError?: boolean, preview?: string): EventFrame {
|
function toolEnd(id: string, isError?: boolean, preview?: string): EventFrame {
|
||||||
return ev({ type: "tool_execution_end", toolCallId: id, isError: isError ?? false, resultPreview: preview ?? "" });
|
return ev({
|
||||||
|
type: "tool_execution_end",
|
||||||
|
toolCallId: id,
|
||||||
|
isError: isError ?? false,
|
||||||
|
resultPreview: preview ?? "",
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- mergeEvents / lastPersistedSeq ----------
|
// ---------- mergeEvents / lastPersistedSeq ----------
|
||||||
|
|
||||||
describe("mergeEvents", () => {
|
describe("mergeEvents", () => {
|
||||||
it("merges and sorts by seq, dedupes by seq", () => {
|
it("merges and sorts by seq, dedupes by seq", () => {
|
||||||
const a = [ev({ type: "hello", seq: 5 }), ev({ type: "hello", seq: 1 })];
|
const a = [ev({ type: "hello", seq: 5 }), ev({ type: "hello", seq: 1 })];
|
||||||
seq = 0;
|
seq = 0;
|
||||||
const b = [ev({ type: "hello", seq: 3 }), ev({ type: "hello", seq: 1 })];
|
const b = [ev({ type: "hello", seq: 3 }), ev({ type: "hello", seq: 1 })];
|
||||||
const merged = mergeEvents(a, b);
|
const merged = mergeEvents(a, b);
|
||||||
expect(merged.map((e) => e.seq)).toEqual([1, 3, 5]);
|
expect(merged.map((e) => e.seq)).toEqual([1, 3, 5]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("incoming wins on duplicate seq", () => {
|
it("incoming wins on duplicate seq", () => {
|
||||||
const a = [ev({ type: "hello", seq: 1, reason: "a" })];
|
const a = [ev({ type: "hello", seq: 1, reason: "a" })];
|
||||||
const b: EventFrame[] = a.map((e) => ({ ...e, reason: "b" }));
|
const b: EventFrame[] = a.map((e) => ({ ...e, reason: "b" }));
|
||||||
expect(mergeEvents(a, b)[0]?.reason).toBe("b");
|
expect(mergeEvents(a, b)[0]?.reason).toBe("b");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("lastPersistedSeq", () => {
|
describe("lastPersistedSeq", () => {
|
||||||
it("ignores message_update deltas", () => {
|
it("ignores message_update deltas", () => {
|
||||||
const events = [
|
const events = [
|
||||||
ev({ type: "message_end", seq: 7 }),
|
ev({ type: "message_end", seq: 7 }),
|
||||||
ev({ type: "message_update", seq: 99 }),
|
ev({ type: "message_update", seq: 99 }),
|
||||||
ev({ type: "agent_settled", seq: 8 }),
|
ev({ type: "agent_settled", seq: 8 }),
|
||||||
];
|
];
|
||||||
expect(lastPersistedSeq(events)).toBe(8);
|
expect(lastPersistedSeq(events)).toBe(8);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("returns 0 for empty or delta-only streams", () => {
|
it("returns 0 for empty or delta-only streams", () => {
|
||||||
expect(lastPersistedSeq([])).toBe(0);
|
expect(lastPersistedSeq([])).toBe(0);
|
||||||
expect(lastPersistedSeq([ev({ type: "message_update", delta: "x", seq: 5 })])).toBe(0);
|
expect(
|
||||||
});
|
lastPersistedSeq([ev({ type: "message_update", delta: "x", seq: 5 })]),
|
||||||
|
).toBe(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- deriveChat ----------
|
// ---------- deriveChat ----------
|
||||||
|
|
||||||
describe("deriveChat", () => {
|
describe("deriveChat", () => {
|
||||||
it("message_end renders user/assistant/system/toolResult messages", () => {
|
it("message_end renders user/assistant/system/toolResult messages", () => {
|
||||||
const events = [
|
const events = [
|
||||||
ev({ type: "message_end", message: msg({ id: "u1", role: "user", text: "hi" }) }),
|
ev({
|
||||||
ev({ type: "message_end", message: msg({ id: "a1", role: "assistant", text: "hello" }) }),
|
type: "message_end",
|
||||||
ev({ type: "message_end", message: msg({ id: "t1", role: "toolResult", text: "out", toolCallId: "c1" }) }),
|
message: msg({ id: "u1", role: "user", text: "hi" }),
|
||||||
ev({ type: "message_end", message: msg({ id: "s1", role: "system", text: "sys" }) }),
|
}),
|
||||||
];
|
ev({
|
||||||
const { messages } = deriveChat(events);
|
type: "message_end",
|
||||||
expect(messages.map((m) => m.role)).toEqual(["user", "assistant", "toolResult", "system"]);
|
message: msg({ id: "a1", role: "assistant", text: "hello" }),
|
||||||
expect(messages[1]?.text).toBe("hello");
|
}),
|
||||||
expect(messages.every((m) => m.streaming === false)).toBe(true);
|
ev({
|
||||||
});
|
type: "message_end",
|
||||||
|
message: msg({
|
||||||
|
id: "t1",
|
||||||
|
role: "toolResult",
|
||||||
|
text: "out",
|
||||||
|
toolCallId: "c1",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
ev({
|
||||||
|
type: "message_end",
|
||||||
|
message: msg({ id: "s1", role: "system", text: "sys" }),
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
const { messages } = deriveChat(events);
|
||||||
|
expect(messages.map((m) => m.role)).toEqual([
|
||||||
|
"user",
|
||||||
|
"assistant",
|
||||||
|
"toolResult",
|
||||||
|
"system",
|
||||||
|
]);
|
||||||
|
expect(messages[1]?.text).toBe("hello");
|
||||||
|
expect(messages.every((m) => m.streaming === false)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("message_update deltas append into a streaming bubble ended by matching message_end", () => {
|
it("message_update deltas append into a streaming bubble ended by matching message_end", () => {
|
||||||
const id = "a9";
|
const id = "a9";
|
||||||
const events = [
|
const events = [
|
||||||
ev({ type: "message_start", message: msg({ id, role: "assistant" }) }),
|
ev({ type: "message_start", message: msg({ id, role: "assistant" }) }),
|
||||||
ev({ type: "message_update", delta: "Hel" }),
|
ev({ type: "message_update", delta: "Hel" }),
|
||||||
ev({ type: "message_update", delta: "lo" }),
|
ev({ type: "message_update", delta: "lo" }),
|
||||||
];
|
];
|
||||||
let chat = deriveChat(events);
|
let chat = deriveChat(events);
|
||||||
expect(chat.messages).toHaveLength(1);
|
expect(chat.messages).toHaveLength(1);
|
||||||
expect(chat.messages[0]).toMatchObject({ role: "assistant", text: "Hello", streaming: true });
|
expect(chat.messages[0]).toMatchObject({
|
||||||
expect(chat.busy).toBe(true);
|
role: "assistant",
|
||||||
|
text: "Hello",
|
||||||
|
streaming: true,
|
||||||
|
});
|
||||||
|
expect(chat.busy).toBe(true);
|
||||||
|
|
||||||
chat = deriveChat([
|
chat = deriveChat([
|
||||||
...events,
|
...events,
|
||||||
ev({ type: "message_end", message: msg({ id, role: "assistant", text: "Hello world" }) }),
|
ev({
|
||||||
]);
|
type: "message_end",
|
||||||
expect(chat.messages).toHaveLength(1);
|
message: msg({ id, role: "assistant", text: "Hello world" }),
|
||||||
expect(chat.messages[0]).toMatchObject({ text: "Hello world", streaming: false });
|
}),
|
||||||
expect(chat.busy).toBe(false);
|
]);
|
||||||
});
|
expect(chat.messages).toHaveLength(1);
|
||||||
|
expect(chat.messages[0]).toMatchObject({
|
||||||
|
text: "Hello world",
|
||||||
|
streaming: false,
|
||||||
|
});
|
||||||
|
expect(chat.busy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("non-assistant message_start does not open a stream", () => {
|
it("non-assistant message_start does not open a stream", () => {
|
||||||
const chat = deriveChat([ev({ type: "message_start", message: msg({ id: "u1", role: "user" }) })]);
|
const chat = deriveChat([
|
||||||
expect(chat.messages).toHaveLength(0);
|
ev({ type: "message_start", message: msg({ id: "u1", role: "user" }) }),
|
||||||
expect(chat.busy).toBe(false);
|
]);
|
||||||
});
|
expect(chat.messages).toHaveLength(0);
|
||||||
|
expect(chat.busy).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("missing delta does not append 'undefined'", () => {
|
it("missing delta does not append 'undefined'", () => {
|
||||||
const chat = deriveChat([
|
const chat = deriveChat([
|
||||||
ev({ type: "message_start", message: msg({ id: "a", role: "assistant" }) }),
|
ev({
|
||||||
ev({ type: "message_update" }),
|
type: "message_start",
|
||||||
]);
|
message: msg({ id: "a", role: "assistant" }),
|
||||||
expect(chat.messages[0]?.text).toBe("");
|
}),
|
||||||
});
|
ev({ type: "message_update" }),
|
||||||
|
]);
|
||||||
|
expect(chat.messages[0]?.text).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
it("agent_start/agent_settled drive busy", () => {
|
it("agent_start/agent_settled drive busy", () => {
|
||||||
expect(deriveChat([ev({ type: "agent_start" })]).busy).toBe(true);
|
expect(deriveChat([ev({ type: "agent_start" })]).busy).toBe(true);
|
||||||
expect(deriveChat([ev({ type: "agent_start" }), ev({ type: "agent_settled" })]).busy).toBe(false);
|
expect(
|
||||||
});
|
deriveChat([ev({ type: "agent_start" }), ev({ type: "agent_settled" })])
|
||||||
|
.busy,
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it("message_start for a new assistant id drops the old stream", () => {
|
it("message_start for a new assistant id drops the old stream", () => {
|
||||||
const chat = deriveChat([
|
const chat = deriveChat([
|
||||||
ev({ type: "message_start", message: msg({ id: "a1", role: "assistant" }) }),
|
ev({
|
||||||
ev({ type: "message_update", delta: "x" }),
|
type: "message_start",
|
||||||
ev({ type: "message_start", message: msg({ id: "a2", role: "assistant" }) }),
|
message: msg({ id: "a1", role: "assistant" }),
|
||||||
ev({ type: "message_update", delta: "y" }),
|
}),
|
||||||
]);
|
ev({ type: "message_update", delta: "x" }),
|
||||||
expect(chat.messages).toHaveLength(1);
|
ev({
|
||||||
expect(chat.messages[0]?.key).toBe("stream-a2");
|
type: "message_start",
|
||||||
expect(chat.messages[0]?.text).toBe("y");
|
message: msg({ id: "a2", role: "assistant" }),
|
||||||
});
|
}),
|
||||||
|
ev({ type: "message_update", delta: "y" }),
|
||||||
|
]);
|
||||||
|
expect(chat.messages).toHaveLength(1);
|
||||||
|
expect(chat.messages[0]?.key).toBe("stream-a2");
|
||||||
|
expect(chat.messages[0]?.text).toBe("y");
|
||||||
|
});
|
||||||
|
|
||||||
it("message_end without message payload is ignored", () => {
|
it("message_end without message payload is ignored", () => {
|
||||||
const chat = deriveChat([ev({ type: "message_end" })]);
|
const chat = deriveChat([ev({ type: "message_end" })]);
|
||||||
expect(chat.messages).toHaveLength(0);
|
expect(chat.messages).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tool lifecycle states", () => {
|
it("tool lifecycle states", () => {
|
||||||
const chat = deriveChat([
|
const chat = deriveChat([
|
||||||
toolStart("c1", "bash", "ls"),
|
toolStart("c1", "bash", "ls"),
|
||||||
toolStart("c2", "read"),
|
toolStart("c2", "read"),
|
||||||
toolEnd("c1", true, "boom"),
|
toolEnd("c1", true, "boom"),
|
||||||
toolEnd("c2", false, "ok"),
|
toolEnd("c2", false, "ok"),
|
||||||
]);
|
]);
|
||||||
expect(chat.tools.get("c1")).toMatchObject({ name: "bash", args: "ls", running: false, isError: true, preview: "boom" });
|
expect(chat.tools.get("c1")).toMatchObject({
|
||||||
expect(chat.tools.get("c2")).toMatchObject({ running: false, isError: false, preview: "ok" });
|
name: "bash",
|
||||||
// defaults
|
args: "ls",
|
||||||
const chat2 = deriveChat([
|
running: false,
|
||||||
ev({ type: "tool_execution_start", toolCallId: "c3", toolName: "t" }),
|
isError: true,
|
||||||
ev({ type: "tool_execution_end", toolCallId: "c3" }),
|
preview: "boom",
|
||||||
]);
|
});
|
||||||
expect(chat2.tools.get("c3")).toMatchObject({ running: false, isError: false, preview: "" });
|
expect(chat.tools.get("c2")).toMatchObject({
|
||||||
});
|
running: false,
|
||||||
|
isError: false,
|
||||||
|
preview: "ok",
|
||||||
|
});
|
||||||
|
// defaults
|
||||||
|
const chat2 = deriveChat([
|
||||||
|
ev({ type: "tool_execution_start", toolCallId: "c3", toolName: "t" }),
|
||||||
|
ev({ type: "tool_execution_end", toolCallId: "c3" }),
|
||||||
|
]);
|
||||||
|
expect(chat2.tools.get("c3")).toMatchObject({
|
||||||
|
running: false,
|
||||||
|
isError: false,
|
||||||
|
preview: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("tool_execution_start without id and end for unknown id are ignored", () => {
|
it("tool_execution_start without id and end for unknown id are ignored", () => {
|
||||||
const chat = deriveChat([
|
const chat = deriveChat([
|
||||||
ev({ type: "tool_execution_start", toolName: "x" }),
|
ev({ type: "tool_execution_start", toolName: "x" }),
|
||||||
ev({ type: "tool_execution_end", toolCallId: "ghost" }),
|
ev({ type: "tool_execution_end", toolCallId: "ghost" }),
|
||||||
]);
|
]);
|
||||||
expect(chat.tools.size).toBe(0);
|
expect(chat.tools.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tool execution defaults missing names/args", () => {
|
it("tool execution defaults missing names/args", () => {
|
||||||
const chat = deriveChat([ev({ type: "tool_execution_start", toolCallId: "c" })]);
|
const chat = deriveChat([
|
||||||
expect(chat.tools.get("c")).toMatchObject({ name: "tool", args: "" });
|
ev({ type: "tool_execution_start", toolCallId: "c" }),
|
||||||
});
|
]);
|
||||||
|
expect(chat.tools.get("c")).toMatchObject({ name: "tool", args: "" });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- deriveTasks: todo derivation truth table ----------
|
// ---------- deriveTasks: todo derivation truth table ----------
|
||||||
|
|
||||||
function todoSnap(items: unknown): string {
|
function todoSnap(items: unknown): string {
|
||||||
return JSON.stringify(items);
|
return JSON.stringify(items);
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("deriveTasks todos", () => {
|
describe("deriveTasks todos", () => {
|
||||||
it("latest snapshot wins; statuses from latest; deletion marks earlier items", () => {
|
it("latest snapshot wins; statuses from latest; deletion marks earlier items", () => {
|
||||||
const events = [
|
const events = [
|
||||||
toolStart("c1", "todo", todoSnap([{ content: "a" }, { content: "b" }])),
|
toolStart("c1", "todo", todoSnap([{ content: "a" }, { content: "b" }])),
|
||||||
toolEnd("c1", false, todoSnap([{ content: "a", status: "in-progress" }, { content: "b" }])),
|
toolEnd(
|
||||||
toolStart("c2", "todo", todoSnap([{ content: "b", status: "completed" }])),
|
"c1",
|
||||||
];
|
false,
|
||||||
const { todos } = deriveTasks(events);
|
todoSnap([{ content: "a", status: "in-progress" }, { content: "b" }]),
|
||||||
expect(todos).toEqual([
|
),
|
||||||
{ content: "b", status: "completed", deleted: false },
|
toolStart(
|
||||||
{ content: "a", status: "pending", deleted: true },
|
"c2",
|
||||||
]);
|
"todo",
|
||||||
});
|
todoSnap([{ content: "b", status: "completed" }]),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const { todos } = deriveTasks(events);
|
||||||
|
expect(todos).toEqual([
|
||||||
|
{ content: "b", status: "completed", deleted: false },
|
||||||
|
{ content: "a", status: "pending", deleted: true },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
it("status alias normalization table", () => {
|
it("status alias normalization table", () => {
|
||||||
const aliases: Array<[unknown, string]> = [
|
const aliases: Array<[unknown, string]> = [
|
||||||
["in_progress", "in-progress"],
|
["in_progress", "in-progress"],
|
||||||
["in-progress", "in-progress"],
|
["in-progress", "in-progress"],
|
||||||
["inprogress", "in-progress"],
|
["inprogress", "in-progress"],
|
||||||
["in progress", "in-progress"],
|
["in progress", "in-progress"],
|
||||||
["doing", "in-progress"],
|
["doing", "in-progress"],
|
||||||
["started", "in-progress"],
|
["started", "in-progress"],
|
||||||
["completed", "completed"],
|
["completed", "completed"],
|
||||||
["complete", "completed"],
|
["complete", "completed"],
|
||||||
["done", "completed"],
|
["done", "completed"],
|
||||||
["weird", "pending"],
|
["weird", "pending"],
|
||||||
[7, "pending"],
|
[7, "pending"],
|
||||||
];
|
];
|
||||||
for (const [raw, expected] of aliases) {
|
for (const [raw, expected] of aliases) {
|
||||||
seq = 0;
|
seq = 0;
|
||||||
const events = [toolStart("c1", "todo", todoSnap([{ content: "t", status: raw }]))];
|
const events = [
|
||||||
expect(deriveTasks(events).todos[0]?.status, `status ${String(raw)}`).toBe(expected);
|
toolStart("c1", "todo", todoSnap([{ content: "t", status: raw }])),
|
||||||
}
|
];
|
||||||
});
|
expect(
|
||||||
|
deriveTasks(events).todos[0]?.status,
|
||||||
|
`status ${String(raw)}`,
|
||||||
|
).toBe(expected);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("snapshot sources: args of start, preview of end, toolResult message text", () => {
|
it("snapshot sources: args of start, preview of end, toolResult message text", () => {
|
||||||
const events = [
|
const events = [
|
||||||
toolStart("c1", "todo", todoSnap([{ content: "from-args" }])),
|
toolStart("c1", "todo", todoSnap([{ content: "from-args" }])),
|
||||||
toolEnd("c1", false, todoSnap([{ content: "from-preview" }])),
|
toolEnd("c1", false, todoSnap([{ content: "from-preview" }])),
|
||||||
ev({
|
ev({
|
||||||
type: "message_end",
|
type: "message_end",
|
||||||
message: msg({ id: "t1", role: "toolResult", text: todoSnap([{ content: "from-msg" }]), toolCallId: "c2" }),
|
message: msg({
|
||||||
}),
|
id: "t1",
|
||||||
];
|
role: "toolResult",
|
||||||
// c2 has no tool_execution_start before its toolResult message; the second
|
text: todoSnap([{ content: "from-msg" }]),
|
||||||
// pass only harvests toolResult text for known todo calls
|
toolCallId: "c2",
|
||||||
const eventsKnown = [
|
}),
|
||||||
...events,
|
}),
|
||||||
toolStart("c2", "todo"),
|
];
|
||||||
];
|
// c2 has no tool_execution_start before its toolResult message; the second
|
||||||
const { todos } = deriveTasks(eventsKnown);
|
// pass only harvests toolResult text for known todo calls
|
||||||
// latest snapshot (highest seq) is the toolResult message; earlier-only
|
const eventsKnown = [...events, toolStart("c2", "todo")];
|
||||||
// items survive as deleted markers
|
const { todos } = deriveTasks(eventsKnown);
|
||||||
expect(todos.map((t) => t.content)).toEqual(["from-msg", "from-args", "from-preview"]);
|
// latest snapshot (highest seq) is the toolResult message; earlier-only
|
||||||
expect(todos.map((t) => t.deleted)).toEqual([false, true, true]);
|
// items survive as deleted markers
|
||||||
});
|
expect(todos.map((t) => t.content)).toEqual([
|
||||||
|
"from-msg",
|
||||||
|
"from-args",
|
||||||
|
"from-preview",
|
||||||
|
]);
|
||||||
|
expect(todos.map((t) => t.deleted)).toEqual([false, true, true]);
|
||||||
|
});
|
||||||
|
|
||||||
it("accepts wrapper objects and string arrays", () => {
|
it("accepts wrapper objects and string arrays", () => {
|
||||||
const wrapped = deriveTasks([
|
const wrapped = deriveTasks([
|
||||||
toolStart("c1", "todo", JSON.stringify({ todos: [{ content: "w" }] })),
|
toolStart("c1", "todo", JSON.stringify({ todos: [{ content: "w" }] })),
|
||||||
]);
|
]);
|
||||||
expect(wrapped.todos.map((t) => t.content)).toEqual(["w"]);
|
expect(wrapped.todos.map((t) => t.content)).toEqual(["w"]);
|
||||||
const nestedItems = deriveTasks([toolStart("c1", "todo", JSON.stringify({ items: ["plain string"] }))]);
|
const nestedItems = deriveTasks([
|
||||||
expect(nestedItems.todos).toEqual([{ content: "plain string", status: "pending", deleted: false }]);
|
toolStart("c1", "todo", JSON.stringify({ items: ["plain string"] })),
|
||||||
const nestedTasks = deriveTasks([toolStart("c1", "todo", JSON.stringify({ tasks: [{ title: "tt" }] }))]);
|
]);
|
||||||
expect(nestedTasks.todos.map((t) => t.content)).toEqual(["tt"]);
|
expect(nestedItems.todos).toEqual([
|
||||||
const nestedList = deriveTasks([toolStart("c1", "todo", JSON.stringify({ list: [{ text: "tx" }] }))]);
|
{ content: "plain string", status: "pending", deleted: false },
|
||||||
expect(nestedList.todos.map((t) => t.content)).toEqual(["tx"]);
|
]);
|
||||||
const subject = deriveTasks([toolStart("c1", "todo", JSON.stringify([{ subject: "sj" }]))]);
|
const nestedTasks = deriveTasks([
|
||||||
expect(subject.todos.map((t) => t.content)).toEqual(["sj"]);
|
toolStart("c1", "todo", JSON.stringify({ tasks: [{ title: "tt" }] })),
|
||||||
const summary = deriveTasks([toolStart("c1", "todo", JSON.stringify([{ summary: "sm" }]))]);
|
]);
|
||||||
expect(summary.todos.map((t) => t.content)).toEqual(["sm"]);
|
expect(nestedTasks.todos.map((t) => t.content)).toEqual(["tt"]);
|
||||||
});
|
const nestedList = deriveTasks([
|
||||||
|
toolStart("c1", "todo", JSON.stringify({ list: [{ text: "tx" }] })),
|
||||||
|
]);
|
||||||
|
expect(nestedList.todos.map((t) => t.content)).toEqual(["tx"]);
|
||||||
|
const subject = deriveTasks([
|
||||||
|
toolStart("c1", "todo", JSON.stringify([{ subject: "sj" }])),
|
||||||
|
]);
|
||||||
|
expect(subject.todos.map((t) => t.content)).toEqual(["sj"]);
|
||||||
|
const summary = deriveTasks([
|
||||||
|
toolStart("c1", "todo", JSON.stringify([{ summary: "sm" }])),
|
||||||
|
]);
|
||||||
|
expect(summary.todos.map((t) => t.content)).toEqual(["sm"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("invalid snapshots are skipped: bad JSON, non-array, empty entries, empty content", () => {
|
it("invalid snapshots are skipped: bad JSON, non-array, empty entries, empty content", () => {
|
||||||
const cases: Array<string | undefined> = [
|
const cases: Array<string | undefined> = [
|
||||||
"{bad json",
|
"{bad json",
|
||||||
JSON.stringify({ nope: 1 }),
|
JSON.stringify({ nope: 1 }),
|
||||||
JSON.stringify([]),
|
JSON.stringify([]),
|
||||||
JSON.stringify([{ content: "" }]),
|
JSON.stringify([{ content: "" }]),
|
||||||
JSON.stringify([42]),
|
JSON.stringify([42]),
|
||||||
"",
|
"",
|
||||||
undefined,
|
undefined,
|
||||||
];
|
];
|
||||||
const events = cases.map((c, i) => toolStart(`c${i}`, "todo", c));
|
const events = cases.map((c, i) => toolStart(`c${i}`, "todo", c));
|
||||||
const { todos } = deriveTasks(events);
|
const { todos } = deriveTasks(events);
|
||||||
expect(todos).toHaveLength(0);
|
expect(todos).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("no todo tool calls yields no todos", () => {
|
it("no todo tool calls yields no todos", () => {
|
||||||
expect(deriveTasks([]).todos).toHaveLength(0);
|
expect(deriveTasks([]).todos).toHaveLength(0);
|
||||||
expect(deriveTasks([toolStart("c1", "bash")]).todos).toHaveLength(0);
|
expect(deriveTasks([toolStart("c1", "bash")]).todos).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- deriveTasks: subagents ----------
|
// ---------- deriveTasks: subagents ----------
|
||||||
|
|
||||||
describe("deriveTasks subagents", () => {
|
describe("deriveTasks subagents", () => {
|
||||||
it("name fields fallback table", () => {
|
it("name fields fallback table", () => {
|
||||||
const events = [
|
const events = [
|
||||||
toolStart("c1", "subagent", JSON.stringify({ agentName: "scout" })),
|
toolStart("c1", "subagent", JSON.stringify({ agentName: "scout" })),
|
||||||
toolStart("c2", "subagent", JSON.stringify({ agent: "worker" })),
|
toolStart("c2", "subagent", JSON.stringify({ agent: "worker" })),
|
||||||
toolStart("c3", "subagent", JSON.stringify({ name: "planner" })),
|
toolStart("c3", "subagent", JSON.stringify({ name: "planner" })),
|
||||||
toolStart("c4", "subagent", JSON.stringify({ agentType: "reviewer" })),
|
toolStart("c4", "subagent", JSON.stringify({ agentType: "reviewer" })),
|
||||||
toolStart("c5", "subagent", JSON.stringify({ role: "oracle" })),
|
toolStart("c5", "subagent", JSON.stringify({ role: "oracle" })),
|
||||||
toolStart("c6", "subagent", JSON.stringify({ agentName: "" })),
|
toolStart("c6", "subagent", JSON.stringify({ agentName: "" })),
|
||||||
toolStart("c7", "subagent", "not json"),
|
toolStart("c7", "subagent", "not json"),
|
||||||
];
|
];
|
||||||
const { subagents } = deriveTasks(events);
|
const { subagents } = deriveTasks(events);
|
||||||
expect(subagents.map((s) => s.name)).toEqual(["scout", "worker", "planner", "reviewer", "oracle", "subagent", "subagent"]);
|
expect(subagents.map((s) => s.name)).toEqual([
|
||||||
expect(subagents.every((s) => s.running && !s.isError)).toBe(true);
|
"scout",
|
||||||
});
|
"worker",
|
||||||
|
"planner",
|
||||||
|
"reviewer",
|
||||||
|
"oracle",
|
||||||
|
"subagent",
|
||||||
|
"subagent",
|
||||||
|
]);
|
||||||
|
expect(subagents.every((s) => s.running && !s.isError)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("tool_execution_end marks done / failed", () => {
|
it("tool_execution_end marks done / failed", () => {
|
||||||
const events = [
|
const events = [
|
||||||
toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })),
|
toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })),
|
||||||
toolStart("c2", "subagent", JSON.stringify({ agentName: "b" })),
|
toolStart("c2", "subagent", JSON.stringify({ agentName: "b" })),
|
||||||
toolEnd("c1", false),
|
toolEnd("c1", false),
|
||||||
toolEnd("c2", true),
|
toolEnd("c2", true),
|
||||||
];
|
];
|
||||||
const { subagents } = deriveTasks(events);
|
const { subagents } = deriveTasks(events);
|
||||||
expect(subagents.find((s) => s.key === "c1")).toMatchObject({ running: false, isError: false });
|
expect(subagents.find((s) => s.key === "c1")).toMatchObject({
|
||||||
expect(subagents.find((s) => s.key === "c2")).toMatchObject({ running: false, isError: true });
|
running: false,
|
||||||
});
|
isError: false,
|
||||||
|
});
|
||||||
|
expect(subagents.find((s) => s.key === "c2")).toMatchObject({
|
||||||
|
running: false,
|
||||||
|
isError: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("end for unknown subagent id ignored", () => {
|
it("end for unknown subagent id ignored", () => {
|
||||||
const { subagents } = deriveTasks([
|
const { subagents } = deriveTasks([
|
||||||
toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })),
|
toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })),
|
||||||
toolEnd("ghost"),
|
toolEnd("ghost"),
|
||||||
]);
|
]);
|
||||||
expect(subagents[0]?.running).toBe(true);
|
expect(subagents[0]?.running).toBe(true);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---------- deriveTasks: working tools ----------
|
// ---------- deriveTasks: working tools ----------
|
||||||
|
|
||||||
describe("deriveTasks workingTools", () => {
|
describe("deriveTasks workingTools", () => {
|
||||||
it("only running non-todo non-subagent tools are listed", () => {
|
it("only running non-todo non-subagent tools are listed", () => {
|
||||||
const events = [
|
const events = [
|
||||||
toolStart("c1", "bash", "ls"),
|
toolStart("c1", "bash", "ls"),
|
||||||
toolStart("c2", "read", "f"),
|
toolStart("c2", "read", "f"),
|
||||||
toolEnd("c1", false, "out"),
|
toolEnd("c1", false, "out"),
|
||||||
toolStart("c3", "todo", todoSnap([{ content: "x" }])),
|
toolStart("c3", "todo", todoSnap([{ content: "x" }])),
|
||||||
toolStart("c4", "subagent", JSON.stringify({ agentName: "a" })),
|
toolStart("c4", "subagent", JSON.stringify({ agentName: "a" })),
|
||||||
];
|
];
|
||||||
const { workingTools } = deriveTasks(events);
|
const { workingTools } = deriveTasks(events);
|
||||||
expect(workingTools.map((w) => w.id)).toEqual(["c2"]);
|
expect(workingTools.map((w) => w.id)).toEqual(["c2"]);
|
||||||
expect(workingTools[0]).toMatchObject({ name: "read", args: "f", running: true });
|
expect(workingTools[0]).toMatchObject({
|
||||||
});
|
name: "read",
|
||||||
|
args: "f",
|
||||||
|
running: true,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("tool_execution_update events do not affect derivation", () => {
|
it("tool_execution_update events do not affect derivation", () => {
|
||||||
const { workingTools } = deriveTasks([
|
const { workingTools } = deriveTasks([
|
||||||
toolStart("c1", "bash"),
|
toolStart("c1", "bash"),
|
||||||
ev({ type: "tool_execution_update", toolCallId: "c1", partial: "…" }),
|
ev({ type: "tool_execution_update", toolCallId: "c1", partial: "…" }),
|
||||||
]);
|
]);
|
||||||
expect(workingTools).toHaveLength(1);
|
expect(workingTools).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("deriveChat/deriveTasks edge branches", () => {
|
describe("deriveChat/deriveTasks edge branches", () => {
|
||||||
it("unhandled event types fall through the switch", () => {
|
it("unhandled event types fall through the switch", () => {
|
||||||
const chat = deriveChat([
|
const chat = deriveChat([
|
||||||
ev({ type: "hello" }),
|
ev({ type: "hello" }),
|
||||||
ev({ type: "bye", reason: "shutdown" }),
|
ev({ type: "bye", reason: "shutdown" }),
|
||||||
ev({ type: "session_info" }),
|
ev({ type: "session_info" }),
|
||||||
ev({ type: "agent_end", usage: {} }),
|
ev({ type: "agent_end", usage: {} }),
|
||||||
ev({ type: "tool_execution_update", toolCallId: "c", partial: "x" }),
|
ev({ type: "tool_execution_update", toolCallId: "c", partial: "x" }),
|
||||||
]);
|
]);
|
||||||
expect(chat.messages).toHaveLength(0);
|
expect(chat.messages).toHaveLength(0);
|
||||||
expect(chat.busy).toBe(false);
|
expect(chat.busy).toBe(false);
|
||||||
expect(chat.tools.size).toBe(0);
|
expect(chat.tools.size).toBe(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("message_end keeps toolCalls and ends only the matching stream", () => {
|
it("message_end keeps toolCalls and ends only the matching stream", () => {
|
||||||
const withTools = deriveChat([
|
const withTools = deriveChat([
|
||||||
ev({
|
ev({
|
||||||
type: "message_end",
|
type: "message_end",
|
||||||
message: msg({ id: "a1", toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }] }),
|
message: msg({
|
||||||
}),
|
id: "a1",
|
||||||
]);
|
toolCalls: [{ id: "c1", name: "bash", argsJson: "{}" }],
|
||||||
expect(withTools.messages[0]?.toolCalls).toHaveLength(1);
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(withTools.messages[0]?.toolCalls).toHaveLength(1);
|
||||||
|
|
||||||
// message_end for a different id does not close the open stream
|
// message_end for a different id does not close the open stream
|
||||||
const mismatch = deriveChat([
|
const mismatch = deriveChat([
|
||||||
ev({ type: "message_start", message: msg({ id: "streaming" }) }),
|
ev({ type: "message_start", message: msg({ id: "streaming" }) }),
|
||||||
ev({ type: "message_update", delta: "par" }),
|
ev({ type: "message_update", delta: "par" }),
|
||||||
ev({ type: "message_end", message: msg({ id: "other", text: "done" }) }),
|
ev({ type: "message_end", message: msg({ id: "other", text: "done" }) }),
|
||||||
]);
|
]);
|
||||||
expect(mismatch.messages[0]?.key).toMatch(/^msg-\d+$/);
|
expect(mismatch.messages[0]?.key).toMatch(/^msg-\d+$/);
|
||||||
expect(mismatch.messages[1]?.text).toBe("par");
|
expect(mismatch.messages[1]?.text).toBe("par");
|
||||||
expect(mismatch.busy).toBe(true);
|
expect(mismatch.busy).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("tool_execution_start without toolName maps to empty name in deriveTasks", () => {
|
it("tool_execution_start without toolName maps to empty name in deriveTasks", () => {
|
||||||
const { workingTools } = deriveTasks([ev({ type: "tool_execution_start", toolCallId: "c" })]);
|
const { workingTools } = deriveTasks([
|
||||||
expect(workingTools).toHaveLength(1);
|
ev({ type: "tool_execution_start", toolCallId: "c" }),
|
||||||
expect(workingTools[0]?.name).toBe("");
|
]);
|
||||||
});
|
expect(workingTools).toHaveLength(1);
|
||||||
|
expect(workingTools[0]?.name).toBe("");
|
||||||
|
});
|
||||||
|
|
||||||
it("tool_execution_end without isError/resultPreview finishes the tool cleanly", () => {
|
it("tool_execution_end without isError/resultPreview finishes the tool cleanly", () => {
|
||||||
const { workingTools } = deriveTasks([
|
const { workingTools } = deriveTasks([
|
||||||
toolStart("c1", "bash"),
|
toolStart("c1", "bash"),
|
||||||
ev({ type: "tool_execution_end", toolCallId: "c1" }),
|
ev({ type: "tool_execution_end", toolCallId: "c1" }),
|
||||||
]);
|
]);
|
||||||
expect(workingTools).toHaveLength(0);
|
expect(workingTools).toHaveLength(0);
|
||||||
const chat = deriveChat([toolStart("c1", "bash"), ev({ type: "tool_execution_end", toolCallId: "c1" })]);
|
const chat = deriveChat([
|
||||||
expect(chat.tools.get("c1")).toMatchObject({ running: false, isError: false, preview: "" });
|
toolStart("c1", "bash"),
|
||||||
});
|
ev({ type: "tool_execution_end", toolCallId: "c1" }),
|
||||||
|
]);
|
||||||
|
expect(chat.tools.get("c1")).toMatchObject({
|
||||||
|
running: false,
|
||||||
|
isError: false,
|
||||||
|
preview: "",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("snapshot from literal null args is ignored", () => {
|
it("snapshot from literal null args is ignored", () => {
|
||||||
const { todos } = deriveTasks([toolStart("c1", "todo", "null")]);
|
const { todos } = deriveTasks([toolStart("c1", "todo", "null")]);
|
||||||
expect(todos).toHaveLength(0);
|
expect(todos).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("content fallback: null content falls through to title", () => {
|
it("content fallback: null content falls through to title", () => {
|
||||||
const { todos } = deriveTasks([
|
const { todos } = deriveTasks([
|
||||||
toolStart("c1", "todo", todoSnap([{ content: null, title: "t1" }])),
|
toolStart("c1", "todo", todoSnap([{ content: null, title: "t1" }])),
|
||||||
]);
|
]);
|
||||||
expect(todos.map((t) => t.content)).toEqual(["t1"]);
|
expect(todos.map((t) => t.content)).toEqual(["t1"]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("optional-field null sides", () => {
|
it("optional-field null sides", () => {
|
||||||
// tool_execution_end without toolCallId: deriveChat ternary else
|
// tool_execution_end without toolCallId: deriveChat ternary else
|
||||||
expect(() => deriveChat([ev({ type: "tool_execution_end" })])).not.toThrow();
|
expect(() =>
|
||||||
|
deriveChat([ev({ type: "tool_execution_end" })]),
|
||||||
|
).not.toThrow();
|
||||||
|
|
||||||
// message without toolCalls: ?? [] fallback
|
// message without toolCalls: ?? [] fallback
|
||||||
const bare = deriveChat([
|
const bare = deriveChat([
|
||||||
ev({
|
ev({
|
||||||
type: "message_end",
|
type: "message_end",
|
||||||
message: { role: "assistant", id: "a", text: "x", thinking: null, toolCallId: null } as Message,
|
message: {
|
||||||
}),
|
role: "assistant",
|
||||||
]);
|
id: "a",
|
||||||
expect(bare.messages[0]?.toolCalls).toEqual([]);
|
text: "x",
|
||||||
|
thinking: null,
|
||||||
|
toolCallId: null,
|
||||||
|
} as Message,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(bare.messages[0]?.toolCalls).toEqual([]);
|
||||||
|
|
||||||
// subagent end without isError: ?? false
|
// subagent end without isError: ?? false
|
||||||
const sa = deriveTasks([
|
const sa = deriveTasks([
|
||||||
toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })),
|
toolStart("c1", "subagent", JSON.stringify({ agentName: "a" })),
|
||||||
ev({ type: "tool_execution_end", toolCallId: "c1" }),
|
ev({ type: "tool_execution_end", toolCallId: "c1" }),
|
||||||
]);
|
]);
|
||||||
expect(sa.subagents[0]).toMatchObject({ running: false, isError: false });
|
expect(sa.subagents[0]).toMatchObject({ running: false, isError: false });
|
||||||
|
|
||||||
// toolResult message for a call id never seen: name lookup ?? ""
|
// toolResult message for a call id never seen: name lookup ?? ""
|
||||||
const orphan = deriveTasks([
|
const orphan = deriveTasks([
|
||||||
ev({
|
ev({
|
||||||
type: "message_end",
|
type: "message_end",
|
||||||
message: msg({ id: "t", role: "toolResult", text: todoSnap([{ content: "z" }]), toolCallId: "ghost" }),
|
message: msg({
|
||||||
}),
|
id: "t",
|
||||||
]);
|
role: "toolResult",
|
||||||
expect(orphan.todos).toHaveLength(0);
|
text: todoSnap([{ content: "z" }]),
|
||||||
});
|
toolCallId: "ghost",
|
||||||
|
}),
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
expect(orphan.todos).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+25
-23
@@ -2,40 +2,42 @@ import { describe, expect, it, vi } from "vitest";
|
|||||||
import type { ReactElement } from "react";
|
import type { ReactElement } from "react";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
render: vi.fn(),
|
render: vi.fn(),
|
||||||
createRoot: vi.fn(() => ({ render: mocks.render })),
|
createRoot: vi.fn(() => ({ render: mocks.render })),
|
||||||
}));
|
}));
|
||||||
vi.mock("react-dom/client", () => ({ createRoot: mocks.createRoot }));
|
vi.mock("react-dom/client", () => ({ createRoot: mocks.createRoot }));
|
||||||
vi.mock("./App", () => ({ default: (): null => null }));
|
vi.mock("./App", () => ({ default: (): null => null }));
|
||||||
vi.mock("./index.css", () => ({}));
|
vi.mock("./index.css", () => ({}));
|
||||||
|
|
||||||
async function freshImport(): Promise<void> {
|
async function freshImport(): Promise<void> {
|
||||||
vi.resetModules();
|
vi.resetModules();
|
||||||
await import("./main");
|
await import("./main");
|
||||||
}
|
}
|
||||||
|
|
||||||
function rootElement(): HTMLElement {
|
function rootElement(): HTMLElement {
|
||||||
const el = document.createElement("div");
|
const el = document.createElement("div");
|
||||||
el.id = "root";
|
el.id = "root";
|
||||||
document.body.appendChild(el);
|
document.body.appendChild(el);
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("main", () => {
|
describe("main", () => {
|
||||||
it("renders the app into #root", async () => {
|
it("renders the app into #root", async () => {
|
||||||
const el = rootElement();
|
const el = rootElement();
|
||||||
await freshImport();
|
await freshImport();
|
||||||
expect(mocks.createRoot).toHaveBeenCalledWith(el);
|
expect(mocks.createRoot).toHaveBeenCalledWith(el);
|
||||||
expect(mocks.render).toHaveBeenCalledTimes(1);
|
expect(mocks.render).toHaveBeenCalledTimes(1);
|
||||||
const tree = mocks.render.mock.calls[0]?.[0] as { props: { children: ReactElement } };
|
const tree = mocks.render.mock.calls[0]?.[0] as {
|
||||||
expect(tree.props.children).not.toBeNull();
|
props: { children: ReactElement };
|
||||||
el.remove();
|
};
|
||||||
mocks.createRoot.mockClear();
|
expect(tree.props.children).not.toBeNull();
|
||||||
mocks.render.mockClear();
|
el.remove();
|
||||||
});
|
mocks.createRoot.mockClear();
|
||||||
|
mocks.render.mockClear();
|
||||||
|
});
|
||||||
|
|
||||||
it("throws when #root is missing", async () => {
|
it("throws when #root is missing", async () => {
|
||||||
await expect(freshImport()).rejects.toThrow("#root missing in index.html");
|
await expect(freshImport()).rejects.toThrow("#root missing in index.html");
|
||||||
expect(mocks.createRoot).not.toHaveBeenCalled();
|
expect(mocks.createRoot).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+171
-126
@@ -2,167 +2,212 @@ import { act, renderHook, waitFor } from "@testing-library/react";
|
|||||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
import { getSettings, saveSettings } from "./settings";
|
import { getSettings, saveSettings } from "./settings";
|
||||||
import { classNames, relativeTime, useSessions, useToasts } from "./store";
|
import { classNames, relativeTime, useSessions, useToasts } from "./store";
|
||||||
import { FakeWebSocket, mockFetchJson, seedSettings, stubReload } from "./test/setup";
|
import {
|
||||||
|
FakeWebSocket,
|
||||||
|
mockFetchJson,
|
||||||
|
seedSettings,
|
||||||
|
stubReload,
|
||||||
|
} from "./test/setup";
|
||||||
import type { EventFrame } from "./protocol";
|
import type { EventFrame } from "./protocol";
|
||||||
|
|
||||||
const NOW = new Date("2024-05-01T12:00:00Z").getTime();
|
const NOW = new Date("2024-05-01T12:00:00Z").getTime();
|
||||||
|
|
||||||
describe("relativeTime", () => {
|
describe("relativeTime", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
vi.setSystemTime(NOW);
|
vi.setSystemTime(NOW);
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("formats all buckets", () => {
|
it("formats all buckets", () => {
|
||||||
expect(relativeTime(null)).toBe("never");
|
expect(relativeTime(null)).toBe("never");
|
||||||
expect(relativeTime(NOW - 10_000)).toBe("just now");
|
expect(relativeTime(NOW - 10_000)).toBe("just now");
|
||||||
expect(relativeTime(NOW + 30_000)).toBe("just now");
|
expect(relativeTime(NOW + 30_000)).toBe("just now");
|
||||||
expect(relativeTime(NOW - 5 * 60_000)).toBe("5m ago");
|
expect(relativeTime(NOW - 5 * 60_000)).toBe("5m ago");
|
||||||
expect(relativeTime(NOW - 3 * 3_600_000)).toBe("3h ago");
|
expect(relativeTime(NOW - 3 * 3_600_000)).toBe("3h ago");
|
||||||
expect(relativeTime(NOW - 2 * 86_400_000)).toBe("2d ago");
|
expect(relativeTime(NOW - 2 * 86_400_000)).toBe("2d ago");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("classNames", () => {
|
describe("classNames", () => {
|
||||||
it("joins truthy parts", () => {
|
it("joins truthy parts", () => {
|
||||||
expect(classNames("a", false, "b", null, undefined, "c")).toBe("a b c");
|
expect(classNames("a", false, "b", null, undefined, "c")).toBe("a b c");
|
||||||
expect(classNames()).toBe("");
|
expect(classNames()).toBe("");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("useToasts", () => {
|
describe("useToasts", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("pushes a toast and removes it after TTL", () => {
|
it("pushes a toast and removes it after TTL", () => {
|
||||||
const { result } = renderHook(() => useToasts());
|
const { result } = renderHook(() => useToasts());
|
||||||
act(() => result.current.push("hello"));
|
act(() => result.current.push("hello"));
|
||||||
expect(result.current.toasts.map((t) => t.text)).toEqual(["hello"]);
|
expect(result.current.toasts.map((t) => t.text)).toEqual(["hello"]);
|
||||||
|
|
||||||
act(() => result.current.push("second"));
|
act(() => result.current.push("second"));
|
||||||
expect(result.current.toasts).toHaveLength(2);
|
expect(result.current.toasts).toHaveLength(2);
|
||||||
|
|
||||||
act(() => {
|
act(() => {
|
||||||
vi.advanceTimersByTime(4000);
|
vi.advanceTimersByTime(4000);
|
||||||
});
|
});
|
||||||
expect(result.current.toasts).toHaveLength(0);
|
expect(result.current.toasts).toHaveLength(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function listEvent(seq: number): EventFrame {
|
function listEvent(seq: number): EventFrame {
|
||||||
return { v: 1, sessionId: "s1", seq, ts: 0, type: "message_end" };
|
return { v: 1, sessionId: "s1", seq, ts: 0, type: "message_end" };
|
||||||
}
|
}
|
||||||
|
|
||||||
describe("useSessions", () => {
|
describe("useSessions", () => {
|
||||||
it("returns null when unconfigured", () => {
|
it("returns null when unconfigured", () => {
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
const { result } = renderHook(() => useSessions(push));
|
const { result } = renderHook(() => useSessions(push));
|
||||||
expect(result.current).toBeNull();
|
expect(result.current).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("seeds via REST, updates from session_list and spawn_status frames, refresh works", async () => {
|
it("seeds via REST, updates from session_list and spawn_status frames, refresh works", async () => {
|
||||||
seedSettings();
|
seedSettings();
|
||||||
const sessions = [{ id: "s1", name: "one", online: true }];
|
const sessions = [{ id: "s1", name: "one", online: true }];
|
||||||
const fetchMock = mockFetchJson((url) => {
|
const fetchMock = mockFetchJson((url) => {
|
||||||
if (url.includes("/api/sessions")) return url.includes("/events") ? [] : sessions;
|
if (url.includes("/api/sessions"))
|
||||||
return [];
|
return url.includes("/events") ? [] : sessions;
|
||||||
});
|
return [];
|
||||||
const push = vi.fn();
|
});
|
||||||
const { result } = renderHook(() => useSessions(push));
|
const push = vi.fn();
|
||||||
|
const { result } = renderHook(() => useSessions(push));
|
||||||
|
|
||||||
expect(result.current?.state).toBe("connecting");
|
expect(result.current?.state).toBe("connecting");
|
||||||
await waitFor(() => expect(result.current?.sessions).toEqual(sessions));
|
await waitFor(() => expect(result.current?.sessions).toEqual(sessions));
|
||||||
|
|
||||||
const sock = FakeWebSocket.last();
|
const sock = FakeWebSocket.last();
|
||||||
act(() => sock.serverOpen());
|
act(() => sock.serverOpen());
|
||||||
expect(result.current?.state).toBe("open");
|
expect(result.current?.state).toBe("open");
|
||||||
|
|
||||||
act(() => sock.serverMessage({ type: "session_list", sessions: [{ id: "s2", name: "two", online: false }] }));
|
act(() =>
|
||||||
expect(result.current?.sessions.map((s) => s.id)).toEqual(["s2"]);
|
sock.serverMessage({
|
||||||
|
type: "session_list",
|
||||||
|
sessions: [{ id: "s2", name: "two", online: false }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current?.sessions.map((s) => s.id)).toEqual(["s2"]);
|
||||||
|
|
||||||
act(() => sock.serverMessage({ type: "spawn_status", jobs: [{ repo: "g/p", state: "cloning" }] }));
|
act(() =>
|
||||||
expect(result.current?.spawnJobs).toEqual([{ repo: "g/p", state: "cloning" }]);
|
sock.serverMessage({
|
||||||
|
type: "spawn_status",
|
||||||
|
jobs: [{ repo: "g/p", state: "cloning" }],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(result.current?.spawnJobs).toEqual([
|
||||||
|
{ repo: "g/p", state: "cloning" },
|
||||||
|
]);
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await result.current?.refresh();
|
await result.current?.refresh();
|
||||||
});
|
});
|
||||||
expect(fetchMock).toHaveBeenCalled();
|
expect(fetchMock).toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("refresh failure pushes a toast", async () => {
|
it("refresh failure pushes a toast", async () => {
|
||||||
seedSettings();
|
seedSettings();
|
||||||
mockFetchJson(() => {
|
mockFetchJson(() => {
|
||||||
throw new Error("network down");
|
throw new Error("network down");
|
||||||
});
|
});
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
const { result } = renderHook(() => useSessions(push));
|
const { result } = renderHook(() => useSessions(push));
|
||||||
await waitFor(() => expect(push).toHaveBeenCalledWith("sessions: network down"));
|
await waitFor(() =>
|
||||||
expect(result.current).not.toBeNull();
|
expect(push).toHaveBeenCalledWith("sessions: network down"),
|
||||||
});
|
);
|
||||||
|
expect(result.current).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
it("subscribe routes events frames for the session and detaches on unsubscribe", async () => {
|
it("subscribe routes events frames for the session and detaches on unsubscribe", async () => {
|
||||||
seedSettings();
|
seedSettings();
|
||||||
mockFetchJson(() => []);
|
mockFetchJson(() => []);
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
const { result } = renderHook(() => useSessions(push));
|
const { result } = renderHook(() => useSessions(push));
|
||||||
const sock = FakeWebSocket.last();
|
const sock = FakeWebSocket.last();
|
||||||
act(() => sock.serverOpen());
|
act(() => sock.serverOpen());
|
||||||
|
|
||||||
const seen: EventFrame[][] = [];
|
const seen: EventFrame[][] = [];
|
||||||
let off: (() => void) | undefined;
|
let off: (() => void) | undefined;
|
||||||
act(() => {
|
act(() => {
|
||||||
off = result.current?.subscribe("s1", (events) => seen.push(events));
|
off = result.current?.subscribe("s1", (events) => seen.push(events));
|
||||||
});
|
});
|
||||||
expect(sock.sent).toContain(JSON.stringify({ type: "subscribe", sessionId: "s1" }));
|
expect(sock.sent).toContain(
|
||||||
|
JSON.stringify({ type: "subscribe", sessionId: "s1" }),
|
||||||
|
);
|
||||||
|
|
||||||
act(() => sock.serverMessage({ type: "events", sessionId: "s1", after: 0, events: [listEvent(1)] }));
|
act(() =>
|
||||||
act(() => sock.serverMessage({ type: "events", sessionId: "other", after: 0, events: [listEvent(2)] }));
|
sock.serverMessage({
|
||||||
expect(seen).toEqual([[listEvent(1)]]);
|
type: "events",
|
||||||
|
sessionId: "s1",
|
||||||
|
after: 0,
|
||||||
|
events: [listEvent(1)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
act(() =>
|
||||||
|
sock.serverMessage({
|
||||||
|
type: "events",
|
||||||
|
sessionId: "other",
|
||||||
|
after: 0,
|
||||||
|
events: [listEvent(2)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(seen).toEqual([[listEvent(1)]]);
|
||||||
|
|
||||||
act(() => off?.());
|
act(() => off?.());
|
||||||
act(() => sock.serverMessage({ type: "events", sessionId: "s1", after: 1, events: [listEvent(3)] }));
|
act(() =>
|
||||||
expect(seen).toHaveLength(1);
|
sock.serverMessage({
|
||||||
expect(sock.sent).toContain(JSON.stringify({ type: "unsubscribe", sessionId: "s1" }));
|
type: "events",
|
||||||
});
|
sessionId: "s1",
|
||||||
|
after: 1,
|
||||||
|
events: [listEvent(3)],
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
expect(seen).toHaveLength(1);
|
||||||
|
expect(sock.sent).toContain(
|
||||||
|
JSON.stringify({ type: "unsubscribe", sessionId: "s1" }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
it("auth failure clears settings and reloads", async () => {
|
it("auth failure clears settings and reloads", async () => {
|
||||||
seedSettings();
|
seedSettings();
|
||||||
mockFetchJson(() => []);
|
mockFetchJson(() => []);
|
||||||
const loc = stubReload();
|
const loc = stubReload();
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
const { result } = renderHook(() => useSessions(push));
|
const { result } = renderHook(() => useSessions(push));
|
||||||
const sock = FakeWebSocket.last();
|
const sock = FakeWebSocket.last();
|
||||||
act(() => sock.serverOpen());
|
act(() => sock.serverOpen());
|
||||||
act(() => sock.serverClose(1008));
|
act(() => sock.serverClose(1008));
|
||||||
expect(loc.reload).toHaveBeenCalled();
|
expect(loc.reload).toHaveBeenCalled();
|
||||||
expect(getSettings()).toBeNull();
|
expect(getSettings()).toBeNull();
|
||||||
await waitFor(() => expect(result.current).toBeNull());
|
await waitFor(() => expect(result.current).toBeNull());
|
||||||
loc.restore();
|
loc.restore();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("unmount closes the manager", async () => {
|
it("unmount closes the manager", async () => {
|
||||||
seedSettings();
|
seedSettings();
|
||||||
mockFetchJson(() => []);
|
mockFetchJson(() => []);
|
||||||
const push = vi.fn();
|
const push = vi.fn();
|
||||||
const { unmount } = renderHook(() => useSessions(push));
|
const { unmount } = renderHook(() => useSessions(push));
|
||||||
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
await waitFor(() => expect(FakeWebSocket.instances.length).toBe(1));
|
||||||
const sock = FakeWebSocket.last();
|
const sock = FakeWebSocket.last();
|
||||||
unmount();
|
unmount();
|
||||||
expect(sock.closeCode).toBe(4900);
|
expect(sock.closeCode).toBe(4900);
|
||||||
expect(sock.onclose).toBeNull();
|
expect(sock.onclose).toBeNull();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("settings persistence used by the store", () => {
|
describe("settings persistence used by the store", () => {
|
||||||
it("saved settings are visible", () => {
|
it("saved settings are visible", () => {
|
||||||
saveSettings({ serverUrl: "http://a", token: "t" });
|
saveSettings({ serverUrl: "http://a", token: "t" });
|
||||||
expect(getSettings()?.serverUrl).toBe("http://a");
|
expect(getSettings()?.serverUrl).toBe("http://a");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+102
-83
@@ -1,5 +1,10 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { EventFrame, ServerFrame, SessionListItem, SpawnJob } from "./protocol";
|
import type {
|
||||||
|
EventFrame,
|
||||||
|
ServerFrame,
|
||||||
|
SessionListItem,
|
||||||
|
SpawnJob,
|
||||||
|
} from "./protocol";
|
||||||
import { Route } from "./protocol";
|
import { Route } from "./protocol";
|
||||||
import { buildWsUrl, getSettings, clearSettings } from "./settings";
|
import { buildWsUrl, getSettings, clearSettings } from "./settings";
|
||||||
import { createWsManager, type WsManager, type WsState } from "./ws";
|
import { createWsManager, type WsManager, type WsState } from "./ws";
|
||||||
@@ -12,115 +17,129 @@ const HOUR_MS: number = 60 * MINUTE_MS;
|
|||||||
const DAY_MS: number = 24 * HOUR_MS;
|
const DAY_MS: number = 24 * HOUR_MS;
|
||||||
|
|
||||||
export function relativeTime(ts: number | null): string {
|
export function relativeTime(ts: number | null): string {
|
||||||
if (ts === null) return "never";
|
if (ts === null) return "never";
|
||||||
const abs: number = Math.abs(Date.now() - ts);
|
const abs: number = Math.abs(Date.now() - ts);
|
||||||
if (abs < MINUTE_MS) return "just now";
|
if (abs < MINUTE_MS) return "just now";
|
||||||
if (abs < HOUR_MS) return `${Math.floor(abs / MINUTE_MS)}m ago`;
|
if (abs < HOUR_MS) return `${Math.floor(abs / MINUTE_MS)}m ago`;
|
||||||
if (abs < DAY_MS) return `${Math.floor(abs / HOUR_MS)}h ago`;
|
if (abs < DAY_MS) return `${Math.floor(abs / HOUR_MS)}h ago`;
|
||||||
return `${Math.floor(abs / DAY_MS)}d ago`;
|
return `${Math.floor(abs / DAY_MS)}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function classNames(...parts: Array<string | false | null | undefined>): string {
|
export function classNames(
|
||||||
return parts.filter(Boolean).join(" ");
|
...parts: Array<string | false | null | undefined>
|
||||||
|
): string {
|
||||||
|
return parts.filter(Boolean).join(" ");
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- toasts ----------
|
// ---------- toasts ----------
|
||||||
|
|
||||||
export interface Toast {
|
export interface Toast {
|
||||||
id: number;
|
id: number;
|
||||||
text: string;
|
text: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
let toastSeq: number = 0;
|
let toastSeq: number = 0;
|
||||||
const TOAST_TTL_MS: number = 4000;
|
const TOAST_TTL_MS: number = 4000;
|
||||||
|
|
||||||
export function useToasts(): { toasts: Toast[]; push: (text: string) => void } {
|
export function useToasts(): { toasts: Toast[]; push: (text: string) => void } {
|
||||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||||
const push = useCallback((text: string): void => {
|
const push = useCallback((text: string): void => {
|
||||||
const id: number = ++toastSeq;
|
const id: number = ++toastSeq;
|
||||||
setToasts((prev) => [...prev, { id, text }]);
|
setToasts((prev) => [...prev, { id, text }]);
|
||||||
window.setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), TOAST_TTL_MS);
|
window.setTimeout(
|
||||||
}, []);
|
() => setToasts((prev) => prev.filter((t) => t.id !== id)),
|
||||||
return { toasts, push };
|
TOAST_TTL_MS,
|
||||||
|
);
|
||||||
|
}, []);
|
||||||
|
return { toasts, push };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- sessions store (REST seed + WS updates) ----------
|
// ---------- sessions store (REST seed + WS updates) ----------
|
||||||
|
|
||||||
export interface SessionsStore {
|
export interface SessionsStore {
|
||||||
sessions: SessionListItem[];
|
sessions: SessionListItem[];
|
||||||
state: WsState;
|
state: WsState;
|
||||||
spawnJobs: SpawnJob[];
|
spawnJobs: SpawnJob[];
|
||||||
refresh: () => Promise<void>;
|
refresh: () => Promise<void>;
|
||||||
/** Subscribe to one session's event stream (protocol allows one at a time). */
|
/** Subscribe to one session's event stream (protocol allows one at a time). */
|
||||||
subscribe: (sessionId: string, onEvents: (events: EventFrame[]) => void) => () => void;
|
subscribe: (
|
||||||
|
sessionId: string,
|
||||||
|
onEvents: (events: EventFrame[]) => void,
|
||||||
|
) => () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSessions(pushToast: (text: string) => void): SessionsStore | null {
|
export function useSessions(
|
||||||
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
pushToast: (text: string) => void,
|
||||||
const [spawnJobs, setSpawnJobs] = useState<SpawnJob[]>([]);
|
): SessionsStore | null {
|
||||||
const [state, setState] = useState<WsState>("connecting");
|
const [sessions, setSessions] = useState<SessionListItem[]>([]);
|
||||||
const [manager, setManager] = useState<WsManager | null>(null);
|
const [spawnJobs, setSpawnJobs] = useState<SpawnJob[]>([]);
|
||||||
|
const [state, setState] = useState<WsState>("connecting");
|
||||||
|
const [manager, setManager] = useState<WsManager | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const settings = getSettings();
|
const settings = getSettings();
|
||||||
if (settings === null) return;
|
if (settings === null) return;
|
||||||
|
|
||||||
const m = createWsManager(buildWsUrl(settings));
|
const m = createWsManager(buildWsUrl(settings));
|
||||||
setManager(m);
|
setManager(m);
|
||||||
|
|
||||||
const offState = m.onState(setState);
|
const offState = m.onState(setState);
|
||||||
const offFrames = m.onFrame((frame: ServerFrame) => {
|
const offFrames = m.onFrame((frame: ServerFrame) => {
|
||||||
if (frame.type === "session_list") setSessions(frame.sessions);
|
if (frame.type === "session_list") setSessions(frame.sessions);
|
||||||
else if (frame.type === "spawn_status") setSpawnJobs(frame.jobs);
|
else if (frame.type === "spawn_status") setSpawnJobs(frame.jobs);
|
||||||
});
|
});
|
||||||
m.onAuthError(() => {
|
m.onAuthError(() => {
|
||||||
clearSettings();
|
clearSettings();
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
const refresh = async (): Promise<void> => {
|
const refresh = async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
|
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pushToast(`sessions: ${errMessage(err)}`);
|
pushToast(`sessions: ${errMessage(err)}`);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
void refresh();
|
void refresh();
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
offState();
|
offState();
|
||||||
offFrames();
|
offFrames();
|
||||||
m.close();
|
m.close();
|
||||||
setManager(null);
|
setManager(null);
|
||||||
};
|
};
|
||||||
}, [pushToast]);
|
}, [pushToast]);
|
||||||
|
|
||||||
const refresh = useCallback(async (): Promise<void> => {
|
const refresh = useCallback(async (): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
|
setSessions(await fetchJson<SessionListItem[]>(Route.Sessions));
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
pushToast(`sessions: ${errMessage(err)}`);
|
pushToast(`sessions: ${errMessage(err)}`);
|
||||||
}
|
}
|
||||||
}, [pushToast]);
|
}, [pushToast]);
|
||||||
|
|
||||||
const subscribe = useCallback(
|
const subscribe = useCallback(
|
||||||
(sessionId: string, onEvents: (events: EventFrame[]) => void): (() => void) => {
|
(
|
||||||
if (manager === null) return () => undefined;
|
sessionId: string,
|
||||||
const off = manager.onFrame((frame: ServerFrame) => {
|
onEvents: (events: EventFrame[]) => void,
|
||||||
if (frame.type === "events" && frame.sessionId === sessionId) onEvents(frame.events);
|
): (() => void) => {
|
||||||
});
|
if (manager === null) return () => undefined;
|
||||||
manager.subscribe(sessionId);
|
const off = manager.onFrame((frame: ServerFrame) => {
|
||||||
return () => {
|
if (frame.type === "events" && frame.sessionId === sessionId)
|
||||||
manager.unsubscribe(sessionId);
|
onEvents(frame.events);
|
||||||
off();
|
});
|
||||||
};
|
manager.subscribe(sessionId);
|
||||||
},
|
return () => {
|
||||||
[manager]
|
manager.unsubscribe(sessionId);
|
||||||
);
|
off();
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[manager],
|
||||||
|
);
|
||||||
|
|
||||||
// hooks above must all run before any early return: clearing settings
|
// hooks above must all run before any early return: clearing settings
|
||||||
// mid-flight (ws auth failure) must not change the hook order on re-render
|
// mid-flight (ws auth failure) must not change the hook order on re-render
|
||||||
if (getSettings() === null) return null;
|
if (getSettings() === null) return null;
|
||||||
|
|
||||||
return { sessions, state, spawnJobs, refresh, subscribe };
|
return { sessions, state, spawnJobs, refresh, subscribe };
|
||||||
}
|
}
|
||||||
|
|||||||
+119
-96
@@ -7,59 +7,59 @@ import type { MockInstance } from "vitest";
|
|||||||
export type WsHandler = ((ev: never) => void) | null;
|
export type WsHandler = ((ev: never) => void) | null;
|
||||||
|
|
||||||
export class FakeWebSocket {
|
export class FakeWebSocket {
|
||||||
static readonly CONNECTING = 0;
|
static readonly CONNECTING = 0;
|
||||||
static readonly OPEN = 1;
|
static readonly OPEN = 1;
|
||||||
static readonly CLOSING = 2;
|
static readonly CLOSING = 2;
|
||||||
static readonly CLOSED = 3;
|
static readonly CLOSED = 3;
|
||||||
static instances: FakeWebSocket[] = [];
|
static instances: FakeWebSocket[] = [];
|
||||||
|
|
||||||
readonly url: string;
|
readonly url: string;
|
||||||
readyState: number = FakeWebSocket.CONNECTING;
|
readyState: number = FakeWebSocket.CONNECTING;
|
||||||
closeCode: number | null = null;
|
closeCode: number | null = null;
|
||||||
sent: string[] = [];
|
sent: string[] = [];
|
||||||
onopen: ((ev: Event) => void) | null = null;
|
onopen: ((ev: Event) => void) | null = null;
|
||||||
onmessage: ((ev: MessageEvent) => void) | null = null;
|
onmessage: ((ev: MessageEvent) => void) | null = null;
|
||||||
onclose: ((ev: CloseEvent) => void) | null = null;
|
onclose: ((ev: CloseEvent) => void) | null = null;
|
||||||
onerror: ((ev: Event) => void) | null = null;
|
onerror: ((ev: Event) => void) | null = null;
|
||||||
|
|
||||||
constructor(url: string) {
|
constructor(url: string) {
|
||||||
this.url = url;
|
this.url = url;
|
||||||
FakeWebSocket.instances.push(this);
|
FakeWebSocket.instances.push(this);
|
||||||
}
|
}
|
||||||
|
|
||||||
send(data: string): void {
|
send(data: string): void {
|
||||||
this.sent.push(data);
|
this.sent.push(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
close(code = 1000): void {
|
close(code = 1000): void {
|
||||||
this.readyState = FakeWebSocket.CLOSED;
|
this.readyState = FakeWebSocket.CLOSED;
|
||||||
this.closeCode = code;
|
this.closeCode = code;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---- test-side server simulation ----
|
// ---- test-side server simulation ----
|
||||||
serverOpen(): void {
|
serverOpen(): void {
|
||||||
this.readyState = FakeWebSocket.OPEN;
|
this.readyState = FakeWebSocket.OPEN;
|
||||||
this.onopen?.(new Event("open"));
|
this.onopen?.(new Event("open"));
|
||||||
}
|
}
|
||||||
|
|
||||||
serverMessage(data: unknown): void {
|
serverMessage(data: unknown): void {
|
||||||
this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent);
|
this.onmessage?.({ data: JSON.stringify(data) } as MessageEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
serverClose(code = 1006): void {
|
serverClose(code = 1006): void {
|
||||||
this.readyState = FakeWebSocket.CLOSED;
|
this.readyState = FakeWebSocket.CLOSED;
|
||||||
this.onclose?.({ code } as CloseEvent);
|
this.onclose?.({ code } as CloseEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
static reset(): void {
|
static reset(): void {
|
||||||
FakeWebSocket.instances = [];
|
FakeWebSocket.instances = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static last(): FakeWebSocket {
|
static last(): FakeWebSocket {
|
||||||
const inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
|
const inst = FakeWebSocket.instances[FakeWebSocket.instances.length - 1];
|
||||||
if (inst === undefined) throw new Error("no FakeWebSocket instance");
|
if (inst === undefined) throw new Error("no FakeWebSocket instance");
|
||||||
return inst;
|
return inst;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
||||||
@@ -68,89 +68,112 @@ window.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
|
|||||||
// ---------- fetch mocking helpers ----------
|
// ---------- fetch mocking helpers ----------
|
||||||
|
|
||||||
export function jsonResponse(body: unknown, status = 200): Response {
|
export function jsonResponse(body: unknown, status = 200): Response {
|
||||||
return {
|
return {
|
||||||
ok: status >= 200 && status < 300,
|
ok: status >= 200 && status < 300,
|
||||||
status,
|
status,
|
||||||
statusText: "StatusText",
|
statusText: "StatusText",
|
||||||
json: async () => body,
|
json: async () => body,
|
||||||
} as unknown as Response;
|
} as unknown as Response;
|
||||||
}
|
}
|
||||||
|
|
||||||
type FetchHandler = (url: string, init?: RequestInit) => unknown;
|
type FetchHandler = (url: string, init?: RequestInit) => unknown;
|
||||||
|
|
||||||
export function mockFetchJson(handler: FetchHandler): MockInstance {
|
export function mockFetchJson(handler: FetchHandler): MockInstance {
|
||||||
return vi.spyOn(globalThis, "fetch").mockImplementation((async (input: RequestInfo | URL, init?: RequestInit) => {
|
return vi.spyOn(globalThis, "fetch").mockImplementation((async (
|
||||||
const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
|
input: RequestInfo | URL,
|
||||||
const out = handler(url, init);
|
init?: RequestInit,
|
||||||
return isResponseLike(out) ? out : jsonResponse(out);
|
) => {
|
||||||
}) as typeof fetch);
|
const url =
|
||||||
|
typeof input === "string"
|
||||||
|
? input
|
||||||
|
: input instanceof URL
|
||||||
|
? input.href
|
||||||
|
: input.url;
|
||||||
|
const out = handler(url, init);
|
||||||
|
return isResponseLike(out) ? out : jsonResponse(out);
|
||||||
|
}) as typeof fetch);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isResponseLike(value: unknown): value is Response {
|
function isResponseLike(value: unknown): value is Response {
|
||||||
return (
|
return (
|
||||||
value !== null &&
|
value !== null &&
|
||||||
typeof value === "object" &&
|
typeof value === "object" &&
|
||||||
typeof (value as { json?: unknown }).json === "function" &&
|
typeof (value as { json?: unknown }).json === "function" &&
|
||||||
"ok" in (value as object)
|
"ok" in (value as object)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- settings seed ----------
|
// ---------- settings seed ----------
|
||||||
|
|
||||||
export function seedSettings(serverUrl = "http://srv", token = "tok"): void {
|
export function seedSettings(serverUrl = "http://srv", token = "tok"): void {
|
||||||
localStorage.setItem("lvmh.settings", JSON.stringify({ serverUrl, token }));
|
localStorage.setItem("lvmh.settings", JSON.stringify({ serverUrl, token }));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------- window.location.reload stub ----------
|
// ---------- window.location.reload stub ----------
|
||||||
|
|
||||||
export function stubReload(): { reload: ReturnType<typeof vi.fn>; restore: () => void } {
|
export function stubReload(): {
|
||||||
const original = window.location;
|
reload: ReturnType<typeof vi.fn>;
|
||||||
const reload = vi.fn();
|
restore: () => void;
|
||||||
Object.defineProperty(window, "location", { value: { reload }, writable: true, configurable: true });
|
} {
|
||||||
return {
|
const original = window.location;
|
||||||
reload,
|
const reload = vi.fn();
|
||||||
restore: (): void => {
|
Object.defineProperty(window, "location", {
|
||||||
Object.defineProperty(window, "location", { value: original, writable: true, configurable: true });
|
value: { reload },
|
||||||
},
|
writable: true,
|
||||||
};
|
configurable: true,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
reload,
|
||||||
|
restore: (): void => {
|
||||||
|
Object.defineProperty(window, "location", {
|
||||||
|
value: original,
|
||||||
|
writable: true,
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// Node's experimental localStorage getter (returns undefined without
|
// Node's experimental localStorage getter (returns undefined without
|
||||||
// --localstorage-file) shadows jsdom's under vitest; replace it with a plain
|
// --localstorage-file) shadows jsdom's under vitest; replace it with a plain
|
||||||
// in-memory Storage so src modules see a working global.
|
// in-memory Storage so src modules see a working global.
|
||||||
class MemoryStorage implements Storage {
|
class MemoryStorage implements Storage {
|
||||||
private readonly map = new Map<string, string>();
|
private readonly map = new Map<string, string>();
|
||||||
get length(): number {
|
get length(): number {
|
||||||
return this.map.size;
|
return this.map.size;
|
||||||
}
|
}
|
||||||
key(index: number): string | null {
|
key(index: number): string | null {
|
||||||
return Array.from(this.map.keys())[index] ?? null;
|
return Array.from(this.map.keys())[index] ?? null;
|
||||||
}
|
}
|
||||||
getItem(key: string): string | null {
|
getItem(key: string): string | null {
|
||||||
return this.map.get(key) ?? null;
|
return this.map.get(key) ?? null;
|
||||||
}
|
}
|
||||||
setItem(key: string, value: string): void {
|
setItem(key: string, value: string): void {
|
||||||
this.map.set(String(key), String(value));
|
this.map.set(String(key), String(value));
|
||||||
}
|
}
|
||||||
removeItem(key: string): void {
|
removeItem(key: string): void {
|
||||||
this.map.delete(String(key));
|
this.map.delete(String(key));
|
||||||
}
|
}
|
||||||
clear(): void {
|
clear(): void {
|
||||||
this.map.clear();
|
this.map.clear();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const memoryStorage = new MemoryStorage();
|
const memoryStorage = new MemoryStorage();
|
||||||
Object.defineProperty(globalThis, "localStorage", { configurable: true, writable: true, value: memoryStorage });
|
Object.defineProperty(globalThis, "localStorage", {
|
||||||
|
configurable: true,
|
||||||
|
writable: true,
|
||||||
|
value: memoryStorage,
|
||||||
|
});
|
||||||
|
|
||||||
// ---------- global hygiene ----------
|
// ---------- global hygiene ----------
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
FakeWebSocket.reset();
|
FakeWebSocket.reset();
|
||||||
memoryStorage.clear();
|
memoryStorage.clear();
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user