643 lines
26 KiB
TypeScript
643 lines
26 KiB
TypeScript
// @vitest-environment happy-dom
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
|
|
vi.mock("./api.js", async () => {
|
|
const actual = await vi.importActual<typeof import("./api.js")>("./api.js");
|
|
return {
|
|
...actual,
|
|
sendStructuredPrompt: vi.fn().mockResolvedValue(undefined),
|
|
cancelStructuredTurn: vi.fn().mockResolvedValue(undefined),
|
|
resolveStructuredApproval: vi.fn().mockResolvedValue(undefined),
|
|
};
|
|
});
|
|
|
|
import { cancelStructuredTurn, resolveStructuredApproval, sendStructuredPrompt } from "./api.js";
|
|
import { TOGGLE_DRAWER_EVENT } from "./drawer.js";
|
|
import { CatalogStore } from "./events.js";
|
|
import { createStructuredConnection, type StructuredSocketFactory } from "./structured.js";
|
|
import type { ReducedState, Session, StructuredEvent, TranscriptRow } from "./types.js";
|
|
|
|
/** Just enough of a WebSocket for the connection to drive (mirrors
|
|
* events.test.ts's FakeSocket, adapted for JSON structured events). */
|
|
class FakeSocket {
|
|
onopen: (() => void) | null = null;
|
|
onmessage: ((ev: { data: string }) => void) | null = null;
|
|
onerror: (() => void) | null = null;
|
|
onclose: ((ev: { code: number; reason: string }) => void) | null = null;
|
|
closed = false;
|
|
|
|
open(): void {
|
|
this.onopen?.();
|
|
}
|
|
emit(event: StructuredEvent): void {
|
|
this.onmessage?.({ data: JSON.stringify(event) });
|
|
}
|
|
drop(code = 1006, reason = ""): void {
|
|
this.onclose?.({ code, reason });
|
|
}
|
|
close(): void {
|
|
this.closed = true;
|
|
}
|
|
}
|
|
|
|
function row(id: string, kind: TranscriptRow["kind"], data: unknown): TranscriptRow {
|
|
return { id, kind, data };
|
|
}
|
|
|
|
const idleState: ReducedState = { turn: { responding: false, thinking: false, active_tool_calls: [] } };
|
|
|
|
const allApprovalOptions = [
|
|
{ optionId: "a1", name: "Allow once", kind: "allow_once" as const },
|
|
{ optionId: "a2", name: "Allow always", kind: "allow_always" as const },
|
|
{ optionId: "r1", name: "Reject once", kind: "reject_once" as const },
|
|
];
|
|
|
|
function mount(opts: {
|
|
sockets: FakeSocket[];
|
|
shouldReconnect?: (id: string, code: number) => boolean;
|
|
store?: CatalogStore;
|
|
}) {
|
|
let i = 0;
|
|
const factory: StructuredSocketFactory = () => opts.sockets[i++] as unknown as WebSocket;
|
|
const conn = createStructuredConnection(
|
|
"s1",
|
|
{ shouldReconnect: opts.shouldReconnect },
|
|
{ name: "s1", command: "claude", view: "Structured" },
|
|
opts.store,
|
|
factory,
|
|
);
|
|
document.body.appendChild(conn.el);
|
|
return conn;
|
|
}
|
|
|
|
describe("structured connection: data flow", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("renders every row from the initial snapshot", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: {
|
|
cursor: 2,
|
|
rows: [
|
|
row("user:1", "user", { content: { type: "text", text: "hello" } }),
|
|
row("agent:1", "agent_text", { content: { type: "text", text: "hi there" } }),
|
|
],
|
|
},
|
|
});
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
expect(conn.el.textContent).toContain("hello");
|
|
expect(conn.el.textContent).toContain("hi there");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("appends and patches rows via projection_update without duplicating them", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: { type: "append", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial" } }) },
|
|
state: idleState,
|
|
});
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: { type: "patch", row: row("agent:1", "agent_text", { content: { type: "text", text: "partial done" } }) },
|
|
state: idleState,
|
|
});
|
|
const rows = conn.el.querySelectorAll(".st-row-agent");
|
|
expect(rows).toHaveLength(1);
|
|
expect(conn.el.textContent).toContain("partial done");
|
|
expect(conn.el.textContent).not.toContain("partial\n"); // the un-patched text is gone, not appended beside it
|
|
conn.dispose();
|
|
});
|
|
});
|
|
|
|
describe("structured connection: turn/composer gating", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("disables the composer while a turn is active and re-enables when idle", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
const cancel = conn.el.querySelector(".structured-cancel") as HTMLButtonElement;
|
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
|
expect(input.disabled).toBe(true);
|
|
expect(cancel.hidden).toBe(false);
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
expect(input.disabled).toBe(false);
|
|
expect(cancel.hidden).toBe(true);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("sends a prompt on Enter, clears the draft, and restores it on failure", async () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "do the thing";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "do the thing");
|
|
expect(input.value).toBe("");
|
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
|
|
vi.mocked(sendStructuredPrompt).mockRejectedValueOnce(new Error("boom"));
|
|
input.value = "retry me";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
await Promise.resolve();
|
|
await Promise.resolve();
|
|
expect(input.value).toBe("retry me");
|
|
expect(conn.el.textContent).toContain("boom");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("disables immediately and dispatches only once before begin-turn is projected", () => {
|
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
const send = conn.el.querySelector(".structured-send") as HTMLButtonElement;
|
|
const form = conn.el.querySelector(".structured-composer") as HTMLFormElement;
|
|
input.value = "only once";
|
|
|
|
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
|
form.dispatchEvent(new SubmitEvent("submit", { bubbles: true, cancelable: true }));
|
|
|
|
expect(input.disabled).toBe(true);
|
|
expect(send.disabled).toBe(true);
|
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(1);
|
|
expect(sendStructuredPrompt).toHaveBeenCalledWith("s1", "only once");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("does not overwrite a newer operator draft when the matching request fails", async () => {
|
|
let reject!: (error: unknown) => void;
|
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
|
() => new Promise((_, rejectPromise) => { reject = rejectPromise; }),
|
|
);
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "first prompt";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
input.value = "newer draft";
|
|
|
|
reject(new Error("late failure"));
|
|
await Promise.resolve().then(() => Promise.resolve());
|
|
|
|
expect(input.value).toBe("newer draft");
|
|
expect(conn.el.textContent).toContain("late failure");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("ignores an older failure after a newer submission generation starts", async () => {
|
|
let rejectFirst!: (error: unknown) => void;
|
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
|
() => new Promise((_, rejectPromise) => { rejectFirst = rejectPromise; }),
|
|
);
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "first prompt";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
input.value = "second prompt";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
|
|
rejectFirst(new Error("stale failure"));
|
|
await Promise.resolve().then(() => Promise.resolve());
|
|
|
|
expect(sendStructuredPrompt).toHaveBeenCalledTimes(2);
|
|
expect(input.value).toBe("");
|
|
expect(input.disabled).toBe(true);
|
|
expect(conn.el.textContent).not.toContain("stale failure");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("cancels the active turn via the cancel button", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: true, active_tool_calls: [] } } });
|
|
(conn.el.querySelector(".structured-cancel") as HTMLButtonElement).click();
|
|
expect(cancelStructuredTurn).toHaveBeenCalledWith("s1");
|
|
conn.dispose();
|
|
});
|
|
});
|
|
|
|
describe("structured connection: approvals", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("shows decision buttons only for the currently pending approval, and answers with its nonce", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: {
|
|
type: "append",
|
|
row: row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions }),
|
|
},
|
|
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
|
});
|
|
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
|
expect(buttons).toHaveLength(3);
|
|
buttons[1].click(); // "Allow for session"
|
|
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_for_session");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("removes the buttons once the approval resolves, even with no accompanying transcript delta", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: { title: "Write x" }, options: allApprovalOptions })] },
|
|
});
|
|
ws.emit({
|
|
type: "reduced_state",
|
|
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
|
});
|
|
expect(conn.el.querySelectorAll(".st-approval-acts button")).toHaveLength(3);
|
|
// Resolved on another client: state clears with no transcript delta at all.
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
expect(conn.el.querySelector(".st-approval-acts")).toBeNull();
|
|
expect(conn.el.querySelector(".st-approval-resolved")).not.toBeNull();
|
|
conn.dispose();
|
|
});
|
|
|
|
it("does not act on a stale click after the approval already moved on", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
|
|
});
|
|
ws.emit({
|
|
type: "reduced_state",
|
|
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
|
});
|
|
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
|
// Approval is superseded before the click is handled (buttons already
|
|
// disabled by the click handler is not the path under test here — this
|
|
// simulates the row having already flipped to "answered" underneath it).
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
|
|
expect(resolveStructuredApproval).not.toHaveBeenCalled();
|
|
conn.dispose();
|
|
});
|
|
|
|
it("restores the buttons and surfaces an error when the POST rejects while still pending", async () => {
|
|
vi.mocked(resolveStructuredApproval).mockRejectedValueOnce(new Error("network down"));
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
|
|
});
|
|
ws.emit({
|
|
type: "reduced_state",
|
|
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
|
});
|
|
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
|
buttons[0].click();
|
|
expect(buttons[0].disabled).toBe(true);
|
|
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
|
|
expect(buttons[0].disabled).toBe(false);
|
|
expect(buttons[1].disabled).toBe(false);
|
|
expect(conn.el.textContent).toContain("network down");
|
|
conn.dispose();
|
|
});
|
|
|
|
it("does not resurrect buttons or show an error for a decision the approval already moved past", async () => {
|
|
let reject!: (e: unknown) => void;
|
|
vi.mocked(resolveStructuredApproval).mockImplementationOnce(
|
|
() => new Promise((_, r) => { reject = r; }),
|
|
);
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: { cursor: 0, rows: [row("approval:1", "approval", { toolCall: {}, options: allApprovalOptions })] },
|
|
});
|
|
ws.emit({
|
|
type: "reduced_state",
|
|
state: { ...idleState, pending_approval: { nonce: "n1", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
|
});
|
|
const buttons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
|
buttons[0].click();
|
|
expect(resolveStructuredApproval).toHaveBeenCalledWith("s1", "n1", "allow_once");
|
|
|
|
// Superseded by a new approval before the in-flight POST settles.
|
|
ws.emit({
|
|
type: "projection_update",
|
|
transcript: { type: "append", row: row("approval:2", "approval", { toolCall: {}, options: allApprovalOptions }) },
|
|
state: { ...idleState, pending_approval: { nonce: "n2", request: { sessionId: "s1", toolCall: {}, options: allApprovalOptions } } },
|
|
});
|
|
reject(new Error("too late"));
|
|
await Promise.resolve().then(() => Promise.resolve()).then(() => Promise.resolve());
|
|
|
|
expect(conn.el.textContent).not.toContain("too late");
|
|
const newButtons = conn.el.querySelectorAll<HTMLButtonElement>(".st-approval-acts button");
|
|
expect(newButtons).toHaveLength(3);
|
|
expect(newButtons[0].disabled).toBe(false);
|
|
conn.dispose();
|
|
});
|
|
});
|
|
|
|
describe("structured connection: reconnection/error/loading", () => {
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
vi.useFakeTimers();
|
|
});
|
|
|
|
it("shows connecting, then connected, then reconnecting with backoff, and rebuilds from the fresh snapshot", () => {
|
|
const first = new FakeSocket();
|
|
const second = new FakeSocket();
|
|
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
|
const state = () => (conn.el.querySelector(".attach-state") as HTMLElement).dataset.state;
|
|
expect(state()).toBe("connecting");
|
|
|
|
first.open();
|
|
expect(state()).toBe("open");
|
|
first.emit({
|
|
type: "transcript_snapshot",
|
|
snapshot: { cursor: 0, rows: [row("user:1", "user", { content: { type: "text", text: "before" } })] },
|
|
});
|
|
expect(conn.el.textContent).toContain("before");
|
|
|
|
first.drop(1006, "");
|
|
expect(state()).toBe("connecting"); // "reconnecting…"
|
|
vi.advanceTimersByTime(1000);
|
|
|
|
second.open();
|
|
expect(state()).toBe("open");
|
|
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
expect(conn.el.textContent).not.toContain("before"); // rebuilt wholesale, not merged
|
|
conn.dispose();
|
|
});
|
|
|
|
it("keeps a local submission gated across a reconnect until begin-turn arrives", () => {
|
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(() => new Promise(() => {}));
|
|
const first = new FakeSocket();
|
|
const second = new FakeSocket();
|
|
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
|
first.open();
|
|
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
first.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "survive reconnect";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
|
|
first.drop();
|
|
vi.advanceTimersByTime(1000);
|
|
second.open();
|
|
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
second.emit({ type: "reduced_state", state: idleState });
|
|
expect(input.disabled).toBe(true);
|
|
|
|
second.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
|
second.emit({ type: "reduced_state", state: idleState });
|
|
expect(input.disabled).toBe(false);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("releases an accepted submission from an idle snapshot after reconnect", async () => {
|
|
vi.mocked(sendStructuredPrompt).mockResolvedValueOnce(undefined);
|
|
const first = new FakeSocket();
|
|
const second = new FakeSocket();
|
|
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
|
first.open();
|
|
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
first.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "finishes while disconnected";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
await Promise.resolve().then(() => Promise.resolve());
|
|
|
|
first.drop();
|
|
vi.advanceTimersByTime(1000);
|
|
second.open();
|
|
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
second.emit({ type: "reduced_state", state: idleState });
|
|
|
|
expect(input.disabled).toBe(false);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("releases when reconnect idle arrives before submission acceptance", async () => {
|
|
let accept!: () => void;
|
|
vi.mocked(sendStructuredPrompt).mockImplementationOnce(
|
|
() => new Promise((resolve) => { accept = resolve; }),
|
|
);
|
|
const first = new FakeSocket();
|
|
const second = new FakeSocket();
|
|
const conn = mount({ sockets: [first, second], shouldReconnect: () => true });
|
|
first.open();
|
|
first.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
first.emit({ type: "reduced_state", state: idleState });
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
input.value = "accepted after reconnect";
|
|
input.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }));
|
|
|
|
first.drop();
|
|
vi.advanceTimersByTime(1000);
|
|
second.open();
|
|
second.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
second.emit({ type: "reduced_state", state: idleState });
|
|
expect(input.disabled).toBe(true);
|
|
|
|
accept();
|
|
await Promise.resolve().then(() => Promise.resolve());
|
|
|
|
expect(input.disabled).toBe(false);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("reports a daemon rejection reason and does not schedule a retry", () => {
|
|
const only = new FakeSocket();
|
|
const conn = mount({ sockets: [only], shouldReconnect: () => true });
|
|
only.open();
|
|
only.drop(4004, "session is not a live structured session");
|
|
const stateEl = conn.el.querySelector(".attach-state") as HTMLElement;
|
|
expect(stateEl.dataset.state).toBe("error");
|
|
expect(stateEl.textContent).toBe("session is not a live structured session");
|
|
vi.advanceTimersByTime(60_000);
|
|
// No second socket was ever asked for — only one factory call happened.
|
|
conn.dispose();
|
|
});
|
|
});
|
|
|
|
describe("structured connection: pane lifecycle", () => {
|
|
beforeEach(() => vi.clearAllMocks());
|
|
|
|
it("sendInput is a no-op — there is no PTY behind a structured session", () => {
|
|
const conn = mount({ sockets: [new FakeSocket()] });
|
|
expect(() => conn.sendInput("\x0b")).not.toThrow();
|
|
conn.dispose();
|
|
});
|
|
|
|
it("carries the same off-canvas sessions button as the terminal attach header", () => {
|
|
const conn = mount({ sockets: [new FakeSocket()] });
|
|
const btn = conn.el.querySelector(".sessions-btn") as HTMLButtonElement;
|
|
expect(btn).not.toBeNull();
|
|
expect(btn.getAttribute("aria-label")).toBe("Show sessions");
|
|
expect(btn.type).toBe("button");
|
|
|
|
let toggled = 0;
|
|
const onToggle = () => toggled++;
|
|
document.addEventListener(TOGGLE_DRAWER_EVENT, onToggle);
|
|
btn.click();
|
|
document.removeEventListener(TOGGLE_DRAWER_EVENT, onToggle);
|
|
expect(toggled).toBe(1);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("focusTerminal focuses the composer only when it is enabled", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: { turn: { responding: true, thinking: false, active_tool_calls: [] } } });
|
|
conn.focusTerminal();
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
expect(document.activeElement).not.toBe(input);
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
conn.focusTerminal();
|
|
expect(document.activeElement).toBe(input);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("dispose closes the socket and unsubscribes from the store", () => {
|
|
const store = new CatalogStore({ socket: () => new FakeSocket() as unknown as WebSocket });
|
|
const unsubscribe = vi.spyOn(store, "subscribe");
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws], store });
|
|
expect(unsubscribe).toHaveBeenCalled();
|
|
conn.dispose();
|
|
expect(ws.closed).toBe(true);
|
|
});
|
|
|
|
it("reflects live worktree/blocked-work data from the store in the rail", () => {
|
|
const catalogWs = new FakeSocket();
|
|
const store = new CatalogStore({ socket: () => catalogWs as unknown as WebSocket });
|
|
store.start();
|
|
const session: Session = {
|
|
id: "s1",
|
|
cwd: "/repo",
|
|
command: "claude",
|
|
env: {},
|
|
metadata: {},
|
|
status: "Running",
|
|
created_at: "2026-01-01T00:00:00Z",
|
|
worktree: { path: "/repo/worktrees/feat/x", base_repo: "/repo" },
|
|
blocked_work: { uncommitted_files: 1, untracked_files: 0, unpushed_commits: 2 },
|
|
};
|
|
catalogWs.emit({ type: "Snapshot", sessions: [session] } as never);
|
|
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws], store });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
expect(conn.el.textContent).toContain("feat/x");
|
|
conn.dispose();
|
|
});
|
|
});
|
|
|
|
describe("structured connection: deferred mount focus", () => {
|
|
let frames: FrameRequestCallback[];
|
|
|
|
beforeEach(() => {
|
|
vi.clearAllMocks();
|
|
frames = [];
|
|
vi.stubGlobal("requestAnimationFrame", (cb: FrameRequestCallback) => {
|
|
frames.push(cb);
|
|
return frames.length;
|
|
});
|
|
});
|
|
|
|
afterEach(() => vi.unstubAllGlobals());
|
|
|
|
function runFrames(): void {
|
|
const pending = frames;
|
|
frames = [];
|
|
for (const cb of pending) cb(0);
|
|
}
|
|
|
|
it("does not focus the composer if the pane was parked before its deferred frame ran", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
|
|
conn.onMount({ focus: true });
|
|
conn.onUnmount(); // parked again before the frame below runs
|
|
runFrames();
|
|
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
expect(document.activeElement).not.toBe(input);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("does not focus the composer if it was re-mounted into a different pane before the stale frame ran", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
|
|
conn.onMount({ focus: true }); // stale frame #1
|
|
conn.onUnmount();
|
|
conn.onMount({ focus: false }); // the pane that actually won focus, without it
|
|
runFrames(); // both frames fire; only the latest should have any say
|
|
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
expect(document.activeElement).not.toBe(input);
|
|
conn.dispose();
|
|
});
|
|
|
|
it("still focuses normally when nothing raced the frame", () => {
|
|
const ws = new FakeSocket();
|
|
const conn = mount({ sockets: [ws] });
|
|
ws.open();
|
|
ws.emit({ type: "transcript_snapshot", snapshot: { cursor: 0, rows: [] } });
|
|
ws.emit({ type: "reduced_state", state: idleState });
|
|
|
|
conn.onMount({ focus: true });
|
|
runFrames();
|
|
|
|
const input = conn.el.querySelector(".structured-input") as HTMLTextAreaElement;
|
|
expect(document.activeElement).toBe(input);
|
|
conn.dispose();
|
|
});
|
|
});
|