Skip to content

Commit e5ad453

Browse files
feat(e2e): implement Playwright + MSW authentication bypass for E2E tests
- Add instrumentation.ts for server-side MSW initialization - Add E2E test mode bypass in proxy.ts middleware - Use JavaScript Proxy pattern in server.ts to mock Supabase auth - Fix test selectors based on actual page structure - Replace networkidle with domcontentloaded for MSW compatibility All 26 E2E tests passing locally with mocked authentication.
1 parent b464c3d commit e5ad453

File tree

10 files changed

+299
-157
lines changed

10 files changed

+299
-157
lines changed

app/layout.tsx

Lines changed: 2 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -33,20 +33,8 @@ import { Providers } from '@/lib/redux/providers'
3333

3434
import { MSWProvider } from './msw-provider'
3535

36-
// Server-side MSW initialization
37-
// This runs at module load time for Node.js runtime only
38-
// Must be at the TOP of the file, before any components render
39-
if (process.env.NEXT_RUNTIME === 'nodejs') {
40-
// Dynamic import to avoid bundling MSW in client bundle
41-
const initMSW = async () => {
42-
const { isMSWEnabled } = await import('@/lib/utils/isMSWEnabled')
43-
if (isMSWEnabled()) {
44-
const { server } = await import('../mocks/server')
45-
server.listen({ onUnhandledRequest: 'bypass' })
46-
}
47-
}
48-
initMSW()
49-
}
36+
// NOTE: Server-side MSW is initialized via instrumentation.ts (Next.js hook)
37+
// This ensures MSW starts BEFORE any route handlers run.
5038

