431 lines
13 KiB
TypeScript
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);
|
|
});
|
|
});
|