generated from nhcarrigan/template
b3d79a82ef
### Explanation _No response_ ### Issue _No response_ ### Attestations - [ ] I have read and agree to the [Code of Conduct](https://docs.nhcarrigan.com/community/coc/) - [ ] I have read and agree to the [Community Guidelines](https://docs.nhcarrigan.com/community/guide/). - [ ] My contribution complies with the [Contributor Covenant](https://docs.nhcarrigan.com/dev/covenant/). ### Dependencies - [ ] I have pinned the dependencies to a specific patch version. ### Style - [ ] I have run the linter and resolved any errors. - [ ] My pull request uses an appropriate title, matching the conventional commit standards. - [ ] My scope of feat/fix/chore/etc. correctly matches the nature of changes in my pull request. ### Tests - [ ] My contribution adds new code, and I have added tests to cover it. - [ ] My contribution modifies existing code, and I have updated the tests to reflect these changes. - [ ] All new and existing tests pass locally with my changes. - [ ] Code coverage remains at or above the configured threshold. ### Documentation _No response_ ### Versioning _No response_ Co-authored-by: Hikari <hikari@nhcarrigan.com> Reviewed-on: #71 Co-authored-by: Naomi Carrigan <commits@nhcarrigan.com> Co-committed-by: Naomi Carrigan <commits@nhcarrigan.com>
526 lines
15 KiB
TypeScript
526 lines
15 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
|
|
// Test the Conversation interface and store behavior
|
|
describe("Conversation interface", () => {
|
|
it("defines all required fields", () => {
|
|
const conversation = {
|
|
id: "conv-123",
|
|
name: "Test Conversation",
|
|
terminalLines: [],
|
|
sessionId: null,
|
|
connectionStatus: "disconnected" as const,
|
|
workingDirectory: "",
|
|
characterState: "idle" as const,
|
|
isProcessing: false,
|
|
grantedTools: new Set<string>(),
|
|
pendingPermission: null,
|
|
pendingQuestion: null,
|
|
scrollPosition: -1,
|
|
createdAt: new Date(),
|
|
lastActivityAt: new Date(),
|
|
attachments: [],
|
|
};
|
|
|
|
expect(conversation.id).toBe("conv-123");
|
|
expect(conversation.name).toBe("Test Conversation");
|
|
expect(conversation.terminalLines).toEqual([]);
|
|
expect(conversation.sessionId).toBeNull();
|
|
expect(conversation.connectionStatus).toBe("disconnected");
|
|
expect(conversation.workingDirectory).toBe("");
|
|
expect(conversation.characterState).toBe("idle");
|
|
expect(conversation.isProcessing).toBe(false);
|
|
expect(conversation.grantedTools.size).toBe(0);
|
|
expect(conversation.pendingPermission).toBeNull();
|
|
expect(conversation.pendingQuestion).toBeNull();
|
|
expect(conversation.scrollPosition).toBe(-1);
|
|
expect(conversation.attachments).toEqual([]);
|
|
});
|
|
|
|
it("handles terminal lines array", () => {
|
|
const lines = [
|
|
{
|
|
id: "line-1",
|
|
type: "user" as const,
|
|
content: "Hello",
|
|
timestamp: new Date(),
|
|
},
|
|
{
|
|
id: "line-2",
|
|
type: "assistant" as const,
|
|
content: "Hi there!",
|
|
timestamp: new Date(),
|
|
},
|
|
];
|
|
|
|
expect(lines).toHaveLength(2);
|
|
expect(lines[0].type).toBe("user");
|
|
expect(lines[1].type).toBe("assistant");
|
|
});
|
|
});
|
|
|
|
describe("conversation ID generation", () => {
|
|
it("generates unique IDs with timestamp prefix", () => {
|
|
let counter = 0;
|
|
const generateId = () => `conv-${Date.now()}-${counter++}`;
|
|
|
|
const id1 = generateId();
|
|
const id2 = generateId();
|
|
|
|
expect(id1).toMatch(/^conv-\d+-\d+$/);
|
|
expect(id2).toMatch(/^conv-\d+-\d+$/);
|
|
expect(id1).not.toBe(id2);
|
|
});
|
|
});
|
|
|
|
describe("line ID generation", () => {
|
|
it("generates unique line IDs", () => {
|
|
let counter = 0;
|
|
const generateLineId = () => `line-${Date.now()}-${counter++}`;
|
|
|
|
const id1 = generateLineId();
|
|
const id2 = generateLineId();
|
|
|
|
expect(id1).toMatch(/^line-\d+-\d+$/);
|
|
expect(id2).toMatch(/^line-\d+-\d+$/);
|
|
expect(id1).not.toBe(id2);
|
|
});
|
|
});
|
|
|
|
describe("connection status types", () => {
|
|
it("supports all connection status values", () => {
|
|
const statuses = ["connected", "disconnected", "connecting", "error"] as const;
|
|
|
|
statuses.forEach((status) => {
|
|
expect(typeof status).toBe("string");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("character state types", () => {
|
|
it("supports all character state values", () => {
|
|
const states = [
|
|
"idle",
|
|
"thinking",
|
|
"typing",
|
|
"searching",
|
|
"coding",
|
|
"mcp",
|
|
"permission",
|
|
"success",
|
|
"error",
|
|
] as const;
|
|
|
|
states.forEach((state) => {
|
|
expect(typeof state).toBe("string");
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("terminal line types", () => {
|
|
it("supports all terminal line types", () => {
|
|
const types = ["user", "assistant", "system", "tool", "error"] as const;
|
|
|
|
types.forEach((type) => {
|
|
expect(typeof type).toBe("string");
|
|
});
|
|
});
|
|
|
|
it("creates terminal line with required fields", () => {
|
|
const line = {
|
|
id: "line-123",
|
|
type: "user" as const,
|
|
content: "Test message",
|
|
timestamp: new Date(),
|
|
toolName: undefined,
|
|
};
|
|
|
|
expect(line.id).toBe("line-123");
|
|
expect(line.type).toBe("user");
|
|
expect(line.content).toBe("Test message");
|
|
expect(line.timestamp).toBeInstanceOf(Date);
|
|
expect(line.toolName).toBeUndefined();
|
|
});
|
|
|
|
it("creates terminal line with tool name", () => {
|
|
const line = {
|
|
id: "line-456",
|
|
type: "tool" as const,
|
|
content: "Tool output",
|
|
timestamp: new Date(),
|
|
toolName: "Read",
|
|
};
|
|
|
|
expect(line.toolName).toBe("Read");
|
|
});
|
|
});
|
|
|
|
describe("permission request structure", () => {
|
|
it("creates valid permission request", () => {
|
|
const request = {
|
|
id: "perm-123",
|
|
tool: "Bash",
|
|
description: "Run shell command",
|
|
input: '{"command": "ls"}',
|
|
};
|
|
|
|
expect(request.id).toBe("perm-123");
|
|
expect(request.tool).toBe("Bash");
|
|
expect(request.description).toBe("Run shell command");
|
|
expect(request.input).toContain("command");
|
|
});
|
|
});
|
|
|
|
describe("user question structure", () => {
|
|
it("creates valid question event", () => {
|
|
const question = {
|
|
id: "q-123",
|
|
question: "Which option?",
|
|
options: [
|
|
{ label: "A", description: "Option A" },
|
|
{ label: "B", description: "Option B" },
|
|
],
|
|
conversation_id: "conv-123",
|
|
};
|
|
|
|
expect(question.id).toBe("q-123");
|
|
expect(question.question).toBe("Which option?");
|
|
expect(question.options).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
describe("attachment structure", () => {
|
|
it("creates valid file attachment", () => {
|
|
const attachment = {
|
|
id: "att-123",
|
|
type: "file" as const,
|
|
name: "test.txt",
|
|
path: "/tmp/test.txt",
|
|
size: 1024,
|
|
mimeType: "text/plain",
|
|
};
|
|
|
|
expect(attachment.id).toBe("att-123");
|
|
expect(attachment.type).toBe("file");
|
|
expect(attachment.name).toBe("test.txt");
|
|
});
|
|
|
|
it("creates valid image attachment", () => {
|
|
const attachment = {
|
|
id: "att-456",
|
|
type: "image" as const,
|
|
name: "screenshot.png",
|
|
path: "/tmp/screenshot.png",
|
|
size: 50000,
|
|
mimeType: "image/png",
|
|
previewUrl: "data:image/png;base64,...",
|
|
};
|
|
|
|
expect(attachment.type).toBe("image");
|
|
expect(attachment.previewUrl).toContain("data:image");
|
|
});
|
|
});
|
|
|
|
describe("conversation management operations", () => {
|
|
it("creates new conversation with default values", () => {
|
|
let counter = 1;
|
|
const createNewConversation = (name?: string) => {
|
|
const id = `conv-${Date.now()}-${counter++}`;
|
|
return {
|
|
id,
|
|
name: name || `Conversation ${counter}`,
|
|
terminalLines: [],
|
|
sessionId: null,
|
|
connectionStatus: "disconnected" as const,
|
|
workingDirectory: "",
|
|
characterState: "idle" as const,
|
|
isProcessing: false,
|
|
grantedTools: new Set<string>(),
|
|
pendingPermission: null,
|
|
pendingQuestion: null,
|
|
scrollPosition: -1,
|
|
createdAt: new Date(),
|
|
lastActivityAt: new Date(),
|
|
attachments: [],
|
|
};
|
|
};
|
|
|
|
const conv = createNewConversation("My Chat");
|
|
expect(conv.name).toBe("My Chat");
|
|
expect(conv.connectionStatus).toBe("disconnected");
|
|
expect(conv.characterState).toBe("idle");
|
|
});
|
|
|
|
it("uses default name when not provided", () => {
|
|
const counter = 5;
|
|
const createNewConversation = (name?: string) => ({
|
|
name: name || `Conversation ${counter}`,
|
|
});
|
|
|
|
const conv = createNewConversation();
|
|
expect(conv.name).toBe("Conversation 5");
|
|
});
|
|
});
|
|
|
|
describe("conversation history formatting", () => {
|
|
it("formats user and assistant messages for history", () => {
|
|
const lines = [
|
|
{ type: "user" as const, content: "Hello" },
|
|
{ type: "assistant" as const, content: "Hi there!" },
|
|
{ type: "system" as const, content: "Connected" },
|
|
{ type: "user" as const, content: "How are you?" },
|
|
];
|
|
|
|
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
|
|
|
|
const history = relevantLines
|
|
.map((line) => {
|
|
const role = line.type === "user" ? "User" : "Assistant";
|
|
return `${role}: ${line.content}`;
|
|
})
|
|
.join("\n\n");
|
|
|
|
expect(history).toContain("User: Hello");
|
|
expect(history).toContain("Assistant: Hi there!");
|
|
expect(history).toContain("User: How are you?");
|
|
expect(history).not.toContain("Connected");
|
|
});
|
|
|
|
it("returns empty string for no messages", () => {
|
|
const lines: Array<{ type: string; content: string }> = [];
|
|
const relevantLines = lines.filter((line) => line.type === "user" || line.type === "assistant");
|
|
|
|
expect(relevantLines.length).toBe(0);
|
|
const history = relevantLines.length === 0 ? "" : "has content";
|
|
expect(history).toBe("");
|
|
});
|
|
});
|
|
|
|
describe("tool granting", () => {
|
|
it("tracks granted tools with Set", () => {
|
|
const grantedTools = new Set<string>();
|
|
|
|
grantedTools.add("Read");
|
|
grantedTools.add("Write");
|
|
grantedTools.add("Bash");
|
|
|
|
expect(grantedTools.has("Read")).toBe(true);
|
|
expect(grantedTools.has("Write")).toBe(true);
|
|
expect(grantedTools.has("Bash")).toBe(true);
|
|
expect(grantedTools.has("Edit")).toBe(false);
|
|
expect(grantedTools.size).toBe(3);
|
|
});
|
|
|
|
it("clears all granted tools", () => {
|
|
const grantedTools = new Set<string>(["Read", "Write"]);
|
|
expect(grantedTools.size).toBe(2);
|
|
|
|
grantedTools.clear();
|
|
expect(grantedTools.size).toBe(0);
|
|
});
|
|
});
|
|
|
|
describe("scroll position handling", () => {
|
|
it("uses -1 for auto-scroll (scroll to bottom)", () => {
|
|
const scrollPosition = -1;
|
|
const isAutoScroll = scrollPosition === -1;
|
|
|
|
expect(isAutoScroll).toBe(true);
|
|
});
|
|
|
|
it("uses positive values for manual scroll position", () => {
|
|
const scrollPosition: number = 500;
|
|
const isAutoScroll = scrollPosition === -1;
|
|
|
|
expect(isAutoScroll).toBe(false);
|
|
expect(scrollPosition).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
describe("conversation deletion rules", () => {
|
|
it("prevents deletion of last conversation", () => {
|
|
const conversations = new Map([["conv-1", { id: "conv-1", name: "Main" }]]);
|
|
|
|
const canDelete = conversations.size > 1;
|
|
expect(canDelete).toBe(false);
|
|
});
|
|
|
|
it("allows deletion when multiple conversations exist", () => {
|
|
const conversations = new Map([
|
|
["conv-1", { id: "conv-1", name: "Main" }],
|
|
["conv-2", { id: "conv-2", name: "Second" }],
|
|
]);
|
|
|
|
const canDelete = conversations.size > 1;
|
|
expect(canDelete).toBe(true);
|
|
});
|
|
|
|
it("switches to remaining conversation after deletion", () => {
|
|
const conversations = new Map([
|
|
["conv-1", { id: "conv-1", name: "Main" }],
|
|
["conv-2", { id: "conv-2", name: "Second" }],
|
|
]);
|
|
|
|
const activeId = "conv-1";
|
|
conversations.delete(activeId);
|
|
|
|
const remaining = Array.from(conversations.keys());
|
|
expect(remaining).toEqual(["conv-2"]);
|
|
expect(remaining[0]).toBe("conv-2");
|
|
});
|
|
});
|
|
|
|
describe("activity timestamp tracking", () => {
|
|
it("updates lastActivityAt on changes", () => {
|
|
const before = new Date();
|
|
|
|
// Simulate a small delay
|
|
const after = new Date(before.getTime() + 100);
|
|
|
|
expect(after.getTime()).toBeGreaterThan(before.getTime());
|
|
});
|
|
});
|
|
|
|
describe("Map operations for conversations", () => {
|
|
it("stores and retrieves conversations by ID", () => {
|
|
const conversations = new Map<string, { id: string; name: string }>();
|
|
|
|
conversations.set("conv-1", { id: "conv-1", name: "First" });
|
|
conversations.set("conv-2", { id: "conv-2", name: "Second" });
|
|
|
|
expect(conversations.get("conv-1")?.name).toBe("First");
|
|
expect(conversations.get("conv-2")?.name).toBe("Second");
|
|
expect(conversations.get("conv-3")).toBeUndefined();
|
|
});
|
|
|
|
it("checks if conversation exists", () => {
|
|
const conversations = new Map([["conv-1", { id: "conv-1" }]]);
|
|
|
|
expect(conversations.has("conv-1")).toBe(true);
|
|
expect(conversations.has("conv-2")).toBe(false);
|
|
});
|
|
|
|
it("iterates over all conversations", () => {
|
|
const conversations = new Map([
|
|
["conv-1", { id: "conv-1", name: "First" }],
|
|
["conv-2", { id: "conv-2", name: "Second" }],
|
|
]);
|
|
|
|
const names: string[] = [];
|
|
conversations.forEach((conv) => names.push(conv.name));
|
|
|
|
expect(names).toContain("First");
|
|
expect(names).toContain("Second");
|
|
});
|
|
});
|
|
|
|
describe("conversation rename", () => {
|
|
it("updates conversation name", () => {
|
|
const conversation = { id: "conv-1", name: "Old Name", lastActivityAt: new Date() };
|
|
|
|
conversation.name = "New Name";
|
|
conversation.lastActivityAt = new Date();
|
|
|
|
expect(conversation.name).toBe("New Name");
|
|
});
|
|
});
|
|
|
|
describe("attachment management", () => {
|
|
it("adds attachment to array", () => {
|
|
const attachments: Array<{ id: string; name: string }> = [];
|
|
|
|
attachments.push({ id: "att-1", name: "file1.txt" });
|
|
expect(attachments).toHaveLength(1);
|
|
|
|
attachments.push({ id: "att-2", name: "file2.txt" });
|
|
expect(attachments).toHaveLength(2);
|
|
});
|
|
|
|
it("removes attachment by ID", () => {
|
|
const attachments = [
|
|
{ id: "att-1", name: "file1.txt" },
|
|
{ id: "att-2", name: "file2.txt" },
|
|
];
|
|
|
|
const filtered = attachments.filter((a) => a.id !== "att-1");
|
|
expect(filtered).toHaveLength(1);
|
|
expect(filtered[0].id).toBe("att-2");
|
|
});
|
|
|
|
it("clears all attachments", () => {
|
|
const cleared: Array<{ id: string }> = [];
|
|
expect(cleared).toHaveLength(0);
|
|
});
|
|
});
|
|
|
|
describe("derived store behavior", () => {
|
|
it("derives connection status from active conversation", () => {
|
|
const activeConv = { connectionStatus: "connected" as const };
|
|
const derivedStatus = activeConv?.connectionStatus || "disconnected";
|
|
|
|
expect(derivedStatus).toBe("connected");
|
|
});
|
|
|
|
it("defaults to disconnected when no active conversation", () => {
|
|
const activeConv = null as { connectionStatus?: string } | null;
|
|
const derivedStatus = activeConv?.connectionStatus || "disconnected";
|
|
|
|
expect(derivedStatus).toBe("disconnected");
|
|
});
|
|
|
|
it("derives terminal lines from active conversation", () => {
|
|
const activeConv = {
|
|
terminalLines: [
|
|
{ id: "1", content: "Hello" },
|
|
{ id: "2", content: "World" },
|
|
],
|
|
};
|
|
const derivedLines = activeConv?.terminalLines || [];
|
|
|
|
expect(derivedLines).toHaveLength(2);
|
|
});
|
|
|
|
it("defaults to empty array when no active conversation", () => {
|
|
const activeConv = null as { terminalLines?: Array<{ id: string; content: string }> } | null;
|
|
const derivedLines = activeConv?.terminalLines || [];
|
|
|
|
expect(derivedLines).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("line update operations", () => {
|
|
it("updates line content by ID", () => {
|
|
const lines = [
|
|
{ id: "line-1", content: "Original" },
|
|
{ id: "line-2", content: "Other" },
|
|
];
|
|
|
|
const line = lines.find((l) => l.id === "line-1");
|
|
if (line) {
|
|
line.content = "Updated";
|
|
}
|
|
|
|
expect(lines[0].content).toBe("Updated");
|
|
expect(lines[1].content).toBe("Other");
|
|
});
|
|
|
|
it("appends to line content", () => {
|
|
const line = { id: "line-1", content: "Hello" };
|
|
|
|
line.content += " World";
|
|
|
|
expect(line.content).toBe("Hello World");
|
|
});
|
|
});
|
|
|
|
describe("pending retry message", () => {
|
|
it("stores and clears retry message", () => {
|
|
let pendingRetryMessage: string | null = null;
|
|
|
|
pendingRetryMessage = "Retry this message";
|
|
expect(pendingRetryMessage).toBe("Retry this message");
|
|
|
|
pendingRetryMessage = null;
|
|
expect(pendingRetryMessage).toBeNull();
|
|
});
|
|
});
|