diff --git a/surfsense_web/tests/fixtures/chat-thread.fixture.ts b/surfsense_web/tests/fixtures/chat-thread.fixture.ts new file mode 100644 index 000000000..81cf3b5c3 --- /dev/null +++ b/surfsense_web/tests/fixtures/chat-thread.fixture.ts @@ -0,0 +1,42 @@ +import type { APIRequestContext } from "@playwright/test"; +import { authHeaders, BACKEND_URL } from "../helpers/api/auth"; +import type { SearchSpaceFixtures } from "./search-space.fixture"; + +export type ChatThreadRow = { + id: number; + title: string; + search_space_id: number; + visibility: string; + created_by_id: string | null; + created_at: string; + updated_at: string; +}; + +export type ChatThreadFixtures = { + chatThread: ChatThreadRow; +}; + +type ChatThreadFixtureArgs = SearchSpaceFixtures & { + request: APIRequestContext; +}; + +export const chatThreadFixtures = { + chatThread: async ( + { request, apiToken, searchSpace }: ChatThreadFixtureArgs, + use: (thread: ChatThreadRow) => Promise + ) => { + const response = await request.post(`${BACKEND_URL}/api/v1/threads`, { + headers: authHeaders(apiToken), + data: { + title: "e2e-drive-journey", + search_space_id: searchSpace.id, + visibility: "PRIVATE", + }, + }); + if (!response.ok()) { + throw new Error(`create chat thread failed (${response.status()}): ${await response.text()}`); + } + + await use((await response.json()) as ChatThreadRow); + }, +}; diff --git a/surfsense_web/tests/fixtures/index.ts b/surfsense_web/tests/fixtures/index.ts index 84d4606c8..25df153cf 100644 --- a/surfsense_web/tests/fixtures/index.ts +++ b/surfsense_web/tests/fixtures/index.ts @@ -9,6 +9,7 @@ * base (@playwright/test) * └─ searchSpaceFixtures — apiToken, searchSpace * └─ composioDriveFixtures — composioDriveConnector + * └─ composioDriveWithChatTest — chatThread * * To add a new connector (Gmail, Slack, manual upload, etc.): * 1. Add a fixture file under `fixtures/connectors/.fixture.ts`. @@ -16,9 +17,11 @@ * doesn't compose cleanly into the existing chain. */ export { expect } from "@playwright/test"; -export { searchSpaceFixtures } from "./search-space.fixture"; +export { chatThreadFixtures } from "./chat-thread.fixture"; export { composioDriveFixtures } from "./connectors/composio-drive.fixture"; +export { searchSpaceFixtures } from "./search-space.fixture"; +import { type ChatThreadFixtures, chatThreadFixtures } from "./chat-thread.fixture"; import { composioDriveFixtures } from "./connectors/composio-drive.fixture"; import { searchSpaceFixtures } from "./search-space.fixture"; @@ -26,3 +29,6 @@ import { searchSpaceFixtures } from "./search-space.fixture"; export const test = searchSpaceFixtures; /** `test` for specs that also need a pre-connected Composio Drive connector. */ export const composioDriveTest = composioDriveFixtures; +/** `test` for specs that also need a chat thread. */ +export const composioDriveWithChatTest = + composioDriveFixtures.extend(chatThreadFixtures); diff --git a/surfsense_web/tests/helpers/api/chat.ts b/surfsense_web/tests/helpers/api/chat.ts new file mode 100644 index 000000000..f5e1f92bb --- /dev/null +++ b/surfsense_web/tests/helpers/api/chat.ts @@ -0,0 +1,67 @@ +import type { APIRequestContext } from "@playwright/test"; +import { authHeaders, BACKEND_URL } from "./auth"; + +export type ChatStreamEvent = { + type: string; + payload: unknown; +}; + +export type ChatStreamResult = { + assistantText: string; + events: ChatStreamEvent[]; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export async function streamChatToCompletion( + request: APIRequestContext, + token: string, + args: { searchSpaceId: number; threadId: number; query: string } +): Promise { + const response = await request.post(`${BACKEND_URL}/api/v1/new_chat`, { + headers: authHeaders(token), + data: { + chat_id: args.threadId, + search_space_id: args.searchSpaceId, + user_query: args.query, + }, + }); + if (!response.ok()) { + throw new Error( + `streamChatToCompletion failed (${response.status()}): ${await response.text()}` + ); + } + + const body = await response.text(); + let assistantText = ""; + let sawDone = false; + const events: ChatStreamEvent[] = []; + + for (const rawFrame of body.split("\n\n")) { + const frame = rawFrame.trim(); + if (!frame) continue; + if (!frame.startsWith("data: ")) continue; + + const payloadText = frame.slice("data: ".length); + if (payloadText === "[DONE]") { + sawDone = true; + events.push({ type: "done", payload: "[DONE]" }); + break; + } + + const payload = JSON.parse(payloadText) as unknown; + const type = isRecord(payload) && typeof payload.type === "string" ? payload.type : "unknown"; + if (type === "text-delta" && isRecord(payload) && typeof payload.delta === "string") { + assistantText += payload.delta; + } + events.push({ type, payload }); + } + + if (!sawDone) { + throw new Error(`Chat stream did not finish with [DONE]. Body: ${body.slice(0, 500)}`); + } + + return { assistantText, events }; +} diff --git a/surfsense_web/tests/smoke/chat-stream.spec.ts b/surfsense_web/tests/smoke/chat-stream.spec.ts new file mode 100644 index 000000000..20fe67f43 --- /dev/null +++ b/surfsense_web/tests/smoke/chat-stream.spec.ts @@ -0,0 +1,32 @@ +import { expect, test } from "../fixtures"; +import { authHeaders, BACKEND_URL } from "../helpers/api/auth"; +import { streamChatToCompletion } from "../helpers/api/chat"; + +test.describe("Smoke", () => { + test("chat stream completes for an unrelated query", async ({ + request, + apiToken, + searchSpace, + }) => { + const threadResponse = await request.post(`${BACKEND_URL}/api/v1/threads`, { + headers: authHeaders(apiToken), + data: { + title: "e2e-chat-stream-smoke", + search_space_id: searchSpace.id, + visibility: "PRIVATE", + }, + }); + expect(threadResponse.ok()).toBeTruthy(); + + const thread = (await threadResponse.json()) as { id: number }; + const chat = await streamChatToCompletion(request, apiToken, { + searchSpaceId: searchSpace.id, + threadId: thread.id, + query: "E2E_NO_RELEVANT_CONTENT_SMOKE", + }); + + expect(chat.events.some((event) => event.type === "done")).toBeTruthy(); + expect(chat.events.some((event) => event.type === "text-delta")).toBeTruthy(); + expect(chat.assistantText).toContain("No relevant indexed content found."); + }); +});