diff --git a/surfsense_web/tests/connectors/composio/drive/README.md b/surfsense_web/tests/connectors/composio/drive/README.md new file mode 100644 index 000000000..3e5d6f5c5 --- /dev/null +++ b/surfsense_web/tests/connectors/composio/drive/README.md @@ -0,0 +1,56 @@ +# Composio Google Drive — E2E + +Phase 1 Playwright coverage for the Composio Drive connector. + +## Journey in this folder + +| File | User expectation | +| --- | --- | +| `journey.spec.ts` | "I connect Google Drive, choose a file, wait for indexing, and SurfSense contains that file's content." | + +## What "passes" actually proves + +- The dashboard and connector dialog render for the authenticated user. +- The Composio Drive connector fixture can complete the happy OAuth setup. +- The selected Drive file config can be persisted. +- Pipeline service summarizes/embeds/chunks an indexed file end-to-end. +- Celery worker is reachable from the FastAPI process (queue + broker). +- `Document.content` contains the Drive canary token after indexing. + +## Edge cases tested elsewhere + +Playwright does not own backend edge cases. They are cheaper and easier +to localize in pytest: + +- OAuth state freshness/tamper/malformed validation: + `surfsense_backend/tests/unit/utils/test_oauth_security.py` +- OAuth denied callback and duplicate/reconnection branch: + `surfsense_backend/tests/integration/composio/test_oauth_callback.py` +- Folder listing, selected file config persistence, and auth-expired + classification: + `surfsense_backend/tests/integration/composio/test_drive_folders_route.py` + +## What "passes" does NOT prove + +- Real Composio.dev integration (mocked). +- Real LLM summarization quality (`FakeListChatModel`). +- Real embedding semantics (constant 0.1 vectors). + +These are intentional. Phase 1's deal is "the user-visible Drive +journey crosses the connector/indexing seams". Phase 2 can add opt-in +"live LLM" smoke tests under a separate workflow and a separate budget. + +## Adding a fourth Composio toolkit (e.g. Slack) + +1. Add fixture data to + `surfsense_backend/tests/e2e/fakes/fixtures/_*.json`. +2. Extend `_Tools.execute()` in + `surfsense_backend/tests/e2e/fakes/composio_module.py` to handle the + new toolkit's tool slugs (`SLACK_FETCH_CONVERSATIONS`, etc.). +3. Add the toolkit to `_AuthConfigs.list()`. +4. Drop a sibling folder `tests/connectors/composio//` with + one `journey.spec.ts` that matches the user's expectation for that + toolkit. + +The fixtures in `tests/fixtures/connectors/composio-drive.fixture.ts` +are the template — copy + change `toolkit_id`. diff --git a/surfsense_web/tests/connectors/composio/drive/journey.spec.ts b/surfsense_web/tests/connectors/composio/drive/journey.spec.ts new file mode 100644 index 000000000..e97532f6a --- /dev/null +++ b/surfsense_web/tests/connectors/composio/drive/journey.spec.ts @@ -0,0 +1,105 @@ +import { composioDriveTest as test, expect } from "../../../fixtures"; +import { listConnectors, triggerIndex, updateConnectorConfig } from "../../../helpers/api/connectors"; +import { listDocuments } from "../../../helpers/api/documents"; +import { CANARY_TOKENS, FAKE_DRIVE_FILES } from "../../../helpers/canary"; +import { openConnectorPopup } from "../../../helpers/ui/connector-popup"; +import { + waitForDocumentByTitle, + waitForIndexingComplete, +} from "../../../helpers/waits/indexing"; + +/** + * Composio Drive user journey. + * + * User expectation: + * "I connect Google Drive, choose the files/folders I care about, + * wait for indexing, and then my Drive content is available in SurfSense." + * + * The OAuth connection is handled by the composioDriveConnector fixture so + * this test can focus on the user-visible expectation. The spec still touches + * the browser (dashboard + connector dialog) and then uses API helpers for + * selection/indexing to keep the expensive pipeline assertion deterministic. + * + * If this passes, the seam from Composio connection -> selection persistence -> + * Celery indexing -> document storage is wired correctly. + */ +test.describe("Composio Drive journey", () => { + test( + "user connects Drive, selects a file, and sees it indexed with the canary token", + async ({ page, request, apiToken, searchSpace, composioDriveConnector }) => { + test.setTimeout(180_000); // worker cold-start + summarize + embed + chunk + + await page.goto(`/dashboard/${searchSpace.id}/new-chat`, { + waitUntil: "domcontentloaded", + }); + await openConnectorPopup(page); + await expect( + page + .getByRole("dialog", { name: "Manage Connectors" }) + .getByText("Search your Drive files via Composio") + ).toBeVisible(); + + await updateConnectorConfig(request, apiToken, composioDriveConnector.id, { + ...composioDriveConnector.config, + selected_folders: [], + selected_files: [ + { + id: FAKE_DRIVE_FILES.canary.id, + name: FAKE_DRIVE_FILES.canary.name, + mimeType: FAKE_DRIVE_FILES.canary.mimeType, + }, + ], + indexing_options: { + max_files_per_folder: 10, + incremental_sync: false, + include_subfolders: false, + }, + }); + + await triggerIndex(request, apiToken, composioDriveConnector.id, searchSpace.id, { + files: [ + { + id: FAKE_DRIVE_FILES.canary.id, + name: FAKE_DRIVE_FILES.canary.name, + mimeType: FAKE_DRIVE_FILES.canary.mimeType, + }, + ], + indexing_options: { + max_files_per_folder: 10, + incremental_sync: false, + include_subfolders: false, + }, + }); + + await waitForIndexingComplete(request, apiToken, composioDriveConnector.id, searchSpace.id, { + timeoutMs: 150_000, + intervalMs: 1_500, + minDocuments: 1, + }); + + await waitForDocumentByTitle( + request, + apiToken, + searchSpace.id, + FAKE_DRIVE_FILES.canary.name, + { timeoutMs: 30_000 } + ); + + const docs = await listDocuments(request, apiToken, searchSpace.id); + const canaryDoc = docs.find((d) => d.title === FAKE_DRIVE_FILES.canary.name); + + expect(canaryDoc, "canary document must exist after indexing").toBeDefined(); + + const content = canaryDoc!.content ?? ""; + expect( + content, + `canary token ${CANARY_TOKENS.driveCanaryFile} should appear in Document.content; ` + + `got first 200 chars: ${content.slice(0, 200)}` + ).toContain(CANARY_TOKENS.driveCanaryFile); + + const refreshedConnectors = await listConnectors(request, apiToken, searchSpace.id); + const refreshed = refreshedConnectors.find((c) => c.id === composioDriveConnector.id); + expect(refreshed?.last_indexed_at).not.toBeNull(); + } + ); +}); diff --git a/surfsense_web/tests/fixtures/connectors/composio-drive.fixture.ts b/surfsense_web/tests/fixtures/connectors/composio-drive.fixture.ts new file mode 100644 index 000000000..1ba9bcb9d --- /dev/null +++ b/surfsense_web/tests/fixtures/connectors/composio-drive.fixture.ts @@ -0,0 +1,37 @@ +import { + type ConnectorRow, + deleteConnector, + runComposioOAuth, +} from "../../helpers/api/connectors"; +import { searchSpaceFixtures } from "../search-space.fixture"; + +export type ComposioDriveFixtures = { + /** + * A pre-connected Composio Google Drive connector inside the + * fixture's `searchSpace`. OAuth happens against the strict fake + * (no real network). Cleaned up automatically after the test. + */ + composioDriveConnector: ConnectorRow; +}; + +export const composioDriveFixtures = searchSpaceFixtures.extend({ + composioDriveConnector: async ({ request, apiToken, searchSpace }, use) => { + const { connector } = await runComposioOAuth( + request, + apiToken, + searchSpace.id, + "googledrive" + ); + if (!connector) { + throw new Error( + "composioDriveConnector 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 new file mode 100644 index 000000000..84d4606c8 --- /dev/null +++ b/surfsense_web/tests/fixtures/index.ts @@ -0,0 +1,28 @@ +/** + * Central export of all Playwright fixtures used across the E2E suite. + * + * Specs import `test` and `expect` from here instead of `@playwright/test` + * directly so that adding a new fixture (e.g. for a new connector) is a + * one-line change for every spec that needs it. + * + * Inheritance chain: + * base (@playwright/test) + * └─ searchSpaceFixtures — apiToken, searchSpace + * └─ composioDriveFixtures — composioDriveConnector + * + * To add a new connector (Gmail, Slack, manual upload, etc.): + * 1. Add a fixture file under `fixtures/connectors/.fixture.ts`. + * 2. Re-export it here under a new typed `test` if the new fixture + * doesn't compose cleanly into the existing chain. + */ +export { expect } from "@playwright/test"; +export { searchSpaceFixtures } from "./search-space.fixture"; +export { composioDriveFixtures } from "./connectors/composio-drive.fixture"; + +import { composioDriveFixtures } from "./connectors/composio-drive.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; diff --git a/surfsense_web/tests/helpers/mocks/composio-oauth.ts b/surfsense_web/tests/helpers/mocks/composio-oauth.ts new file mode 100644 index 000000000..6924a4715 --- /dev/null +++ b/surfsense_web/tests/helpers/mocks/composio-oauth.ts @@ -0,0 +1,25 @@ +import type { Page } from "@playwright/test"; + +/** + * Frontend route mock for the Composio OAuth redirect. + * + * In normal E2E runs we DON'T need this: the backend Composio fake + * returns a same-origin auth_url that lands directly on our callback, + * so the browser never navigates to composio.dev. + * + * Reserved here for future negative tests that intentionally exercise + * a tampered/external auth_url (e.g. validating that the frontend + * doesn't blindly follow off-origin redirects). + */ +export async function mockComposioOAuthRedirect( + page: Page, + options: { rewriteTo: string } +): Promise { + await page.route(/composio\.dev/, async (route) => { + await route.fulfill({ + status: 302, + headers: { Location: options.rewriteTo }, + body: "", + }); + }); +} diff --git a/surfsense_web/tests/helpers/ui/composio-drive-config.ts b/surfsense_web/tests/helpers/ui/composio-drive-config.ts new file mode 100644 index 000000000..6fc404f30 --- /dev/null +++ b/surfsense_web/tests/helpers/ui/composio-drive-config.ts @@ -0,0 +1,14 @@ +import type { Page } from "@playwright/test"; + +/** + * Placeholder for the Composio Drive configuration view (folder + * selector + indexing options) rendered by ConnectorEditView. + * + * Phase 1 specs drive folder/file selection through the API helper + * (`updateConnectorConfig`) and `triggerIndex` for determinism. UI- + * level interaction with the folder tree is a Phase 2 task; this + * module is reserved for those selectors. + */ +export async function reservedForPhaseTwo(_page: Page): Promise { + // Intentionally empty. See README in tests/connectors/composio/drive/. +} diff --git a/surfsense_web/tests/helpers/ui/connector-status.ts b/surfsense_web/tests/helpers/ui/connector-status.ts new file mode 100644 index 000000000..49c617dad --- /dev/null +++ b/surfsense_web/tests/helpers/ui/connector-status.ts @@ -0,0 +1,10 @@ +import type { Page } from "@playwright/test"; + +/** + * Selectors for connector status indicators (last_indexed_at badge, + * indexing spinner, auth-expired banner). Reserved for Phase 2 UI-level + * assertions; Phase 1 specs assert these via the API. + */ +export async function reservedForPhaseTwo(_page: Page): Promise { + // Intentionally empty. +}