Files
ForgeLM/tauri-app/src/components/ChatView.test.tsx
T

431 lines
13 KiB
TypeScript

/* @vitest-environment jsdom */
import { createRoot, type Root } from "react-dom/client";
import { act } from "react-dom/test-utils";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { DB } from "../lib/db";
import ChatView from "./ChatView";
import type { Message } from "../lib/types";
function createDbStub(): DB {
return {
insert: vi.fn(() => 1),
query: vi.fn(() => []),
get: vi.fn(() => null),
run: vi.fn(),
persist: vi.fn(),
} as unknown as DB;
}
describe("ChatView", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
Element.prototype.scrollIntoView = vi.fn();
});
afterEach(() => {
act(() => root.unmount());
container.remove();
vi.restoreAllMocks();
// @ts-expect-error React test environment flag
globalThis.IS_REACT_ACT_ENVIRONMENT = false;
});
it("keeps the draft in place when Enter is pressed before the runtime is ready", async () => {
const db = createDbStub();
await act(async () => {
root.render(
<ChatView
db={db}
chatId={null}
server=""
apiKey=""
model=""
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
await act(async () => {
textarea!.value = "Keep this draft";
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
await act(async () => {
textarea!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
});
expect(textarea?.value).toBe("Keep this draft");
expect(container.textContent).toContain("Connect an API");
expect(vi.mocked(db.run)).not.toHaveBeenCalled();
});
it("loads messages when switching to a saved chat", async () => {
const rowsByChat = new Map<number, Message[]>([
[1, [{ id: 11, chat_id: 1, role: "user", content: "First saved chat", created_at: 1 }]],
[2, [{ id: 21, chat_id: 2, role: "assistant", content: "Second saved chat", created_at: 2 }]],
]);
const db = {
insert: vi.fn(() => 1),
query: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("FROM messages WHERE chat_id = ?")) {
const chatId = Number(params?.[0]);
return rowsByChat.get(chatId) ?? [];
}
return [];
}),
get: vi.fn(() => null),
run: vi.fn(),
persist: vi.fn(),
} as unknown as DB;
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
expect(container.textContent).toContain("First saved chat");
await act(async () => {
root.render(
<ChatView
db={db}
chatId={2}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
expect(container.textContent).toContain("Second saved chat");
expect(container.textContent).not.toContain("First saved chat");
});
it("keeps the selected saved chat visible when a previous request finishes later", async () => {
const rowsByChat = new Map<number, Message[]>([
[1, [{ id: 11, chat_id: 1, role: "user", content: "History from first chat", created_at: 1 }]],
[2, [{ id: 21, chat_id: 2, role: "assistant", content: "Saved chat stays visible", created_at: 2 }]],
]);
let nextMessageId = 100;
const db = {
insert: vi.fn(() => 1),
query: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("FROM messages WHERE chat_id = ?")) {
return [...(rowsByChat.get(Number(params?.[0])) ?? [])];
}
return [];
}),
get: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("COUNT(*) as c FROM messages WHERE chat_id = ?")) {
return { c: rowsByChat.get(Number(params?.[0]))?.length ?? 0 };
}
return null;
}),
run: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)")) {
const chatId = Number(params?.[0]);
const content = String(params?.[1] ?? "");
const next = rowsByChat.get(chatId) ?? [];
next.push({ id: nextMessageId++, chat_id: chatId, role: "user", content, created_at: nextMessageId });
rowsByChat.set(chatId, next);
return;
}
if (sql.includes("INSERT INTO messages (") && sql.includes("'assistant'")) {
const chatId = Number(params?.[0]);
const content = String(params?.[1] ?? "");
const next = rowsByChat.get(chatId) ?? [];
next.push({ id: nextMessageId++, chat_id: chatId, role: "assistant", content, created_at: nextMessageId });
rowsByChat.set(chatId, next);
}
}),
persist: vi.fn(),
} as unknown as DB;
let resolveFetch: ((value: Response) => void) | null = null;
vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
),
);
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
await act(async () => {
textarea!.value = "Send from first chat";
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send"));
expect(sendButton).toBeTruthy();
await act(async () => {
sendButton?.click();
});
await act(async () => {
root.render(
<ChatView
db={db}
chatId={2}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
expect(container.textContent).toContain("Saved chat stays visible");
await act(async () => {
resolveFetch?.(
new Response(
JSON.stringify({
choices: [{ message: { content: "Late answer from first chat" } }],
usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 },
}),
{
status: 200,
headers: { "Content-Type": "application/json" },
},
),
);
await Promise.resolve();
});
expect(container.textContent).toContain("Saved chat stays visible");
expect(container.textContent).not.toContain("Late answer from first chat");
});
it("shows assistant reply after generation completes in an existing chat", async () => {
const rowsByChat = new Map<number, Message[]>([
[1, [{ id: 11, chat_id: 1, role: "user", content: "Previous message", created_at: 1 }]],
]);
let nextMessageId = 200;
const db = {
insert: vi.fn(() => 1),
query: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("FROM messages WHERE chat_id = ?")) {
return [...(rowsByChat.get(Number(params?.[0])) ?? [])];
}
if (sql.includes("SELECT role, content FROM messages WHERE chat_id = ?")) {
return [...(rowsByChat.get(Number(params?.[0])) ?? [])].map(({ role, content }) => ({ role, content }));
}
return [];
}),
get: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("COUNT(*) as c FROM messages WHERE chat_id = ?")) {
return { c: rowsByChat.get(Number(params?.[0]))?.length ?? 0 };
}
return null;
}),
run: vi.fn((sql: string, params?: unknown[]) => {
if (sql.includes("INSERT INTO messages (chat_id, role, content) VALUES (?, 'user', ?)")) {
const chatId = Number(params?.[0]);
const content = String(params?.[1] ?? "");
const next = rowsByChat.get(chatId) ?? [];
next.push({ id: nextMessageId++, chat_id: chatId, role: "user", content, created_at: nextMessageId });
rowsByChat.set(chatId, next);
}
if (sql.includes("INSERT INTO messages (") && sql.includes("'assistant'")) {
const chatId = Number(params?.[0]);
const content = String(params?.[1] ?? "");
const next = rowsByChat.get(chatId) ?? [];
next.push({ id: nextMessageId++, chat_id: chatId, role: "assistant", content, created_at: nextMessageId });
rowsByChat.set(chatId, next);
}
}),
persist: vi.fn(),
} as unknown as DB;
let resolveFetch: ((value: Response) => void) | null = null;
vi.stubGlobal(
"fetch",
vi.fn(
() =>
new Promise<Response>((resolve) => {
resolveFetch = resolve;
}),
),
);
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
expect(container.textContent).toContain("Previous message");
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
const setTextareaValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
await act(async () => {
setTextareaValue?.call(textarea, "New question");
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send"));
await act(async () => { sendButton?.click(); });
// During generation, user message should be visible
expect(container.textContent).toContain("New question");
// Complete the generation
await act(async () => {
const encoder = new TextEncoder();
resolveFetch?.(
new Response(new ReadableStream({
start(controller) {
controller.enqueue(encoder.encode("data: {\"choices\":[{\"delta\":{\"content\":\"The answer is 42\"}}]}\n"));
controller.enqueue(encoder.encode("data: {\"usage\":{\"prompt_tokens\":5,\"completion_tokens\":5,\"total_tokens\":10}}\n"));
controller.enqueue(encoder.encode("data: [DONE]\n"));
controller.close();
},
}), { status: 200, headers: { "Content-Type": "text/event-stream" } }),
);
await Promise.resolve();
await Promise.resolve();
await Promise.resolve();
});
// After generation completes, both messages should still be visible
expect(container.textContent).toContain("New question");
expect(container.textContent).toContain("The answer is 42");
});
it("aborts an in-flight request when switching to another saved chat", async () => {
const db = createDbStub();
const signalRef: { current: { aborted: boolean } | null } = { current: null };
vi.stubGlobal(
"fetch",
vi.fn((_input, init?: RequestInit) => {
const signal = init?.signal as AbortSignal;
signalRef.current = signal;
return new Promise<Response>((_resolve, reject) => {
signal.addEventListener("abort", () => {
reject(new DOMException("Aborted", "AbortError"));
});
});
}),
);
await act(async () => {
root.render(
<ChatView
db={db}
chatId={1}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
});
const textarea = container.querySelector("textarea") as HTMLTextAreaElement | null;
expect(textarea).toBeTruthy();
const setTextareaValue = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, "value")?.set;
expect(setTextareaValue).toBeTruthy();
await act(async () => {
setTextareaValue?.call(textarea, "Message in progress");
textarea!.dispatchEvent(new Event("input", { bubbles: true }));
});
const sendButton = Array.from(container.querySelectorAll("button")).find((node) => node.textContent?.includes("Send"));
expect(sendButton).toBeTruthy();
await act(async () => {
sendButton?.click();
});
expect(signalRef.current).toBeTruthy();
if (!signalRef.current) {
throw new Error("expected the request signal to be captured");
}
const requestSignal = signalRef.current;
expect(requestSignal.aborted).toBe(false);
await act(async () => {
root.render(
<ChatView
db={db}
chatId={2}
server="http://127.0.0.1:1234"
apiKey=""
model="demo"
onChatCreated={vi.fn()}
onChatUpdated={vi.fn()}
/>,
);
await Promise.resolve();
});
expect(requestSignal.aborted).toBe(true);
});
});