mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-17 18:35:19 +02:00
Merge pull request #1377 from AnishSarkar22/feat/e2e-testing-ci
feat: add E2E CI and harden Docker build migrations
This commit is contained in:
commit
4db3cf7fd5
45 changed files with 1733 additions and 495 deletions
7
surfsense_web/.gitignore
vendored
7
surfsense_web/.gitignore
vendored
|
|
@ -12,6 +12,10 @@
|
|||
|
||||
# testing
|
||||
/coverage
|
||||
/playwright/.auth/
|
||||
/playwright-report/
|
||||
/test-results/
|
||||
/blob-report/
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
|
|
@ -48,5 +52,4 @@ next-env.d.ts
|
|||
# source
|
||||
/.source/
|
||||
|
||||
.pnpm-store/
|
||||
|
||||
.pnpm-store/
|
||||
|
|
@ -12,7 +12,7 @@ WORKDIR /app
|
|||
RUN corepack enable pnpm
|
||||
|
||||
# Copy package files
|
||||
COPY package.json pnpm-lock.yaml* .npmrc* ./
|
||||
COPY package.json pnpm-lock.yaml* pnpm-workspace.yaml* .npmrc* ./
|
||||
|
||||
# First copy the config file and content to avoid fumadocs-mdx postinstall error
|
||||
COPY source.config.ts ./
|
||||
|
|
|
|||
|
|
@ -208,7 +208,10 @@ const MentionedDocumentInfoSchema = z.object({
|
|||
id: z.number(),
|
||||
title: z.string(),
|
||||
document_type: z.string(),
|
||||
kind: z.union([z.literal("doc"), z.literal("folder")]).optional().default("doc"),
|
||||
kind: z
|
||||
.union([z.literal("doc"), z.literal("folder")])
|
||||
.optional()
|
||||
.default("doc"),
|
||||
});
|
||||
|
||||
const MentionedDocumentsPartSchema = z.object({
|
||||
|
|
@ -1029,9 +1032,7 @@ export default function NewChatPage() {
|
|||
mentioned_surfsense_doc_ids: hasSurfsenseDocIds
|
||||
? mentionedDocumentIds.surfsense_doc_ids
|
||||
: undefined,
|
||||
mentioned_folder_ids: hasFolderIds
|
||||
? mentionedDocumentIds.folder_ids
|
||||
: undefined,
|
||||
mentioned_folder_ids: hasFolderIds ? mentionedDocumentIds.folder_ids : undefined,
|
||||
// Full mention metadata (docs + folders, with
|
||||
// ``kind`` discriminator) so the BE can embed a
|
||||
// ``mentioned-documents`` ContentPart on the
|
||||
|
|
@ -1900,12 +1901,10 @@ export default function NewChatPage() {
|
|||
filesystem_mode: selection.filesystem_mode,
|
||||
client_platform: selection.client_platform,
|
||||
local_filesystem_mounts: selection.local_filesystem_mounts,
|
||||
mentioned_document_ids:
|
||||
regenerateDocIds.length > 0 ? regenerateDocIds : undefined,
|
||||
mentioned_document_ids: regenerateDocIds.length > 0 ? regenerateDocIds : undefined,
|
||||
mentioned_surfsense_doc_ids:
|
||||
regenerateSurfsenseDocIds.length > 0 ? regenerateSurfsenseDocIds : undefined,
|
||||
mentioned_folder_ids:
|
||||
regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
|
||||
mentioned_folder_ids: regenerateFolderIds.length > 0 ? regenerateFolderIds : undefined,
|
||||
// Full mention metadata for the regenerate-specific
|
||||
// source list. Only meaningful for edit (the BE only
|
||||
// re-persists a user row when ``user_query`` is set);
|
||||
|
|
|
|||
|
|
@ -97,9 +97,7 @@ export const mentionedDocumentIdsAtom = atom((get) => {
|
|||
surfsense_doc_ids: docs
|
||||
.filter((doc) => doc.document_type === "SURFSENSE_DOCS")
|
||||
.map((doc) => doc.id),
|
||||
document_ids: docs
|
||||
.filter((doc) => doc.document_type !== "SURFSENSE_DOCS")
|
||||
.map((doc) => doc.id),
|
||||
document_ids: docs.filter((doc) => doc.document_type !== "SURFSENSE_DOCS").map((doc) => doc.id),
|
||||
folder_ids: folders.map((f) => f.id),
|
||||
};
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,7 +7,19 @@
|
|||
},
|
||||
"files": {
|
||||
"ignoreUnknown": true,
|
||||
"includes": ["**", "!!node_modules", "!!.git", "!!.next", "!!dist", "!!build", "!!coverage"],
|
||||
"includes": [
|
||||
"**",
|
||||
"!!node_modules",
|
||||
"!!.git",
|
||||
"!!.next",
|
||||
"!!dist",
|
||||
"!!build",
|
||||
"!!coverage",
|
||||
"!!test-results",
|
||||
"!!playwright-report",
|
||||
"!!blob-report",
|
||||
"!!playwright/.auth"
|
||||
],
|
||||
"maxSize": 1048576
|
||||
},
|
||||
"formatter": {
|
||||
|
|
|
|||
|
|
@ -47,10 +47,7 @@ export interface InlineMentionEditorRef {
|
|||
setText: (text: string) => void;
|
||||
getText: () => string;
|
||||
getMentionedDocuments: () => MentionedDocument[];
|
||||
insertMentionChip: (
|
||||
mention: MentionChipInput,
|
||||
options?: { removeTriggerText?: boolean }
|
||||
) => void;
|
||||
insertMentionChip: (mention: MentionChipInput, options?: { removeTriggerText?: boolean }) => void;
|
||||
/**
|
||||
* @deprecated Use ``insertMentionChip``. Kept for one transition
|
||||
* cycle so we don't break ad-hoc callers; prefer the new name.
|
||||
|
|
@ -364,8 +361,7 @@ export const InlineMentionEditor = forwardRef<InlineMentionEditorRef, InlineMent
|
|||
const selection = editor.selection;
|
||||
const kind: MentionKind = mention.kind ?? "doc";
|
||||
const document_type =
|
||||
mention.document_type ??
|
||||
(kind === "folder" ? FOLDER_MENTION_DOCUMENT_TYPE : undefined);
|
||||
mention.document_type ?? (kind === "folder" ? FOLDER_MENTION_DOCUMENT_TYPE : undefined);
|
||||
const mentionNode: MentionElementNode = {
|
||||
type: MENTION_TYPE,
|
||||
id: mention.id,
|
||||
|
|
|
|||
|
|
@ -33,8 +33,8 @@ import {
|
|||
} from "@/components/ui/table";
|
||||
import { useElectronAPI } from "@/hooks/use-platform";
|
||||
import { documentsApiService } from "@/lib/apis/documents-api.service";
|
||||
import { type CitationUrlMap, preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
|
||||
import { getVirtualPathDisplay } from "@/lib/chat/virtual-path-display";
|
||||
import { type CitationUrlMap, preprocessCitationMarkdown } from "@/lib/citations/citation-parser";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function MarkdownCodeBlockSkeleton() {
|
||||
|
|
@ -222,11 +222,7 @@ function FilePathLink({ path, className }: { path: string; className?: string })
|
|||
: undefined;
|
||||
|
||||
const { displayName, isFolder } = getVirtualPathDisplay(path);
|
||||
const icon = isFolder ? (
|
||||
<FolderIcon className="size-3.5" />
|
||||
) : (
|
||||
<FileIcon className="size-3.5" />
|
||||
);
|
||||
const icon = isFolder ? <FolderIcon className="size-3.5" /> : <FileIcon className="size-3.5" />;
|
||||
|
||||
const handleClick = useCallback(
|
||||
(event: React.MouseEvent<HTMLButtonElement>) => {
|
||||
|
|
|
|||
|
|
@ -111,11 +111,7 @@ const UserTextPart: FC = () => {
|
|||
icon={icon}
|
||||
label={segment.doc.title}
|
||||
tooltip={isFolder ? `Folder: ${segment.doc.title}` : segment.doc.title}
|
||||
onClick={
|
||||
isFolder
|
||||
? undefined
|
||||
: () => handleOpenDoc(segment.doc.id, segment.doc.title)
|
||||
}
|
||||
onClick={isFolder ? undefined : () => handleOpenDoc(segment.doc.id, segment.doc.title)}
|
||||
className="mx-0.5"
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -170,16 +170,10 @@ export function PlateEditor({
|
|||
: markdown
|
||||
? (editor) => {
|
||||
if (!enableCitations) {
|
||||
return safeDeserializeMarkdown(
|
||||
editor,
|
||||
escapeMdxExpressions(markdown)
|
||||
) as Value;
|
||||
return safeDeserializeMarkdown(editor, escapeMdxExpressions(markdown)) as Value;
|
||||
}
|
||||
const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown);
|
||||
const value = safeDeserializeMarkdown(
|
||||
editor,
|
||||
escapeMdxExpressions(rewritten)
|
||||
);
|
||||
const value = safeDeserializeMarkdown(editor, escapeMdxExpressions(rewritten));
|
||||
return injectCitationNodes(value, urlMap) as Value;
|
||||
}
|
||||
: undefined,
|
||||
|
|
@ -203,10 +197,7 @@ export function PlateEditor({
|
|||
let newValue: Descendant[];
|
||||
if (enableCitations) {
|
||||
const { content: rewritten, urlMap } = preprocessCitationMarkdown(markdown);
|
||||
const deserialized = safeDeserializeMarkdown(
|
||||
editor,
|
||||
escapeMdxExpressions(rewritten)
|
||||
);
|
||||
const deserialized = safeDeserializeMarkdown(editor, escapeMdxExpressions(rewritten));
|
||||
newValue = injectCitationNodes(deserialized, urlMap);
|
||||
} else {
|
||||
newValue = safeDeserializeMarkdown(editor, escapeMdxExpressions(markdown));
|
||||
|
|
|
|||
|
|
@ -49,10 +49,7 @@ export function safeDeserializeMarkdown(
|
|||
return api.deserialize(markdown, { remarkPlugins: STRICT_PLUGINS }) as Descendant[];
|
||||
} catch (mdxError) {
|
||||
if (process.env.NODE_ENV !== "production") {
|
||||
console.warn(
|
||||
"[plate-editor] MDX parse failed, retrying without remark-mdx:",
|
||||
mdxError
|
||||
);
|
||||
console.warn("[plate-editor] MDX parse failed, retrying without remark-mdx:", mdxError);
|
||||
}
|
||||
try {
|
||||
return api.deserialize(markdown, { remarkPlugins: LENIENT_PLUGINS }) as Descendant[];
|
||||
|
|
|
|||
|
|
@ -24,10 +24,7 @@ import type React from "react";
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { agentFlagsAtom } from "@/atoms/agent/agent-flags-query.atom";
|
||||
import {
|
||||
makeFolderMention,
|
||||
mentionedDocumentsAtom,
|
||||
} from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { makeFolderMention, mentionedDocumentsAtom } from "@/atoms/chat/mentioned-documents.atom";
|
||||
import { connectorDialogOpenAtom } from "@/atoms/connector-dialog/connector-dialog.atoms";
|
||||
import { connectorsAtom } from "@/atoms/connectors/connector-query.atoms";
|
||||
import { deleteDocumentMutationAtom } from "@/atoms/documents/document-mutation.atoms";
|
||||
|
|
|
|||
|
|
@ -301,8 +301,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
// folder entries lift the existing kind-aware key so the same
|
||||
// matchers used by the chip atom apply unchanged.
|
||||
const selectedKeys = useMemo(
|
||||
() =>
|
||||
new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
|
||||
() => new Set(initialSelectedDocuments.map((d) => getMentionDocKey(d))),
|
||||
[initialSelectedDocuments]
|
||||
);
|
||||
|
||||
|
|
@ -583,9 +582,7 @@ export const DocumentMentionPicker = forwardRef<
|
|||
{(surfsenseDocsList.length > 0 || userDocsList.length > 0) && (
|
||||
<div className="mx-2 my-4 border-t border-border dark:border-white/5" />
|
||||
)}
|
||||
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">
|
||||
Folders
|
||||
</div>
|
||||
<div className="px-3 py-2 text-xs font-bold text-muted-foreground/55">Folders</div>
|
||||
{folderMentions.map((folder) => {
|
||||
const folderKey = getMentionDocKey(folder);
|
||||
const isAlreadySelected = selectedKeys.has(folderKey);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
"name": "surfsense_web",
|
||||
"version": "0.0.23",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.26.0",
|
||||
"description": "SurfSense Frontend",
|
||||
"scripts": {
|
||||
"dev": "next dev --turbopack",
|
||||
|
|
@ -20,6 +21,7 @@
|
|||
"db:studio": "drizzle-kit studio",
|
||||
"format:fix": "npx @biomejs/biome check --fix",
|
||||
"test:e2e": "playwright test",
|
||||
"test:e2e:prod": "cross-env CI=1 playwright test",
|
||||
"test:e2e:ui": "playwright test --ui",
|
||||
"test:e2e:headed": "playwright test --headed",
|
||||
"test:e2e:debug": "playwright test --debug",
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ const PORT = process.env.PORT || "3000";
|
|||
const BACKEND_PORT = process.env.BACKEND_PORT || "8000";
|
||||
const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://localhost:${PORT}`;
|
||||
|
||||
process.env.PLAYWRIGHT_TEST_EMAIL ??= "e2e-test@surfsense.net";
|
||||
process.env.PLAYWRIGHT_TEST_PASSWORD ??= "E2eTestPassword123!";
|
||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL ??= `http://localhost:${BACKEND_PORT}`;
|
||||
process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE ??= "LOCAL";
|
||||
|
||||
/**
|
||||
* Playwright configuration for SurfSense web E2E tests.
|
||||
*
|
||||
|
|
@ -22,8 +27,8 @@ export default defineConfig({
|
|||
expect: { timeout: 15_000 },
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
workers: 1,
|
||||
reporter: process.env.CI
|
||||
? [["html", { open: "never" }], ["github"], ["list"]]
|
||||
: [["html", { open: "on-failure" }], ["list"]],
|
||||
|
|
@ -31,7 +36,7 @@ export default defineConfig({
|
|||
baseURL,
|
||||
trace: "on-first-retry",
|
||||
screenshot: "only-on-failure",
|
||||
video: "retain-on-failure",
|
||||
video: process.env.CI ? "off" : "retain-on-failure",
|
||||
extraHTTPHeaders: {
|
||||
"x-playwright-test": "true",
|
||||
},
|
||||
|
|
@ -53,14 +58,16 @@ export default defineConfig({
|
|||
webServer: process.env.PLAYWRIGHT_NO_WEB_SERVER
|
||||
? undefined
|
||||
: {
|
||||
// Pin to webpack dev (Turbopack has caused stale-lock panics in E2E).
|
||||
command: "pnpm exec next dev",
|
||||
// Local stays on webpack dev (Turbopack caused stale-lock panics in E2E).
|
||||
command: process.env.CI ? "pnpm build && pnpm start" : "pnpm exec next dev",
|
||||
url: `http://localhost:${PORT}`,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
timeout: process.env.CI ? 300_000 : 180_000,
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
env: {
|
||||
NEXT_PUBLIC_FASTAPI_BACKEND_URL: `http://localhost:${BACKEND_PORT}`,
|
||||
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: "LOCAL",
|
||||
NEXT_PUBLIC_FASTAPI_BACKEND_URL: process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL,
|
||||
NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE: process.env.NEXT_PUBLIC_FASTAPI_BACKEND_AUTH_TYPE,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
11
surfsense_web/pnpm-workspace.yaml
Normal file
11
surfsense_web/pnpm-workspace.yaml
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
allowBuilds:
|
||||
"@parcel/watcher": true
|
||||
"@rocicorp/zero-sqlite3": true
|
||||
"@swc/core": true
|
||||
core-js: true
|
||||
esbuild: true
|
||||
protobufjs: true
|
||||
sharp: true
|
||||
unrs-resolver: true
|
||||
|
||||
minimumReleaseAge: 10080
|
||||
|
|
@ -5,29 +5,6 @@ Celery + Postgres + Redis). Designed to scale from one connector
|
|||
(Composio Drive in Phase 1) to every connector + manual file upload
|
||||
without rewriting the harness.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
tests/
|
||||
├── auth.setup.ts # one-time login, persists localStorage
|
||||
├── smoke/ # tracer-bullet tests (dashboard renders)
|
||||
├── connectors/
|
||||
│ └── composio/
|
||||
│ └── drive/ # Composio Google Drive — Phase 1
|
||||
│ └── journey.spec.ts # connect -> select -> index -> canary assertion
|
||||
├── fixtures/ # test.extend() fixtures
|
||||
│ ├── index.ts # named `test` exports per spec category
|
||||
│ ├── search-space.fixture.ts # apiToken + per-test search space
|
||||
│ └── connectors/
|
||||
│ └── composio-drive.fixture.ts
|
||||
├── helpers/ # reusable building blocks
|
||||
│ ├── api/ # backend HTTP helpers
|
||||
│ ├── ui/ # page-object selectors
|
||||
│ ├── waits/ # deterministic polling
|
||||
│ └── canary.ts # canary tokens + fixed Drive file ids
|
||||
└── README.md # this file
|
||||
```
|
||||
|
||||
## How the deterministic harness works
|
||||
|
||||
There are **three layers of defense** against accidental real-world
|
||||
|
|
@ -47,26 +24,90 @@ calls. None of them touch production code.
|
|||
|
||||
## Running locally
|
||||
|
||||
The recommended flow runs only Postgres and Redis in Docker, and the backend
|
||||
+ Celery worker on the host. The E2E entrypoints `setdefault` every backend
|
||||
variable they need, so no `.env` file is required on a fresh checkout.
|
||||
|
||||
### One-time setup
|
||||
|
||||
From `surfsense_web/`:
|
||||
|
||||
```bash
|
||||
# 1. Bring up Postgres + Redis (Docker compose, supabase, whatever you use)
|
||||
docker compose up -d postgres redis
|
||||
|
||||
# 2. Backend with E2E entrypoint (note: NOT `uv run main.py`)
|
||||
cd surfsense_backend
|
||||
uv run alembic upgrade head
|
||||
uv run python tests/e2e/run_backend.py &
|
||||
|
||||
# 3. Celery worker with the same entrypoint pattern
|
||||
uv run python tests/e2e/run_celery.py &
|
||||
|
||||
# 4. Run Playwright tests (auto-starts `pnpm dev` via webServer config)
|
||||
cd ../surfsense_web
|
||||
pnpm test:e2e
|
||||
pnpm install
|
||||
pnpm exec playwright install --with-deps chromium
|
||||
```
|
||||
|
||||
For CI behavior in one go: `pnpm test:e2e:headless`.
|
||||
### Each run
|
||||
|
||||
To debug the Drive journey: `pnpm test:e2e -- connectors/composio/drive/journey.spec.ts --headed`.
|
||||
**1. Bring up Postgres + Redis** from the repo root:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.deps-only.yml up -d db redis
|
||||
```
|
||||
|
||||
**2. Start the backend** in `surfsense_backend/`, terminal A:
|
||||
|
||||
```bash
|
||||
uv sync
|
||||
uv run alembic upgrade head
|
||||
uv run python tests/e2e/run_backend.py
|
||||
```
|
||||
|
||||
**3. Start the Celery worker** in `surfsense_backend/`, terminal B:
|
||||
|
||||
```bash
|
||||
uv run python tests/e2e/run_celery.py
|
||||
```
|
||||
|
||||
**4. Register the Playwright user**:
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:8000/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"e2e-test@surfsense.net","password":"E2eTestPassword123!"}'
|
||||
```
|
||||
|
||||
**5. Run Playwright** from `surfsense_web/`, terminal C:
|
||||
|
||||
```bash
|
||||
pnpm test:e2e # dev server (fast iteration)
|
||||
pnpm test:e2e:headed # show the browser
|
||||
pnpm test:e2e:ui # Playwright UI mode
|
||||
pnpm test:e2e:debug # Playwright Inspector
|
||||
pnpm test:e2e:prod # build + start (matches CI exactly)
|
||||
pnpm test:e2e:report # open the last HTML report
|
||||
```
|
||||
|
||||
`playwright.config.ts` and the backend run scripts share defaults, so the
|
||||
above works without exporting any env vars. Override
|
||||
`PLAYWRIGHT_TEST_EMAIL`, `PLAYWRIGHT_TEST_PASSWORD`, or
|
||||
`NEXT_PUBLIC_FASTAPI_BACKEND_URL` only when pointing tests at a different
|
||||
stack.
|
||||
|
||||
To debug a single journey:
|
||||
|
||||
```bash
|
||||
pnpm test:e2e:headed connectors/composio/drive/journey.spec.ts
|
||||
```
|
||||
|
||||
### Hermetic alternative (matches CI)
|
||||
|
||||
To reproduce the CI environment exactly: backend and Celery in containers
|
||||
with L3 egress denied, replace steps 1–3 with:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.e2e.yml up -d --build --wait
|
||||
```
|
||||
|
||||
Then run steps 4 (curl register) and 5 (`pnpm test:e2e:prod`) as above. Tear
|
||||
down with:
|
||||
|
||||
```bash
|
||||
docker compose -f docker/docker-compose.e2e.yml down -v --remove-orphans
|
||||
```
|
||||
|
||||
This builds the ~9 GB e2e backend image, so the deps-only flow is faster for
|
||||
day-to-day work.
|
||||
|
||||
## Adding a new connector
|
||||
|
||||
|
|
|
|||
|
|
@ -1,47 +1,21 @@
|
|||
import path from "node:path";
|
||||
import { expect, test as setup } from "@playwright/test";
|
||||
import { acquireTestToken } from "./helpers/api/auth";
|
||||
|
||||
/**
|
||||
* One-time authentication setup. Logs in via the FastAPI backend directly
|
||||
* (skipping the UI) and persists the resulting localStorage token so every
|
||||
* test in the chromium project starts already authenticated.
|
||||
*
|
||||
* Mirrors the real auth flow in `lib/apis/auth-api.service.ts`:
|
||||
* POST /auth/jwt/login -> { access_token }
|
||||
* localStorage.setItem("surfsense_bearer_token", access_token)
|
||||
*
|
||||
* Requires a seeded test user in the dev/test DB. Configure via env:
|
||||
* PLAYWRIGHT_TEST_EMAIL, PLAYWRIGHT_TEST_PASSWORD
|
||||
* NEXT_PUBLIC_FASTAPI_BACKEND_URL (defaults to http://localhost:8000)
|
||||
* One-time authentication setup. Acquires a bearer token for the seeded
|
||||
* e2e user (rate-limit-free /__e2e__/auth/token first, /auth/jwt/login
|
||||
* fallback) and persists it via localStorage so every test in the
|
||||
* chromium project starts already authenticated.
|
||||
*/
|
||||
|
||||
const authFile = path.join(__dirname, "..", "playwright", ".auth", "user.json");
|
||||
|
||||
const TEST_USER_EMAIL = process.env.PLAYWRIGHT_TEST_EMAIL || "test@surfsense.net";
|
||||
const TEST_USER_PASSWORD = process.env.PLAYWRIGHT_TEST_PASSWORD || "TestPassword123!";
|
||||
const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
const STORAGE_KEY = "surfsense_bearer_token";
|
||||
|
||||
setup("authenticate", async ({ page, request }) => {
|
||||
const response = await request.post(`${BACKEND_URL}/auth/jwt/login`, {
|
||||
form: {
|
||||
username: TEST_USER_EMAIL,
|
||||
password: TEST_USER_PASSWORD,
|
||||
grant_type: "password",
|
||||
},
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
});
|
||||
|
||||
expect(
|
||||
response.ok(),
|
||||
`Login to ${BACKEND_URL}/auth/jwt/login failed (${response.status()}). ` +
|
||||
`Check that the backend is running and that PLAYWRIGHT_TEST_EMAIL ` +
|
||||
`(${TEST_USER_EMAIL}) is seeded with PLAYWRIGHT_TEST_PASSWORD. ` +
|
||||
`Body: ${await response.text()}`
|
||||
).toBeTruthy();
|
||||
|
||||
const { access_token } = (await response.json()) as { access_token: string };
|
||||
expect(access_token, "Backend response missing access_token").toBeTruthy();
|
||||
const access_token = await acquireTestToken(request);
|
||||
expect(access_token, "Failed to acquire e2e bearer token").toBeTruthy();
|
||||
|
||||
await page.addInitScript(
|
||||
({ key, token }) => {
|
||||
|
|
|
|||
|
|
@ -107,14 +107,14 @@ test.describe("Manual file upload journey", () => {
|
|||
});
|
||||
});
|
||||
|
||||
test("user uploads a PDF (DOCUMENT branch via real Docling)", async ({
|
||||
test("user uploads a PDF (DOCUMENT branch)", async ({
|
||||
page,
|
||||
request,
|
||||
apiToken,
|
||||
searchSpace,
|
||||
chatThread,
|
||||
}) => {
|
||||
test.setTimeout(240_000); // Docling cold-start can take 30-60s on first invocation.
|
||||
test.setTimeout(180_000);
|
||||
|
||||
await uploadAndAssert({
|
||||
page,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { test as base } from "@playwright/test";
|
||||
import { loginAsTestUser } from "../helpers/api/auth";
|
||||
import { acquireTestToken } from "../helpers/api/auth";
|
||||
import {
|
||||
createSearchSpace,
|
||||
deleteSearchSpace,
|
||||
|
|
@ -20,12 +22,45 @@ export type SearchSpaceFixtures = {
|
|||
searchSpace: SearchSpaceRow;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = "surfsense_bearer_token";
|
||||
|
||||
// Reuse the token written by tests/auth.setup.ts; on cache miss we
|
||||
// mint a fresh one via /__e2e__/auth/token (rate-limit-free).
|
||||
const AUTH_STATE_PATH = path.join(__dirname, "..", "..", "playwright", ".auth", "user.json");
|
||||
|
||||
function loadCachedBearerToken(): string | null {
|
||||
try {
|
||||
const raw = fs.readFileSync(AUTH_STATE_PATH, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
origins?: Array<{
|
||||
origin?: string;
|
||||
localStorage?: Array<{ name?: string; value?: string }>;
|
||||
}>;
|
||||
};
|
||||
for (const origin of parsed.origins ?? []) {
|
||||
for (const entry of origin.localStorage ?? []) {
|
||||
if (entry.name === STORAGE_KEY && entry.value) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall back to a fresh login.
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export const searchSpaceFixtures = base.extend<SearchSpaceFixtures, { apiTokenWorker: string }>({
|
||||
apiTokenWorker: [
|
||||
async ({ playwright }, use) => {
|
||||
const cached = loadCachedBearerToken();
|
||||
if (cached) {
|
||||
await use(cached);
|
||||
return;
|
||||
}
|
||||
const ctx = await playwright.request.newContext();
|
||||
try {
|
||||
const token = await loginAsTestUser(ctx);
|
||||
const token = await acquireTestToken(ctx);
|
||||
await use(token);
|
||||
} finally {
|
||||
await ctx.dispose();
|
||||
|
|
|
|||
|
|
@ -11,8 +11,39 @@ import type { APIRequestContext } from "@playwright/test";
|
|||
|
||||
export const BACKEND_URL = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
|
||||
|
||||
const TEST_USER_EMAIL = process.env.PLAYWRIGHT_TEST_EMAIL || "test@surfsense.net";
|
||||
const TEST_USER_PASSWORD = process.env.PLAYWRIGHT_TEST_PASSWORD || "TestPassword123!";
|
||||
const TEST_USER_EMAIL = process.env.PLAYWRIGHT_TEST_EMAIL || "e2e-test@surfsense.net";
|
||||
const TEST_USER_PASSWORD = process.env.PLAYWRIGHT_TEST_PASSWORD || "E2eTestPassword123!";
|
||||
const E2E_MINT_SECRET = process.env.E2E_MINT_SECRET || "local-e2e-mint-secret-not-for-production";
|
||||
|
||||
/**
|
||||
* Mints a JWT for the seeded e2e user via the test-only endpoint mounted
|
||||
* by surfsense_backend/tests/e2e/run_backend.py. Bypasses the production
|
||||
* /auth/jwt/login rate limit (5/min/IP), so it's safe to call from any
|
||||
* worker / retry. Returns 404 from the backend when the endpoint isn't
|
||||
* mounted (i.e. someone is pointing the suite at a non-e2e backend).
|
||||
*/
|
||||
export async function mintTestToken(
|
||||
request: APIRequestContext,
|
||||
email: string = TEST_USER_EMAIL
|
||||
): Promise<string> {
|
||||
const response = await request.post(`${BACKEND_URL}/__e2e__/auth/token`, {
|
||||
data: { email },
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-E2E-Mint-Secret": E2E_MINT_SECRET,
|
||||
},
|
||||
});
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`Mint token at ${BACKEND_URL}/__e2e__/auth/token failed (${response.status()}): ${await response.text()}`
|
||||
);
|
||||
}
|
||||
const { access_token } = (await response.json()) as { access_token: string };
|
||||
if (!access_token) {
|
||||
throw new Error("Mint response missing access_token");
|
||||
}
|
||||
return access_token;
|
||||
}
|
||||
|
||||
export async function loginAsTestUser(request: APIRequestContext): Promise<string> {
|
||||
const response = await request.post(`${BACKEND_URL}/auth/jwt/login`, {
|
||||
|
|
@ -37,6 +68,23 @@ export async function loginAsTestUser(request: APIRequestContext): Promise<strin
|
|||
return access_token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a bearer token by trying the rate-limit-free mint endpoint first
|
||||
* and falling back to /auth/jwt/login if the e2e endpoint isn't mounted
|
||||
* (e.g. running against a non-e2e backend in local dev).
|
||||
*/
|
||||
export async function acquireTestToken(request: APIRequestContext): Promise<string> {
|
||||
try {
|
||||
return await mintTestToken(request);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
if (msg.includes("(404)") || msg.includes("(405)")) {
|
||||
return loginAsTestUser(request);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Standard auth headers for backend API calls. Optionally injects an
|
||||
* X-E2E-Scenario header that the test-only ScenarioMiddleware in
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue