Initial ForgeLM import
This commit is contained in:
commit
a392b9dc60
4296 files changed
+1212675
No files matched your search
@@ -0,0 +1,430 @@
|
||||
/* @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);
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user