Merge pull request #1377 from AnishSarkar22/feat/e2e-testing-ci

feat: add E2E CI and harden Docker build migrations
This commit is contained in:
Rohan Verma 2026-05-15 04:47:26 -07:00 committed by GitHub
commit 4db3cf7fd5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
45 changed files with 1733 additions and 495 deletions

View file

@ -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/

View file

@ -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 ./

View file

@ -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);

View file

@ -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),
};
});

View file

@ -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": {

View file

@ -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,

View file

@ -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>) => {

View file

@ -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"
/>
);

View file

@ -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));

View file

@ -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[];

View file

@ -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";

View file

@ -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);

View file

@ -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",

View file

@ -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,
},
},
});

View 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

View file

@ -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 13 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

View file

@ -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 }) => {

View file

@ -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,

View file

@ -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();

View file

@ -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