diff --git a/surfsense_web/tests/connectors/composio/gmail/journey.spec.ts b/surfsense_web/tests/connectors/composio/gmail/journey.spec.ts new file mode 100644 index 000000000..a8d22a082 --- /dev/null +++ b/surfsense_web/tests/connectors/composio/gmail/journey.spec.ts @@ -0,0 +1,87 @@ +import { expect, composioGmailWithChatTest as test } from "../../../fixtures"; +import { streamChatToCompletion } from "../../../helpers/api/chat"; +import { + listConnectors, + triggerIndexByDateRange, +} from "../../../helpers/api/connectors"; +import { getEditorContent, listDocuments } from "../../../helpers/api/documents"; +import { CANARY_TOKENS, FAKE_GMAIL_MESSAGES } from "../../../helpers/canary"; +import { openConnectorPopup } from "../../../helpers/ui/connector-popup"; +import { waitForDocumentByTitle, waitForIndexingComplete } from "../../../helpers/waits/indexing"; + +/** + * Proves the Gmail wiring from OAuth fixture -> date-range indexing -> + * Gmail message markdown -> stored source_markdown -> chat. + * + * Unlike Drive, Gmail has no file/folder selection config. The E2E + * hits the same date-range index contract the production route uses. + */ +test.describe("Composio Gmail journey", () => { + test("user connects Gmail, indexes messages, and chats with the canary token", async ({ + page, + request, + apiToken, + searchSpace, + composioGmailConnector, + chatThread, + }) => { + test.setTimeout(180_000); // worker cold-start + summarize + embed + chunk + + await page.goto(`/dashboard/${searchSpace.id}/new-chat`, { + waitUntil: "domcontentloaded", + }); + await openConnectorPopup(page); + const connectorDialog = page.getByRole("dialog", { name: "Manage Connectors" }); + await expect(connectorDialog).toBeVisible(); + await expect(connectorDialog.getByRole("button", { name: "Manage" })).toBeVisible(); + + await triggerIndexByDateRange(request, apiToken, composioGmailConnector.id, searchSpace.id, { + startDate: "2025-01-01", + endDate: "2026-12-31", + }); + + await waitForIndexingComplete(request, apiToken, composioGmailConnector.id, searchSpace.id, { + timeoutMs: 150_000, + intervalMs: 1_500, + minDocuments: 1, + }); + + await waitForDocumentByTitle( + request, + apiToken, + searchSpace.id, + FAKE_GMAIL_MESSAGES.canary.subject, + { timeoutMs: 30_000 } + ); + + const docs = await listDocuments(request, apiToken, searchSpace.id); + const canaryDoc = docs.find((d) => d.title === FAKE_GMAIL_MESSAGES.canary.subject); + + expect(canaryDoc, "canary Gmail document must exist after indexing").toBeDefined(); + if (!canaryDoc) throw new Error("unreachable: canaryDoc asserted defined above"); + + const editor = await getEditorContent(request, apiToken, searchSpace.id, canaryDoc.id); + expect( + editor.source_markdown, + `canary token ${CANARY_TOKENS.gmailCanary} should appear in editor source_markdown; ` + + `got first 200 chars: ${editor.source_markdown.slice(0, 200)}` + ).toContain(CANARY_TOKENS.gmailCanary); + expect(editor.source_markdown).toContain(`**From:** ${FAKE_GMAIL_MESSAGES.canary.from}`); + expect(editor.source_markdown).toContain("## Message Content"); + expect(editor.chunk_count).toBeGreaterThan(0); + + const refreshedConnectors = await listConnectors(request, apiToken, searchSpace.id); + const refreshed = refreshedConnectors.find((c) => c.id === composioGmailConnector.id); + expect(refreshed?.last_indexed_at).not.toBeNull(); + + const chat = await streamChatToCompletion(request, apiToken, { + searchSpaceId: searchSpace.id, + threadId: chatThread.id, + query: `What is in my Gmail message titled "${FAKE_GMAIL_MESSAGES.canary.subject}"?`, + }); + expect( + chat.assistantText, + `chat agent should surface Gmail canary token after indexing; got: ${chat.assistantText.slice(0, 200)}` + ).toContain(CANARY_TOKENS.gmailCanary); + }); +}); diff --git a/surfsense_web/tests/fixtures/connectors/composio-gmail.fixture.ts b/surfsense_web/tests/fixtures/connectors/composio-gmail.fixture.ts new file mode 100644 index 000000000..04c93b08e --- /dev/null +++ b/surfsense_web/tests/fixtures/connectors/composio-gmail.fixture.ts @@ -0,0 +1,37 @@ +import { + type ConnectorRow, + deleteConnector, + runComposioOAuth, +} from "../../helpers/api/connectors"; +import { searchSpaceFixtures } from "../search-space.fixture"; + +export type ComposioGmailFixtures = { + /** + * A pre-connected Composio Gmail connector inside the fixture's + * `searchSpace`. OAuth happens against the strict fake (no real + * network). Cleaned up automatically after the test. + */ + composioGmailConnector: ConnectorRow; +}; + +export const composioGmailFixtures = searchSpaceFixtures.extend({ + composioGmailConnector: async ({ request, apiToken, searchSpace }, use) => { + const { connector } = await runComposioOAuth( + request, + apiToken, + searchSpace.id, + "gmail" + ); + if (!connector) { + throw new Error( + "composioGmailConnector fixture: OAuth completed but no connector was created. " + + "Check the backend log — the strict Composio fake likely rejected an unmodelled call." + ); + } + try { + await use(connector); + } finally { + await deleteConnector(request, apiToken, connector.id); + } + }, +}); diff --git a/surfsense_web/tests/fixtures/index.ts b/surfsense_web/tests/fixtures/index.ts index 25df153cf..411abb69a 100644 --- a/surfsense_web/tests/fixtures/index.ts +++ b/surfsense_web/tests/fixtures/index.ts @@ -8,8 +8,10 @@ * Inheritance chain: * base (@playwright/test) * └─ searchSpaceFixtures — apiToken, searchSpace - * └─ composioDriveFixtures — composioDriveConnector - * └─ composioDriveWithChatTest — chatThread + * ├─ composioDriveFixtures — composioDriveConnector + * │ └─ composioDriveWithChatTest — chatThread + * └─ composioGmailFixtures — composioGmailConnector + * └─ composioGmailWithChatTest — chatThread * * To add a new connector (Gmail, Slack, manual upload, etc.): * 1. Add a fixture file under `fixtures/connectors/.fixture.ts`. @@ -19,16 +21,23 @@ export { expect } from "@playwright/test"; export { chatThreadFixtures } from "./chat-thread.fixture"; export { composioDriveFixtures } from "./connectors/composio-drive.fixture"; +export { composioGmailFixtures } from "./connectors/composio-gmail.fixture"; export { searchSpaceFixtures } from "./search-space.fixture"; import { type ChatThreadFixtures, chatThreadFixtures } from "./chat-thread.fixture"; import { composioDriveFixtures } from "./connectors/composio-drive.fixture"; +import { composioGmailFixtures } from "./connectors/composio-gmail.fixture"; import { searchSpaceFixtures } from "./search-space.fixture"; /** Default `test` for specs that just need auth + a clean search space. */ 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. */ +/** `test` for Drive specs that also need a chat thread. */ export const composioDriveWithChatTest = composioDriveFixtures.extend(chatThreadFixtures); +/** `test` for specs that also need a pre-connected Composio Gmail connector. */ +export const composioGmailTest = composioGmailFixtures; +/** `test` for Gmail specs that also need a chat thread. */ +export const composioGmailWithChatTest = + composioGmailFixtures.extend(chatThreadFixtures); diff --git a/surfsense_web/tests/helpers/api/connectors.ts b/surfsense_web/tests/helpers/api/connectors.ts index 3e270f2dc..4040591ef 100644 --- a/surfsense_web/tests/helpers/api/connectors.ts +++ b/surfsense_web/tests/helpers/api/connectors.ts @@ -128,6 +128,35 @@ export async function triggerIndex( return { ok: true }; } +export async function triggerIndexByDateRange( + request: APIRequestContext, + token: string, + connectorId: number, + searchSpaceId: number, + options: { startDate?: string; endDate?: string } = {} +): Promise<{ ok: true }> { + const params = new URLSearchParams({ search_space_id: String(searchSpaceId) }); + if (options.startDate) params.set("start_date", options.startDate); + if (options.endDate) params.set("end_date", options.endDate); + + const response = await request.post( + `${BACKEND_URL}/api/v1/search-source-connectors/${connectorId}/index?${params.toString()}`, + { headers: authHeaders(token) } + ); + if (!response.ok()) { + throw new Error( + `triggerIndexByDateRange(${connectorId}) failed (${response.status()}): ${await response.text()}` + ); + } + const body = (await response.json()) as { indexing_started?: boolean; message?: string }; + if (body.indexing_started === false) { + throw new Error( + `triggerIndexByDateRange(${connectorId}) did not start indexing: ${body.message ?? "no message"}` + ); + } + return { ok: true }; +} + /** * Drives the OAuth flow for a Composio toolkit programmatically. * diff --git a/surfsense_web/tests/helpers/canary.ts b/surfsense_web/tests/helpers/canary.ts index 8033065c8..8f5551798 100644 --- a/surfsense_web/tests/helpers/canary.ts +++ b/surfsense_web/tests/helpers/canary.ts @@ -18,6 +18,7 @@ export const CANARY_TOKENS = { driveBudget: "SURFSENSE_E2E_BUDGET_MARKER", driveRoadmap: "SURFSENSE_E2E_ROADMAP_MARKER", driveArchive: "SURFSENSE_E2E_ARCHIVE_MARKER", + gmailCanary: "SURFSENSE_E2E_CANARY_TOKEN_GMAIL_001", } as const; /** @@ -43,6 +44,27 @@ export const FAKE_DRIVE_FOLDERS = { }, } as const; +/** + * Fake Gmail message IDs that match what the backend fake returns from + * GMAIL_FETCH_EMAILS / GMAIL_FETCH_MESSAGE_BY_MESSAGE_ID. + */ +export const FAKE_GMAIL_MESSAGES = { + canary: { + id: "fake-msg-canary-001", + threadId: "fake-thread-canary-001", + subject: "E2E Canary Email", + from: "sender@surfsense.example", + to: "e2e-fake@surfsense.example", + }, + planning: { + id: "fake-msg-planning-001", + threadId: "fake-thread-planning-001", + subject: "E2E Planning Notes", + from: "planner@surfsense.example", + to: "e2e-fake@surfsense.example", + }, +} as const; + /** Generate a unique-per-run search space name. Keeps parallel tests isolated. */ export function uniqueSearchSpaceName(prefix = "e2e"): string { return `${prefix}-${randomUUID().slice(0, 8)}`;