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 <noreply@anthropic.com>
1 parent 0bbc398 commit 8ceab5b

12 files changed

Lines changed: 489 additions & 3 deletions

File tree

.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+
email: "dev@example.com",
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
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

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)