Skip to content

Commit 8ceab5b

Browse files
aster-voidclaude
andcommitted
treewide: add e2e test infrastructure with Playwright
- Add e2e/ directory with Playwright configuration - Docker-based test database on port 5433 - Global setup/teardown for server and frontend - Tests for message, channel, and navigation (9 tests) - Mock session for DISABLE_AUTH mode in better-auth.ts - Fix WebSocket URL duplication in +layout.svelte - Add test:e2e scripts to package.json 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 0bbc398 commit 8ceab5b

File tree

12 files changed

+489
-3
lines changed

12 files changed

+489
-3
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ node_modules
99

1010
# Tests
1111
test-results
12+
playwright-report
1213

1314
# target
1415
target

apps/desktop/src/routes/+layout.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@
99
// Initialize API client
1010
setupApi();
1111
12-
// Initialize WebSocket client
13-
const wsUrl = `${env.PUBLIC_API_BASE_URL.replace(/^http/, "ws")}/ws`;
12+
// Initialize WebSocket client (Eden Treaty adds /ws automatically)
13+
const wsUrl = env.PUBLIC_API_BASE_URL.replace(/^http/, "ws");
1414
1515
$effect(() => {
1616
let cleanup: (() => void) | undefined;

apps/server/src/domains/auth/better-auth.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,29 @@
11
import { type Context, Elysia } from "elysia";
22
import { auth } from "../../auth.ts";
3+
import { env } from "../../env.ts";
4+
5+
const MOCK_SESSION = {
6+
session: {
7+
id: "mock-session-id",
8+
userId: "dev-user-id",
9+
expiresAt: new Date(Date.now() + 86400000).toISOString(),
10+
},
11+
user: {
12+
id: "dev-user-id",
13+
14+
name: "Dev User",
15+
emailVerified: true,
16+
createdAt: new Date().toISOString(),
17+
updatedAt: new Date().toISOString(),
18+
},
19+
};
320

421
const betterAuthView = (context: Context) => {
22+
// Return mock session when auth is disabled
23+
if (env.DISABLE_AUTH && context.request.url.endsWith("/get-session")) {
24+
return Response.json(MOCK_SESSION);
25+
}
26+
527
const BETTER_AUTH_ACCEPT_METHODS = ["POST", "GET"];
628
if (BETTER_AUTH_ACCEPT_METHODS.includes(context.request.method)) {
729
return auth.handler(context.request);

bun.lock

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"name": "prism",
77
"devDependencies": {
88
"@biomejs/biome": "^2.3.8",
9+
"@playwright/test": "^1.57.0",
910
"@sveltejs/mcp": "^0.1.13",
1011
"lefthook": "^2.0.8",
1112
"prettier": "^3.7.4",
@@ -330,6 +331,8 @@
330331

331332
"@pinojs/redact": ["@pinojs/[email protected]", "", {}, "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg=="],
332333

334+
"@playwright/test": ["@playwright/[email protected]", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="],
335+
333336
"@polka/url": ["@polka/[email protected]", "", {}, "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww=="],
334337

335338
"@poppinss/colors": ["@poppinss/[email protected]", "", { "dependencies": { "kleur": "^4.1.5" } }, "sha512-FvdDqtcRCtz6hThExcFOgW0cWX+xwSMWcRuQe5ZEb2m7cVQOAVZOIMt+/v9RxGiD9/OY16qJBXK4CVKWAPalBw=="],
@@ -774,6 +777,10 @@
774777

775778
"pino-std-serializers": ["[email protected]", "", {}, "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA=="],
776779

780+
"playwright": ["[email protected]", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="],
781+
782+
"playwright-core": ["[email protected]", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="],
783+
777784
"postcss": ["[email protected]", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="],
778785

779786
"postgres": ["[email protected]", "", {}, "sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw=="],
@@ -942,6 +949,8 @@
942949

943950
"miniflare/zod": ["[email protected]", "", {}, "sha512-EjIevzuJRiRPbVH4mGc8nApb/lVLKVpmUhAaR5R5doKGfAnGJ6Gr3CViAVjP+4FWSxCsybeWQdcgCtbX+7oZug=="],
944951

952+
"playwright/fsevents": ["[email protected]", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="],
953+
945954
"wrangler/esbuild": ["[email protected]", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.0", "@esbuild/android-arm": "0.27.0", "@esbuild/android-arm64": "0.27.0", "@esbuild/android-x64": "0.27.0", "@esbuild/darwin-arm64": "0.27.0", "@esbuild/darwin-x64": "0.27.0", "@esbuild/freebsd-arm64": "0.27.0", "@esbuild/freebsd-x64": "0.27.0", "@esbuild/linux-arm": "0.27.0", "@esbuild/linux-arm64": "0.27.0", "@esbuild/linux-ia32": "0.27.0", "@esbuild/linux-loong64": "0.27.0", "@esbuild/linux-mips64el": "0.27.0", "@esbuild/linux-ppc64": "0.27.0", "@esbuild/linux-riscv64": "0.27.0", "@esbuild/linux-s390x": "0.27.0", "@esbuild/linux-x64": "0.27.0", "@esbuild/netbsd-arm64": "0.27.0", "@esbuild/netbsd-x64": "0.27.0", "@esbuild/openbsd-arm64": "0.27.0", "@esbuild/openbsd-x64": "0.27.0", "@esbuild/openharmony-arm64": "0.27.0", "@esbuild/sunos-x64": "0.27.0", "@esbuild/win32-arm64": "0.27.0", "@esbuild/win32-ia32": "0.27.0", "@esbuild/win32-x64": "0.27.0" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-jd0f4NHbD6cALCyGElNpGAOtWxSq46l9X/sWB0Nzd5er4Kz2YTm+Vl0qKFT9KUJvD8+fiO8AvoHhFvEatfVixA=="],
946955

947956
"youch/cookie": ["[email protected]", "", {}, "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ=="],

e2e/env.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
/**
2+
* E2E test environment configuration.
3+
* Uses isolated ports and Docker-based database.
4+
*/
5+
export const testEnv = {
6+
// Database (Docker container on port 5433)
7+
DATABASE_URL: "postgres://postgres:postgres@localhost:5433/postgres",
8+
DB_CONTAINER_NAME: "prism-test-db",
9+
DB_PORT: "5433",
10+
11+
// Server
12+
PORT: "3001",
13+
SERVER_URL: "http://localhost:3001",
14+
15+
// Frontend
16+
FRONTEND_PORT: "5174",
17+
FRONTEND_URL: "http://localhost:5174",
18+
19+
// Auth (disabled for tests)
20+
DISABLE_AUTH: "true",
21+
PUBLIC_DISABLE_AUTH: "true",
22+
CORS_ORIGIN: "http://localhost:5174",
23+
PUBLIC_API_BASE_URL: "http://localhost:3001",
24+
25+
// Dummy values for Better Auth (not used when DISABLE_AUTH=true)
26+
BETTER_AUTH_SECRET: "test-secret-key-at-least-32-characters-long",
27+
GOOGLE_CLIENT_ID: "test-client-id",
28+
GOOGLE_CLIENT_SECRET: "test-client-secret",
29+
};
30+
31+
/**
32+
* Convert testEnv to process.env format
33+
*/
34+
export function getEnvString(): string {
35+
return Object.entries(testEnv)
36+
.filter(
37+
([key]) =>
38+
![
39+
"DB_CONTAINER_NAME",
40+
"DB_PORT",
41+
"SERVER_URL",
42+
"FRONTEND_URL",
43+
"FRONTEND_PORT",
44+
].includes(key),
45+
)
46+
.map(([key, value]) => `${key}=${value}`)
47+
.join("\n");
48+
}

e2e/global-setup.ts

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
1+
import { type ChildProcess, execSync, spawn } from "node:child_process";
2+
import { testEnv } from "./env.ts";
3+
4+
const processes: ChildProcess[] = [];
5+
6+
/**
7+
* Get all test environment variables for subprocess
8+
*/
9+
function getTestEnvVars(): NodeJS.ProcessEnv {
10+
return {
11+
...process.env,
12+
DATABASE_URL: testEnv.DATABASE_URL,
13+
PORT: testEnv.PORT,
14+
CORS_ORIGIN: testEnv.CORS_ORIGIN,
15+
DISABLE_AUTH: testEnv.DISABLE_AUTH,
16+
PUBLIC_DISABLE_AUTH: testEnv.PUBLIC_DISABLE_AUTH,
17+
PUBLIC_API_BASE_URL: testEnv.PUBLIC_API_BASE_URL,
18+
BETTER_AUTH_SECRET: testEnv.BETTER_AUTH_SECRET,
19+
GOOGLE_CLIENT_ID: testEnv.GOOGLE_CLIENT_ID,
20+
GOOGLE_CLIENT_SECRET: testEnv.GOOGLE_CLIENT_SECRET,
21+
};
22+
}
23+
24+
/**
25+
* Wait for a URL to respond with 200 OK
26+
*/
27+
async function waitForUrl(url: string, timeout = 30000): Promise<void> {
28+
const start = Date.now();
29+
while (Date.now() - start < timeout) {
30+
try {
31+
const res = await fetch(url);
32+
if (res.ok) return;
33+
} catch {
34+
// Not ready yet
35+
}
36+
await new Promise((r) => setTimeout(r, 500));
37+
}
38+
throw new Error(`Timeout waiting for ${url}`);
39+
}
40+
41+
/**
42+
* Wait for PostgreSQL to be ready
43+
*/
44+
async function waitForDb(timeout = 30000): Promise<void> {
45+
const start = Date.now();
46+
while (Date.now() - start < timeout) {
47+
try {
48+
execSync(
49+
`docker exec ${testEnv.DB_CONTAINER_NAME} pg_isready -U postgres`,
50+
{ stdio: "ignore" },
51+
);
52+
return;
53+
} catch {
54+
// Not ready yet
55+
}
56+
await new Promise((r) => setTimeout(r, 500));
57+
}
58+
throw new Error("Timeout waiting for database");
59+
}
60+
61+
/**
62+
* Start PostgreSQL Docker container
63+
*/
64+
function startDatabase(): void {
65+
console.log("🐘 Starting test database...");
66+
67+
// Remove existing container if any
68+
try {
69+
execSync(`docker rm -f ${testEnv.DB_CONTAINER_NAME}`, { stdio: "ignore" });
70+
} catch {
71+
// Container doesn't exist, that's fine
72+
}
73+
74+
// Start new container
75+
execSync(
76+
`docker run -d --name ${testEnv.DB_CONTAINER_NAME} -p ${testEnv.DB_PORT}:5432 -e POSTGRES_PASSWORD=postgres postgres:16`,
77+
{ stdio: "inherit" },
78+
);
79+
}
80+
81+
/**
82+
* Run database migrations
83+
*/
84+
function runMigrations(): void {
85+
console.log("📦 Running migrations...");
86+
execSync("bun db:migrate", {
87+
cwd: "apps/server",
88+
stdio: "inherit",
89+
env: getTestEnvVars(),
90+
});
91+
}
92+
93+
/**
94+
* Run database seed
95+
*/
96+
function runSeed(): void {
97+
console.log("🌱 Seeding database...");
98+
execSync("bun db:seed", {
99+
stdio: "inherit",
100+
env: getTestEnvVars(),
101+
});
102+
}
103+
104+
/**
105+
* Start the API server
106+
*/
107+
function startServer(): ChildProcess {
108+
console.log("🦊 Starting server...");
109+
const server = spawn("bun", ["run", "src/index.ts"], {
110+
cwd: "apps/server",
111+
stdio: "inherit",
112+
env: getTestEnvVars(),
113+
});
114+
processes.push(server);
115+
return server;
116+
}
117+
118+
/**
119+
* Start the frontend dev server
120+
*/
121+
function startFrontend(): ChildProcess {
122+
console.log("🌐 Starting frontend...");
123+
const frontend = spawn(
124+
"bun",
125+
["vite", "dev", "--port", testEnv.FRONTEND_PORT],
126+
{
127+
cwd: "apps/desktop",
128+
stdio: "inherit",
129+
env: {
130+
...process.env,
131+
PUBLIC_API_BASE_URL: testEnv.PUBLIC_API_BASE_URL,
132+
PUBLIC_DISABLE_AUTH: testEnv.PUBLIC_DISABLE_AUTH,
133+
},
134+
},
135+
);
136+
processes.push(frontend);
137+
return frontend;
138+
}
139+
140+
/**
141+
* Global setup for Playwright tests
142+
*/
143+
export default async function globalSetup(): Promise<void> {
144+
console.log("\n🚀 Starting E2E test environment...\n");
145+
146+
// 1. Start database
147+
startDatabase();
148+
await waitForDb();
149+
console.log("✅ Database ready");
150+
151+
// 2. Run migrations and seed
152+
runMigrations();
153+
runSeed();
154+
console.log("✅ Database seeded");
155+
156+
// 3. Start server
157+
startServer();
158+
await waitForUrl(`${testEnv.SERVER_URL}/health`);
159+
console.log("✅ Server ready");
160+
161+
// 4. Start frontend
162+
startFrontend();
163+
await waitForUrl(testEnv.FRONTEND_URL);
164+
console.log("✅ Frontend ready");
165+
166+
console.log("\n✨ E2E environment ready!\n");
167+
168+
// Store process info for teardown
169+
(globalThis as Record<string, unknown>).__E2E_PROCESSES__ = processes;
170+
}

e2e/global-teardown.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { type ChildProcess, execSync } from "node:child_process";
2+
import { testEnv } from "./env.ts";
3+
4+
/**
5+
* Global teardown for Playwright tests
6+
*/
7+
export default async function globalTeardown(): Promise<void> {
8+
console.log("\n🧹 Cleaning up E2E test environment...\n");
9+
10+
// 1. Kill server and frontend processes
11+
const processes = (globalThis as Record<string, unknown>)
12+
.__E2E_PROCESSES__ as ChildProcess[] | undefined;
13+
if (processes) {
14+
for (const proc of processes) {
15+
if (proc && !proc.killed) {
16+
proc.kill("SIGTERM");
17+
}
18+
}
19+
console.log("✅ Processes terminated");
20+
}
21+
22+
// 2. Stop and remove Docker container
23+
try {
24+
execSync(`docker rm -f ${testEnv.DB_CONTAINER_NAME}`, { stdio: "inherit" });
25+
console.log("✅ Database container removed");
26+
} catch {
27+
console.log("⚠️ Could not remove database container (may not exist)");
28+
}
29+
30+
console.log("\n✨ Cleanup complete!\n");
31+
}

e2e/playwright.config.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import { defineConfig, devices } from "@playwright/test";
2+
import { testEnv } from "./env.ts";
3+
4+
export default defineConfig({
5+
testDir: "./tests",
6+
fullyParallel: true,
7+
forbidOnly: !!process.env.CI,
8+
retries: process.env.CI ? 2 : 0,
9+
workers: 2,
10+
reporter: "html",
11+
12+
globalSetup: "./global-setup.ts",
13+
globalTeardown: "./global-teardown.ts",
14+
15+
use: {
16+
baseURL: testEnv.FRONTEND_URL,
17+
trace: "on-first-retry",
18+
screenshot: "only-on-failure",
19+
launchOptions: {
20+
executablePath: process.env.PLAYWRIGHT_CHROMIUM_PATH,
21+
},
22+
},
23+
24+
projects: [
25+
{
26+
name: "chromium",
27+
use: { ...devices["Desktop Chrome"] },
28+
},
29+
],
30+
});

0 commit comments

Comments
 (0)