Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,9 @@ In-memory sliding window in `app/lib/rate-limit.server.ts`. Used for chat (20/mi

**Unit tests** use Vitest (`bun run test`). Test files live alongside source files as `*.test.ts`. Modules that import server-side dependencies (auth, Prisma) need `vi.mock()` to avoid env validation side effects.

**E2E tests** use Playwright (`bun run test:e2e`) in `tests/`. Auth setup project runs first and saves `storageState` to `test-results/.auth/user.json` -- all browser projects reuse this so tests don't re-login. Test fixtures in `tests/fixtures.ts` export `test` (with `authedPage` fixture), `expect`, and `TEST_USER`. Chat tests mock `/api/chat` with canned SSE responses (no AI service needed).
**E2E tests** use Playwright (`bun run test:e2e`) in `tests/`, covering auth, navigation, dashboard, healthcheck, the chat flow, agent tool rendering, chat error UX, the `/api/chat` API boundary, and cross-user thread access control. They run against a dedicated dev server on port `7778` (override with `E2E_PORT`) so they never collide with `bun run dev` on 5173; the `webServer` config also points `BETTER_AUTH_BASE_URL`/`VITE_BETTER_AUTH_BASE_URL` at that port and sets `DISABLE_AUTH_RATE_LIMIT=true` plus a dummy `ANTHROPIC_API_KEY`.

Auth is explicit per test: the `authedPage` fixture in `tests/fixtures.ts` signs up a brand-new isolated user on demand (so every test starts with zero threads and parallel runs never share state), while a plain `page` stays logged out. `globalSetup` only ensures the seed users (Alice, Bob) exist for tests that log in as them. Fixtures also export `createAuthedContext` and `createThreadViaApi` for multi-user scenarios. Chat tests mock `/api/chat` with canned SSE responses (no AI service needed); tool-rendering tests stream `dynamic-tool` parts via helpers in `tests/chat-mock.ts`.

## Conventions

Expand Down
6 changes: 4 additions & 2 deletions app/lib/auth.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,9 +40,11 @@ export const auth = betterAuth({
},
plugins: [admin({ defaultRole: 'USER' })],
// Better Auth's built-in rate limiter. Defaults are off in non-prod;
// explicitly enable so dev and CI exercise the same limits as prod.
// explicitly enable so dev and CI exercise the same limits as prod. The
// E2E test server opts out via DISABLE_AUTH_RATE_LIMIT so it can create
// many sessions quickly.
rateLimit: {
enabled: true,
enabled: !env.DISABLE_AUTH_RATE_LIMIT,
// 10s sliding window, 100 req/window per IP across all auth endpoints.
window: 10,
max: 100,
Expand Down
9 changes: 9 additions & 0 deletions app/lib/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,15 @@ const envSchema = z.object({
NODE_ENV: z
.enum(['development', 'production', 'test'])
.default('development'),
/**
* Disables Better Auth's rate limiter. Intended only for the E2E test
* server, which creates many sessions quickly; unset everywhere else so
* dev, CI, and prod keep the production limits.
*/
DISABLE_AUTH_RATE_LIMIT: z
.enum(['true', 'false'])
.optional()
.transform((value) => value === 'true'),
});

function validateEnv() {
Expand Down
45 changes: 27 additions & 18 deletions playwright.config.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import { defineConfig, devices } from '@playwright/test';
import { STORAGE_STATE } from './tests/fixtures';

// Iridium's dev port 5173 is often occupied by other local projects, so the
// E2E suite runs the app on a dedicated port. Override with E2E_PORT if needed.
const PORT = Number(process.env.E2E_PORT ?? 7778);
const BASE_URL = `http://localhost:${PORT}`;

export default defineConfig({
globalSetup: './tests/global-setup.ts',
Expand All @@ -10,47 +14,52 @@ export default defineConfig({
workers: process.env.CI ? 1 : undefined,
reporter: 'html',
use: {
baseURL: 'http://localhost:5173',
baseURL: BASE_URL,
navigationTimeout: 60000,
trace: 'on-first-retry',
},
// Auth is explicit per test: the `authedPage` fixture creates a fresh,
// isolated user on demand, while a plain `page` stays logged out. No global
// storageState or setup project is needed; `globalSetup` only ensures the
// named seed users (Alice, Bob) exist for the tests that log in as them.
projects: [
// Auth setup — runs first, saves storageState for other projects
{
name: 'setup',
testMatch: /auth-setup\.ts/,
},
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
storageState: STORAGE_STATE,
},
dependencies: ['setup'],
testIgnore: /auth-setup\.ts/,
},
{
name: 'firefox',
use: {
...devices['Desktop Firefox'],
storageState: STORAGE_STATE,
},
dependencies: ['setup'],
testIgnore: /auth-setup\.ts/,
},
{
name: 'webkit',
use: {
...devices['Desktop Safari'],
storageState: STORAGE_STATE,
},
dependencies: ['setup'],
testIgnore: /auth-setup\.ts/,
},
],
webServer: {
command: 'bun run dev',
url: 'http://localhost:5173',
command: `bun run dev --port ${PORT}`,
url: BASE_URL,
reuseExistingServer: !process.env.CI,
// The suite mocks /api/chat, so the real Anthropic key is never used —
// env validation just needs a non-empty value for the server to boot.
// The two BETTER_AUTH base URLs must match the test port: the server
// one drives Better Auth's trusted origins, and the VITE_ one is baked
// into the browser auth client — if it points elsewhere, sign-in POSTs
// to the wrong origin and silently hangs.
env: {
ANTHROPIC_API_KEY:
process.env.ANTHROPIC_API_KEY || 'sk-ant-e2e-dummy-key',
BETTER_AUTH_BASE_URL: BASE_URL,
VITE_BETTER_AUTH_BASE_URL: BASE_URL,
// The suite creates a fresh user per test; the prod-tuned auth rate
// limiter would otherwise reject the rapid sign-ups with 429s.
DISABLE_AUTH_RATE_LIMIT: 'true',
},
},
});
85 changes: 85 additions & 0 deletions tests/api-chat.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import {
test,
expect,
createAuthedContext,
createThreadViaApi,
} from './fixtures';

/**
* API-level checks for the chat endpoint. These exercise the auth/ownership
* boundary that runs before any tokens are spent, so no AI service is needed.
*/

function validBody(id = 'some-thread') {
return {
id,
messages: [
{ id: 'm1', role: 'user', parts: [{ type: 'text', text: 'hi' }] },
],
};
}

test.describe('POST /api/chat', () => {
test('rejects write methods other than POST with 405', async ({
request,
}) => {
expect((await request.delete('/api/chat')).status()).toBe(405);
expect(
(await request.fetch('/api/chat', { method: 'PUT' })).status(),
).toBe(405);
});

test('rejects GET (no loader on the endpoint)', async ({ request }) => {
const res = await request.get('/api/chat');
expect(res.ok()).toBeFalsy();
expect(res.status()).toBe(400);
});

test('returns 401 when unauthenticated', async ({ request }) => {
const res = await request.post('/api/chat', { data: validBody() });
expect(res.status()).toBe(401);
});

test('returns 400 for an invalid request body', async ({ request }) => {
const res = await request.post('/api/chat', { data: { nope: true } });
expect(res.status()).toBe(400);
});

test('returns 404 for a thread that does not exist', async ({
browser,
baseURL,
}) => {
const context = await createAuthedContext(browser, baseURL!, 'chat404');
try {
const res = await context.request.post('/api/chat', {
data: validBody('thread-does-not-exist'),
});
expect(res.status()).toBe(404);
} finally {
await context.close();
}
});

test("returns 403 for another user's thread", async ({
browser,
baseURL,
}) => {
const owner = await createAuthedContext(browser, baseURL!, 'owner');
const attacker = await createAuthedContext(
browser,
baseURL!,
'attacker',
);
try {
const threadId = await createThreadViaApi(owner);

const res = await attacker.request.post('/api/chat', {
data: validBody(threadId),
});
expect(res.status()).toBe(403);
} finally {
await owner.close();
await attacker.close();
}
});
});
18 changes: 0 additions & 18 deletions tests/auth-setup.ts

This file was deleted.

18 changes: 9 additions & 9 deletions tests/auth.spec.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
import { test, expect, TEST_USER } from './fixtures';

test.describe('Login', () => {
test('logs in with valid credentials and redirects to profile', async ({
test('logs in with valid credentials and redirects to dashboard', async ({
page,
}) => {
await page.goto('/login');
await page.getByPlaceholder('name@example.com').fill(TEST_USER.email);
await page.getByPlaceholder('Your password').fill(TEST_USER.password);
await page.getByRole('button', { name: 'Login' }).click();

await expect(page).toHaveURL(/\/profile/);
await expect(page.getByText(TEST_USER.email)).toBeVisible();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText('Hello Dashboard!')).toBeVisible();
});

test('shows error for invalid credentials', async ({ page }) => {
Expand Down Expand Up @@ -66,18 +66,18 @@ test.describe('Registration', () => {
await expect(page.getByText('Name is required')).toBeVisible();
});

test('registers a new user and redirects to profile', async ({ page }) => {
test('registers a new user and redirects to dashboard', async ({
page,
}) => {
const unique = `e2e-${Date.now()}@test.com`;
await page.goto('/login');
await page.getByRole('radio', { name: 'Register' }).check();
await page.getByPlaceholder('Your name').fill('E2E Test User');
await page.getByPlaceholder('name@example.com').fill(unique);
await page.getByPlaceholder('Your password').fill('password123');
await page.getByRole('button', { name: 'Register' }).click();
await expect(
page.getByRole('heading', { name: 'Profile' }),
).toBeVisible();
await expect(page.getByText(unique)).toBeVisible();
await expect(page).toHaveURL(/\/dashboard/);
await expect(page.getByText('Hello Dashboard!')).toBeVisible();
});

test('shows error when registering with existing email', async ({
Expand Down Expand Up @@ -108,7 +108,7 @@ test.describe('Logout', () => {
test('protected pages redirect to login when logged out', async ({
page,
}) => {
await page.goto('/profile');
await page.goto('/dashboard');
await expect(page).toHaveURL(/\/login/);
});
});
Expand Down
107 changes: 107 additions & 0 deletions tests/chat-mock.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import type { Page, Route as PwRoute } from '@playwright/test';

/**
* Helpers for faking the `/api/chat` Server-Sent Events stream so E2E tests
* never call a real AI provider. The chunk shapes mirror the Vercel AI SDK
* v6 UI message stream (see `node_modules/ai/dist/index.d.ts`): text parts use
* `text-*` chunks; tool parts use `tool-input-*` / `tool-output-available`,
* which `useChat` assembles into a `tool-<name>` message part.
*/

export const SSE_HEADERS = {
'content-type': 'text/event-stream',
'cache-control': 'no-cache',
connection: 'keep-alive',
'x-vercel-ai-ui-message-stream': 'v1',
};

/** Serialize an array of UI message stream chunks into an SSE body. */
export function sseBody(chunks: Record<string, unknown>[]): string {
return (
chunks.map((c) => `data: ${JSON.stringify(c)}\n\n`).join('') +
'data: [DONE]\n\n'
);
}

/** Chunks that stream a single plain-text assistant reply. */
export function textReplyChunks(text: string, messageId = 'mock-msg-1') {
return [
{ type: 'start', messageId },
{ type: 'start-step' },
{ type: 'text-start', id: 'txt-1' },
{ type: 'text-delta', id: 'txt-1', delta: text },
{ type: 'text-end', id: 'txt-1' },
{ type: 'finish-step' },
{ type: 'finish', finishReason: 'stop' },
];
}

/**
* Chunks that stream a tool call which resolves to `output`. The `dynamic: true`
* flag makes the SDK assemble a `dynamic-tool` part that carries `toolName` —
* which is what the thread view reads to pick the right tool renderer (the
* VoltAgent backend emits dynamic tool parts too).
*/
export function toolCallChunks({
toolName,
input = {},
output,
messageId = 'mock-tool-msg',
toolCallId = 'tool-call-1',
}: {
toolName: string;
input?: unknown;
output: unknown;
messageId?: string;
toolCallId?: string;
}) {
return [
{ type: 'start', messageId },
{ type: 'start-step' },
{ type: 'tool-input-start', toolCallId, toolName, dynamic: true },
{
type: 'tool-input-available',
toolCallId,
toolName,
input,
dynamic: true,
},
{ type: 'tool-output-available', toolCallId, output, dynamic: true },
{ type: 'finish-step' },
{ type: 'finish', finishReason: 'stop' },
];
}

/** Intercept POST /api/chat and reply with a canned SSE stream of `chunks`. */
export async function mockChatStream(
page: Page,
chunks: Record<string, unknown>[],
) {
await page.route('**/api/chat', async (route: PwRoute) => {
await route.fulfill({
status: 200,
headers: SSE_HEADERS,
body: sseBody(chunks),
});
});
}

/** Intercept POST /api/chat and reply with a plain-text assistant message. */
export async function mockChatReply(
page: Page,
text: string,
messageId?: string,
) {
await mockChatStream(page, textReplyChunks(text, messageId));
}

/** Intercept POST /api/chat and fail it, to exercise the error UI. */
export async function mockChatError(page: Page, status = 500) {
await page.route('**/api/chat', async (route: PwRoute) => {
await route.fulfill({
status,
contentType: 'application/json',
body: JSON.stringify({ error: 'Something went wrong.' }),
});
});
}
Loading
Loading