5139
export default function RootLayout({
5240
children,

instrumentation.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/**
2+
* Next.js Instrumentation
3+
*
4+
* This file is automatically loaded by Next.js before the application starts.
5+
* Used to initialize MSW for server-side mocking in test environments.
6+
*
7+
* @see https://nextjs.org/docs/app/building-your-application/optimizing/instrumentation
8+
*/
9+
10+
export async function register() {
11+
// Only run on Node.js runtime (not Edge)
12+
if (process.env.NEXT_RUNTIME === 'nodejs') {
13+
console.log(
14+
'[instrumentation] APP_ENV:',
15+
process.env.APP_ENV,
16+
'NODE_ENV:',
17+
process.env.NODE_ENV,
18+
)
19+
20+
// Check if MSW should be enabled
21+
const isMSWEnabled =
22+
process.env.NEXT_PUBLIC_ENABLE_MSW_MOCK === 'true' &&
23+
(process.env.APP_ENV === 'test' || process.env.NODE_ENV === 'test')
24+
25+
if (isMSWEnabled) {
26+
console.log('[MSW] Starting server-side MSW...')
27+
const { server } = await import('./mocks/server')
28+
server.listen({ onUnhandledRequest: 'bypass' })
29+
console.log('[MSW] Server-side MSW started')
30+
}
31+
}
32+
}

lib/supabase/server.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,17 @@ import { cookies } from 'next/headers'
1010

1111
import type { Database } from './types'
1212

13+
/**
14+
* Check if E2E test mode is enabled
15+
* When true, auth checks return mock data to bypass OAuth
16+
*
17+
* NOTE: Uses APP_ENV (server-side) instead of NEXT_PUBLIC_ENABLE_MSW_MOCK
18+
* because NEXT_PUBLIC_* vars are inlined at build time and may not be
19+
* available at runtime on the server.
20+
*/
21+
const isE2ETestMode = () =>
22+
process.env.APP_ENV === 'test' || process.env.NODE_ENV === 'test'
23+
1324
/**
1425
* Create Supabase client for use in Server Components
1526
*
@@ -27,7 +38,7 @@ import type { Database } from './types'
2738
export async function createClient() {
2839
const cookieStore = await cookies()
2940

30-
return createServerClient<Database>(
41+
const supabase = createServerClient<Database>(
3142
process.env.NEXT_PUBLIC_SUPABASE_URL!,
3243
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
3344
{
@@ -53,6 +64,59 @@ export async function createClient() {
5364
},
5465
},
5566
)
67+
68+
// In E2E test mode, wrap auth methods to return mock data
69+
const testMode = isE2ETestMode()
70+
console.log(
71+
'[createClient] APP_ENV:',
72+
process.env.APP_ENV,
73+
'NODE_ENV:',
74+
process.env.NODE_ENV,
75+
'isE2ETestMode:',
76+
testMode,
77+
)
78+
if (testMode) {
79+
// Use a Proxy to intercept auth methods while preserving all other client functionality
80+
const mockedAuth = {
81+
getUser: async () => ({
82+
data: { user: MOCK_USER_FOR_E2E },
83+
error: null,
84+
}),
85+
getSession: async () => ({
86+
data: {
87+
session: {
88+
access_token: 'mock-access-token-for-testing',
89+
token_type: 'bearer',
90+
expires_in: 3600,
91+
expires_at: Math.floor(Date.now() / 1000) + 3600,
92+
refresh_token: 'mock-refresh-token-for-testing',
93+
user: MOCK_USER_FOR_E2E,
94+
},
95+
},
96+
error: null,
97+
}),
98+
}
99+
100+
return new Proxy(supabase, {
101+
get(target, prop) {
102+
if (prop === 'auth') {
103+
// Return a proxy for auth that intercepts getUser/getSession
104+
return new Proxy(target.auth, {
105+
get(authTarget, authProp) {
106+
if (authProp === 'getUser') return mockedAuth.getUser
107+
if (authProp === 'getSession') return mockedAuth.getSession
108+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
109+
return (authTarget as any)[authProp]
110+
},
111+
})
112+
}
113+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
114+
return (target as any)[prop]
115+
},
116+
}) as typeof supabase
117+
}
118+
119+
return supabase
56120
}
57121

58122
/**
@@ -136,10 +200,47 @@ export async function createServerActionClient() {
136200
)
137201
}
138202

203+
/**
204+
* Mock user for E2E testing when MSW is enabled
205+
*/
206+
const MOCK_USER_FOR_E2E = {
207+
id: 'test-user-id-12345',
208+
aud: 'authenticated',
209+
role: 'authenticated',
210+
email: 'test@example.com',
211+
email_confirmed_at: new Date().toISOString(),
212+
phone: '',
213+
confirmed_at: new Date().toISOString(),
214+
last_sign_in_at: new Date().toISOString(),
215+
app_metadata: { provider: 'github', providers: ['github'] },
216+
user_metadata: {
217+
avatar_url: 'https://avatars.githubusercontent.com/u/12345',
218+
full_name: 'Test User',
219+
preferred_username: 'testuser',
220+
user_name: 'testuser',
221+
},
222+
identities: [],
223+
created_at: new Date().toISOString(),
224+
updated_at: new Date().toISOString(),
225+
is_anonymous: false,
226+
} as const
227+
139228
/**
140229
* Get current user (server-side)
230+
*
231+
* In E2E test mode (NEXT_PUBLIC_ENABLE_MSW_MOCK=true + APP_ENV=test),
232+
* returns a mock user to bypass OAuth flow.
141233
*/
142234
export async function getUser() {
235+
// E2E test mode: return mock user to bypass OAuth
236+
const isMSWEnabled =
237+
process.env.NEXT_PUBLIC_ENABLE_MSW_MOCK === 'true' &&
238+
(process.env.APP_ENV === 'test' || process.env.NODE_ENV === 'test')
239+
240+
if (isMSWEnabled) {
241+
return MOCK_USER_FOR_E2E
242+
}
243+
143244
const supabase = await createClient()
144245
const {
145246
data: { user },
@@ -156,8 +257,27 @@ export async function getUser() {
156257

157258
/**
158259
* Get current session (server-side)
260+
*
261+
* In E2E test mode (NEXT_PUBLIC_ENABLE_MSW_MOCK=true + APP_ENV=test),
262+
* returns a mock session to bypass OAuth flow.
159263
*/
160264
export async function getSession() {
265+
// E2E test mode: return mock session to bypass OAuth
266+
const isMSWEnabled =
267+
process.env.NEXT_PUBLIC_ENABLE_MSW_MOCK === 'true' &&
268+
(process.env.APP_ENV === 'test' || process.env.NODE_ENV === 'test')
269+
270+
if (isMSWEnabled) {
271+
return {
272+
access_token: 'mock-access-token-for-testing',
273+
token_type: 'bearer',
274+
expires_in: 3600,
275+
expires_at: Math.floor(Date.now() / 1000) + 3600,
276+
refresh_token: 'mock-refresh-token-for-testing',
277+
user: MOCK_USER_FOR_E2E,
278+
}
279+
}
280+
161281
const supabase = await createClient()
162282
const {
163283
data: { session },

mocks/handlers.ts

Lines changed: 6 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -418,20 +418,13 @@ const supabaseAuthHandlers: HttpHandler[] = [
418418

419419
/**
420420
* GET /auth/v1/user - Get current authenticated user
421+
*
422+
* For E2E tests: Always returns mock user to simulate authenticated state.
423+
* The cookie injection in auth.setup.ts triggers Supabase to call this endpoint.
421424
*/
422-
http.get(`${SUPABASE_URL}/auth/v1/user`, ({ request }) => {
423-
const authHeader = request.headers.get('Authorization')
424-
425-
if (!authHeader || !authHeader.startsWith('Bearer ')) {
426-
return HttpResponse.json(
427-
{
428-
error: 'unauthorized',
429-
message: 'Missing or invalid authorization header',
430-
},
431-
{ status: 401 },
432-
)
433-
}
434-
425+
http.get(`${SUPABASE_URL}/auth/v1/user`, () => {
426+
// Always return authenticated user for E2E tests
427+
// This allows tests to proceed without real OAuth flow
435428
return HttpResponse.json(mockUser)
436429
}),
437430

proxy.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,23 @@ import { NextResponse, type NextRequest } from 'next/server'
1414
// Public paths that don't require authentication
1515
const publicPaths = ['/', '/login', '/auth/callback']
1616

17+
/**
18+
* Check if E2E test mode is enabled
19+
* Bypass authentication in middleware for E2E tests
20+
*/
21+
const isE2ETestMode = () =>
22+
process.env.APP_ENV === 'test' || process.env.NODE_ENV === 'test'
23+
1724
export async function proxy(request: NextRequest) {
25+
// In E2E test mode, bypass authentication and allow all requests
26+
if (isE2ETestMode()) {
27+
console.log('[proxy] E2E test mode - bypassing auth check')
28+
return NextResponse.next({
29+
request: {
30+
headers: request.headers,
31+
},
32+
})
33+
}
1834
let response = NextResponse.next({
1935
request: {
2036
headers: request.headers,

tests/e2e/auth.setup.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,9 @@ const MOCK_USER = {
2929

3030
/**
3131
* Creates a mock Supabase session token.
32-
* This is a simplified JWT structure for testing purposes.
32+
* This is a simplified session structure for testing purposes.
3333
*
34-
* @returns Base64-encoded mock session data
34+
* @returns JSON string of mock session data (Supabase SSR expects JSON, not Base64)
3535
*/
3636
function createMockSupabaseSession(): string {
3737
const session = {
@@ -43,8 +43,8 @@ function createMockSupabaseSession(): string {
4343
user: MOCK_USER,
4444
}
4545

46-
// Supabase stores session as base64-encoded JSON
47-
return Buffer.from(JSON.stringify(session)).toString('base64')
46+
// Supabase SSR stores session as JSON string (NOT base64)
47+
return JSON.stringify(session)
4848
}
4949

5050
/**

tests/e2e/boards.spec.ts

Lines changed: 28 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -16,66 +16,65 @@ test.describe('Boards Page (Authenticated)', () => {
1616
// Should show the boards page (not redirected to login)
1717
await expect(page).toHaveURL(/\/boards/)
1818

19-
// Should have a heading or indication of boards
20-
const heading = page.getByRole('heading', { name: /board/i })
21-
await expect(heading).toBeVisible()
19+
// Wait for main heading to appear
20+
const heading = page.getByRole('heading', { level: 1 })
21+
await expect(heading).toBeVisible({ timeout: 10000 })
2222
})
2323

2424
test('should display user avatar or profile indicator', async ({ page }) => {
2525
await page.goto('/boards')
2626

27-
// Look for user menu or avatar
27+
// Wait for page to stabilize
28+
await page.waitForLoadState('domcontentloaded')
29+
30+
// Look for user info section (Test User text or avatar)
2831
const userIndicator = page.locator(
29-
'[data-testid="user-menu"], [data-testid="user-avatar"], button:has(img[alt*="avatar" i])',
32+
'img[alt="Test User"], :text("Test User")',
3033
)
31-
await expect(userIndicator.first()).toBeVisible()
34+
await expect(userIndicator.first()).toBeVisible({ timeout: 10000 })
3235
})
3336

3437
test('should have option to create new board', async ({ page }) => {
3538
await page.goto('/boards')
3639

37-
// Look for create/add board button
38-
const createButton = page.getByRole('button', { name: /create|new|add/i })
39-
await expect(createButton).toBeVisible()
40+
// Wait for page to stabilize
41+
await page.waitForLoadState('domcontentloaded')
42+
43+
// Look for create board link (it's a link, not a button based on the snapshot)
44+
const createLink = page.getByRole('link', { name: /create board/i })
45+
await expect(createLink).toBeVisible({ timeout: 10000 })
4046
})
4147

4248
test('should navigate to board detail when clicking a board', async ({
4349
page,
4450
}) => {
4551
await page.goto('/boards')
4652

47-
// Wait for boards to load (MSW will return mock data)
48-
await page.waitForLoadState('networkidle')
53+
// Wait for page to stabilize
54+
await page.waitForLoadState('domcontentloaded')
4955

5056
// Click on the first board link/card
5157
const boardLink = page.locator('a[href*="/board/"]').first()
58+
await expect(boardLink).toBeVisible({ timeout: 10000 })
59+
await boardLink.click()
5260

53-
if (await boardLink.isVisible()) {
54-
await boardLink.click()
55-
56-
// Should navigate to board detail page
57-
await expect(page).toHaveURL(/\/board\//)
58-
}
61+
// Should navigate to board detail page
62+
await expect(page).toHaveURL(/\/board\//)
5963
})
6064

6165
test('should open create board modal or navigate to new board page', async ({
6266
page,
6367
}) => {
6468
await page.goto('/boards')
6569

66-
// Click create button
67-
const createButton = page.getByRole('button', { name: /create|new|add/i })
68-
await createButton.click()
70+
// Wait for page to stabilize
71+
await page.waitForLoadState('domcontentloaded')
6972

70-
// Should either open modal or navigate to /boards/new
71-
const modalOrPage = page.locator(
72-
'[role="dialog"], [data-testid="create-board-modal"]',
73-
)
74-
const newBoardUrl = page.url().includes('/boards/new')
73+
// Click create board link
74+
const createLink = page.getByRole('link', { name: /create board/i })
75+
await createLink.click()
7576

76-
expect(
77-
(await modalOrPage.isVisible()) || newBoardUrl,
78-
'Should either show modal or navigate to new board page',
79-
).toBeTruthy()
77+
// Should navigate to /boards/new
78+
await expect(page).toHaveURL(/\/boards\/new/)
8079
})
8180
})

0 commit comments

Comments
 (0)