391 lines
15 KiB
TypeScript
391 lines
15 KiB
TypeScript
// @vitest-environment happy-dom
|
||
import { describe, expect, it, vi } from "vitest";
|
||
|
||
import {
|
||
branchFromWorktreePath,
|
||
buildTranscriptRow,
|
||
renderInlineMarkdown,
|
||
renderRail,
|
||
} from "./structuredRender.js";
|
||
import type { Session, TranscriptRow } from "./types.js";
|
||
|
||
function row<K extends TranscriptRow["kind"]>(
|
||
kind: K,
|
||
data: unknown,
|
||
id = "r1",
|
||
): TranscriptRow & { kind: K } {
|
||
return { id, kind, data };
|
||
}
|
||
|
||
describe("buildTranscriptRow: user/agent/thought", () => {
|
||
it("renders a user row with the text content and no markdown", () => {
|
||
const el = buildTranscriptRow(
|
||
row("user", { content: { type: "text", text: "hi <b>there</b>" } }),
|
||
);
|
||
expect(el.className).toContain("st-row-user");
|
||
expect(el.textContent).toContain("hi <b>there</b>");
|
||
// never actual markup from user input
|
||
expect(el.querySelector("b")).toBeNull();
|
||
});
|
||
|
||
it("renders agent prose as paragraphs with inline markdown", () => {
|
||
const el = buildTranscriptRow(
|
||
row("agent_text", { content: { type: "text", text: "one **two** `three`\n\nfour" } }),
|
||
);
|
||
expect(el.className).toContain("st-row-agent");
|
||
const ps = el.querySelectorAll("p");
|
||
expect(ps).toHaveLength(2);
|
||
expect(ps[0].innerHTML).toContain("<strong>two</strong>");
|
||
expect(ps[0].innerHTML).toContain("<code>three</code>");
|
||
});
|
||
|
||
it("renders a thought row as plain text", () => {
|
||
const el = buildTranscriptRow(row("agent_thought", { content: { type: "text", text: "hmm" } }));
|
||
expect(el.className).toContain("st-row-thought");
|
||
expect(el.textContent).toBe("hmm");
|
||
});
|
||
|
||
it("falls back to a labelled placeholder for non-text content, never crashing", () => {
|
||
const el = buildTranscriptRow(row("user", { content: { type: "image" } }));
|
||
expect(el.textContent).toContain("[image]");
|
||
});
|
||
|
||
it("never clips text: a very long user message is not truncated", () => {
|
||
const long = "x".repeat(5000);
|
||
const el = buildTranscriptRow(row("user", { content: { type: "text", text: long } }));
|
||
expect(el.textContent).toContain(long);
|
||
});
|
||
});
|
||
|
||
describe("renderInlineMarkdown", () => {
|
||
it("escapes raw HTML before applying markdown", () => {
|
||
expect(renderInlineMarkdown("<script>alert(1)</script>")).not.toContain("<script>");
|
||
});
|
||
|
||
it("does not treat a single asterisk pair meant as bold as nested italics", () => {
|
||
expect(renderInlineMarkdown("**bold**")).toBe("<strong>bold</strong>");
|
||
});
|
||
});
|
||
|
||
describe("buildTranscriptRow: tool calls", () => {
|
||
it("shows glyph, name, argument and no meta for a completed non-diff call", () => {
|
||
const el = buildTranscriptRow(
|
||
row("tool_call", {
|
||
toolCallId: "t1",
|
||
title: "Read(src/lib.rs)",
|
||
kind: "read",
|
||
status: "completed",
|
||
}),
|
||
);
|
||
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
|
||
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Read");
|
||
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("src/lib.rs");
|
||
expect(head.querySelector(".st-tool-meta")).toBeNull();
|
||
});
|
||
|
||
it("falls back to the kind label and full title when the title has no Name(arg) shape", () => {
|
||
const el = buildTranscriptRow(row("tool_call", { title: "does something odd", kind: "execute" }));
|
||
const head = el.querySelector(".st-tool-head")!;
|
||
expect(head.querySelector(".st-tool-name")!.textContent).toBe("Execute");
|
||
expect(head.querySelector(".st-tool-arg")!.textContent).toBe("does something odd");
|
||
});
|
||
|
||
it("shows a running badge for an in-progress call", () => {
|
||
const el = buildTranscriptRow(row("tool_call", { title: "Bash(sleep 1)", status: "in_progress" }));
|
||
const meta = el.querySelector(".st-tool-meta")!;
|
||
expect(meta.textContent).toBe("running");
|
||
expect(meta.className).toContain("st-tool-meta-running");
|
||
});
|
||
|
||
it("shows a failed badge for a failed call", () => {
|
||
const el = buildTranscriptRow(row("tool_call", { title: "Bash(false)", status: "failed" }));
|
||
expect(el.querySelector(".st-tool-meta")!.textContent).toBe("failed");
|
||
});
|
||
|
||
it("starts collapsed and expands its text output on click", () => {
|
||
const el = buildTranscriptRow(
|
||
row("tool_call", {
|
||
title: "Grep(x)",
|
||
status: "completed",
|
||
content: [{ type: "content", content: { type: "text", text: "match found" } }],
|
||
}),
|
||
);
|
||
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
|
||
const out = el.querySelector(".st-tool-out") as HTMLElement;
|
||
expect(out.hidden).toBe(true);
|
||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||
head.click();
|
||
expect(out.hidden).toBe(false);
|
||
expect(head.getAttribute("aria-expanded")).toBe("true");
|
||
expect(out.textContent).toContain("match found");
|
||
head.click();
|
||
expect(out.hidden).toBe(true);
|
||
});
|
||
|
||
it("does not expand when there is nothing to show", () => {
|
||
const el = buildTranscriptRow(row("tool_call", { title: "Read(x)", status: "completed" }));
|
||
const head = el.querySelector(".st-tool-head") as HTMLButtonElement;
|
||
head.click();
|
||
expect(head.getAttribute("aria-expanded")).toBe("false");
|
||
});
|
||
});
|
||
|
||
describe("buildTranscriptRow: tool diffs", () => {
|
||
it("shows +added -removed derived from the diff, and renders coloured hunks on expand", () => {
|
||
const el = buildTranscriptRow(
|
||
row("tool_diff", {
|
||
title: "Edit(x.rs)",
|
||
status: "completed",
|
||
content: [{ type: "diff", path: "/repo/x.rs", oldText: "a\nb\n", newText: "a\nc\n" }],
|
||
}),
|
||
);
|
||
const meta = el.querySelector(".st-tool-meta")!;
|
||
expect(meta.textContent).toContain("+1");
|
||
expect(meta.textContent).toContain("−1");
|
||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||
const diff = el.querySelector(".st-diff") as HTMLElement;
|
||
expect(diff).not.toBeNull();
|
||
expect(diff.querySelector(".st-ln-add")!.textContent).toBe("c");
|
||
expect(diff.querySelector(".st-ln-del")!.textContent).toBe("b");
|
||
});
|
||
|
||
it("gives the diff its own horizontally-scrolling container", () => {
|
||
const el = buildTranscriptRow(
|
||
row("tool_diff", {
|
||
title: "Edit(x.rs)",
|
||
content: [{ type: "diff", path: "/repo/x.rs", newText: "new file\n" }],
|
||
}),
|
||
);
|
||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||
// jsdom/happy-dom doesn't compute layout CSS, so assert the class the
|
||
// stylesheet keys `overflow-x: auto` off, not a computed style.
|
||
expect(el.querySelector(".st-diff")!.className).toBe("st-diff");
|
||
});
|
||
|
||
it("renders a visible marker for a removed final newline", () => {
|
||
const el = buildTranscriptRow(
|
||
row("tool_diff", {
|
||
title: "Edit(x.rs)",
|
||
content: [{ type: "diff", path: "/repo/x.rs", oldText: "same\n", newText: "same" }],
|
||
}),
|
||
);
|
||
(el.querySelector(".st-tool-head") as HTMLButtonElement).click();
|
||
expect(el.querySelector(".st-ln-eof")!.textContent).toBe("\\ No newline at end of file");
|
||
});
|
||
});
|
||
|
||
describe("buildTranscriptRow: approval", () => {
|
||
const approvalRow = (options: Array<{ optionId: string; name: string; kind: string }>) =>
|
||
row("approval", {
|
||
sessionId: "s1",
|
||
toolCall: { toolCallId: "t1", title: "Write outside worktree", locations: [{ path: "/etc/x" }] },
|
||
options,
|
||
});
|
||
|
||
const allThreeOptions = [
|
||
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
|
||
{ optionId: "a2", name: "Allow always", kind: "allow_always" },
|
||
{ optionId: "r1", name: "Reject once", kind: "reject_once" },
|
||
];
|
||
|
||
it("shows the three decision buttons only when the request offers all three option kinds", () => {
|
||
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
||
pending: true,
|
||
onDecide: () => Promise.resolve(),
|
||
});
|
||
const buttons = el.querySelectorAll(".st-approval-acts button");
|
||
expect(buttons).toHaveLength(3);
|
||
expect(el.querySelector(".st-approval-path")!.textContent).toBe("/etc/x");
|
||
});
|
||
|
||
it("hides the buttons and shows an answered note once resolved", () => {
|
||
const el = buildTranscriptRow(approvalRow(allThreeOptions), {
|
||
pending: false,
|
||
onDecide: () => Promise.resolve(),
|
||
});
|
||
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
||
expect(el.querySelector(".st-approval-resolved")!.textContent).toBe("answered");
|
||
});
|
||
|
||
it("invokes onDecide with the clicked decision and disables the buttons", () => {
|
||
const onDecide = vi.fn().mockResolvedValue(undefined);
|
||
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
|
||
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||
buttons[2].click();
|
||
expect(onDecide).toHaveBeenCalledWith("deny");
|
||
expect(buttons[0].disabled).toBe(true);
|
||
});
|
||
|
||
it("treats a row with no approval state as not pending, defensively", () => {
|
||
const el = buildTranscriptRow(approvalRow(allThreeOptions));
|
||
expect(el.querySelector(".st-approval-acts")).toBeNull();
|
||
});
|
||
|
||
it("omits Allow for session when the request offers no allow_always option", () => {
|
||
const el = buildTranscriptRow(
|
||
approvalRow([
|
||
{ optionId: "a1", name: "Allow once", kind: "allow_once" },
|
||
{ optionId: "r1", name: "Reject once", kind: "reject_once" },
|
||
]),
|
||
{ pending: true, onDecide: () => Promise.resolve() },
|
||
);
|
||
const labels = [...el.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent);
|
||
expect(labels).toEqual(["Allow once", "Deny"]);
|
||
});
|
||
|
||
it("offers Deny for either reject_once or reject_always, never for neither", () => {
|
||
const rejectAlwaysOnly = buildTranscriptRow(
|
||
approvalRow([{ optionId: "r2", name: "Reject always", kind: "reject_always" }]),
|
||
{ pending: true, onDecide: () => Promise.resolve() },
|
||
);
|
||
expect([...rejectAlwaysOnly.querySelectorAll(".st-approval-acts button")].map((b) => b.textContent)).toEqual([
|
||
"Deny",
|
||
]);
|
||
});
|
||
|
||
it("shows a 'no decision offered' note instead of an empty action row when nothing matches", () => {
|
||
const el = buildTranscriptRow(approvalRow([{ optionId: "x", name: "Something else", kind: "allow_once" }]), {
|
||
pending: true,
|
||
onDecide: () => Promise.resolve(),
|
||
});
|
||
// allow_once is offered, so this specific case still shows a button —
|
||
// use a kind the client doesn't map to any decision at all.
|
||
expect(el.querySelectorAll(".st-approval-acts button")).toHaveLength(1);
|
||
|
||
const none = buildTranscriptRow(approvalRow([]), { pending: true, onDecide: () => Promise.resolve() });
|
||
expect(none.querySelector(".st-approval-acts")).toBeNull();
|
||
expect(none.querySelector(".st-approval-resolved")!.textContent).toBe("no decision offered");
|
||
});
|
||
|
||
it("re-enables the buttons when onDecide rejects", async () => {
|
||
const onDecide = vi.fn().mockRejectedValue(new Error("network down"));
|
||
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
|
||
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||
buttons[0].click();
|
||
expect(buttons[0].disabled).toBe(true);
|
||
await Promise.resolve().then(() => Promise.resolve());
|
||
expect(buttons[0].disabled).toBe(false);
|
||
expect(buttons[1].disabled).toBe(false);
|
||
});
|
||
|
||
it("leaves the buttons disabled when onDecide resolves", async () => {
|
||
const onDecide = vi.fn().mockResolvedValue(undefined);
|
||
const el = buildTranscriptRow(approvalRow(allThreeOptions), { pending: true, onDecide });
|
||
const buttons = el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
||
buttons[0].click();
|
||
await Promise.resolve().then(() => Promise.resolve());
|
||
expect(buttons[0].disabled).toBe(true);
|
||
});
|
||
});
|
||
|
||
describe("buildTranscriptRow: notice / unknown kinds", () => {
|
||
it("renders notice and unknown row kinds safely", () => {
|
||
expect(buildTranscriptRow(row("notice", { sessionUpdate: "future" }))?.textContent).toBe(
|
||
"[notice]",
|
||
);
|
||
const unknown = { id: "r1", kind: "future_kind", data: {} } as unknown as TranscriptRow;
|
||
expect(buildTranscriptRow(unknown)?.textContent).toBe("[future_kind]");
|
||
});
|
||
|
||
it("returns no transcript element for a plan row", () => {
|
||
expect(buildTranscriptRow(row("plan", { entries: [] }))).toBeNull();
|
||
});
|
||
});
|
||
|
||
describe("branchFromWorktreePath", () => {
|
||
it("takes everything after the last /worktrees/ segment", () => {
|
||
expect(branchFromWorktreePath("/home/n/tea/maestro/worktrees/acp-bridge")).toBe("acp-bridge");
|
||
});
|
||
|
||
it("keeps slashes in a branch name that contains them", () => {
|
||
expect(branchFromWorktreePath("/repo/worktrees/feature/login")).toBe("feature/login");
|
||
});
|
||
|
||
it("falls back to the basename when there is no /worktrees/ marker", () => {
|
||
expect(branchFromWorktreePath("/some/other/path")).toBe("path");
|
||
});
|
||
});
|
||
|
||
function session(overrides: Partial<Session> = {}): Session {
|
||
return {
|
||
id: "s1",
|
||
cwd: "/repo",
|
||
command: "claude",
|
||
env: {},
|
||
metadata: {},
|
||
status: "Running",
|
||
created_at: "2026-01-01T00:00:00Z",
|
||
...overrides,
|
||
};
|
||
}
|
||
|
||
describe("renderRail", () => {
|
||
it("omits every section that has nothing to show", () => {
|
||
const el = document.createElement("div");
|
||
renderRail(el, {
|
||
plan: null,
|
||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||
toolRowCount: 0,
|
||
usage: null,
|
||
session: session(),
|
||
});
|
||
// Turn always renders (it has a state and a count); the rest are absent.
|
||
expect(el.querySelectorAll("h3")).toHaveLength(1);
|
||
expect(el.querySelector("h3")!.textContent).toBe("Turn");
|
||
});
|
||
|
||
it("renders the plan with status marks", () => {
|
||
const el = document.createElement("div");
|
||
renderRail(el, {
|
||
plan: {
|
||
entries: [
|
||
{ content: "done thing", priority: "high", status: "completed" },
|
||
{ content: "now thing", priority: "high", status: "in_progress" },
|
||
{ content: "later thing", priority: "low", status: "pending" },
|
||
],
|
||
},
|
||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||
toolRowCount: 0,
|
||
usage: null,
|
||
session: session(),
|
||
});
|
||
const items = el.querySelectorAll(".st-plan li");
|
||
expect(items).toHaveLength(3);
|
||
expect(items[0].className).toBe("st-plan-done");
|
||
expect(items[1].className).toBe("st-plan-now");
|
||
expect(items[2].className).toBe("st-plan-todo");
|
||
});
|
||
|
||
it("renders context usage and a meter proportional to used/size", () => {
|
||
const el = document.createElement("div");
|
||
renderRail(el, {
|
||
plan: null,
|
||
turn: { responding: true, thinking: false, active_tool_calls: ["a"] },
|
||
toolRowCount: 3,
|
||
usage: { used: 50_000, size: 200_000 },
|
||
session: session(),
|
||
});
|
||
expect(el.querySelector(".st-meter i")!.getAttribute("style")).toContain("25%");
|
||
});
|
||
|
||
it("renders the worktree section only when the session has one", () => {
|
||
const el = document.createElement("div");
|
||
renderRail(el, {
|
||
plan: null,
|
||
turn: { responding: false, thinking: false, active_tool_calls: [] },
|
||
toolRowCount: 0,
|
||
usage: null,
|
||
session: session({
|
||
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
|
||
blocked_work: { uncommitted_files: 2, untracked_files: 1, unpushed_commits: 3 },
|
||
}),
|
||
});
|
||
const headers = [...el.querySelectorAll("h3")].map((h) => h.textContent);
|
||
expect(headers).toContain("Worktree");
|
||
const rows = [...el.querySelectorAll(".st-kv-row")].map((r) => r.textContent);
|
||
expect(rows.some((t) => t?.includes("feat/x"))).toBe(true);
|
||
expect(rows.some((t) => t?.includes("3"))).toBe(true); // ahead
|
||
expect(rows.some((t) => t?.includes("dirty") && t.includes("3"))).toBe(true); // 2+1
|
||
});
|
||
});
|