Skip to content

Commit f2bcd22

Browse files
feat(e2e): add Playwright + MSW testing infrastructure
- Add Playwright config with auth setup (cookie injection for MSW) - Configure MSW with t3-env (@t3-oss/env-nextjs) for type-safe env vars - Add MSW handlers for Supabase Auth, PostgREST, and GitHub API - Add MSWProvider for client-side initialization - Add server-side MSW init in layout.tsx - Add E2E tests for landing, login, boards, kanban, and settings pages - Add GitHub Actions CI workflow with parallel jobs - Add asymmetric MSW activation (client/server safety) Reference: https://github.com/laststance/next-msw-integration
1 parent 59a9497 commit f2bcd22

File tree

23 files changed

+2063
-71
lines changed

23 files changed

+2063
-71
lines changed

.github/workflows/ci.yml

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
name: CI
2+
3+
on:
4+
push:
5+
branches: [main]
6+
pull_request:
7+
branches: [main]
8+
9+
concurrency:
10+
group: ci-${{ github.ref }}
11+
cancel-in-progress: true
12+
13+
env:
14+
# Test environment configuration
15+
NEXT_PUBLIC_SUPABASE_URL: https://jqtxjzdxczqwsrvevmyk.supabase.co
16+
NEXT_PUBLIC_SUPABASE_ANON_KEY: test-anon-key
17+
NEXT_PUBLIC_ENABLE_MSW_MOCK: 'true'
18+
APP_ENV: test
19+
20+
jobs:
21+
lint:
22+
runs-on: ubuntu-latest
23+
steps:
24+
- uses: actions/checkout@v4
25+
- uses: pnpm/action-setup@v4
26+
- uses: actions/setup-node@v4
27+
with:
28+
node-version: '24'
29+
cache: 'pnpm'
30+
- run: pnpm install --frozen-lockfile
31+
- run: pnpm lint
32+
- run: pnpm typecheck
33+
34+
unit-test:
35+
runs-on: ubuntu-latest
36+
steps:
37+
- uses: actions/checkout@v4
38+
- uses: pnpm/action-setup@v4
39+
- uses: actions/setup-node@v4
40+
with:
41+
node-version: '24'
42+
cache: 'pnpm'
43+
- run: pnpm install --frozen-lockfile
44+
- run: pnpm test run --project unit
45+
46+
e2e-test:
47+
runs-on: ubuntu-latest
48+
steps:
49+
- uses: actions/checkout@v4
50+
- uses: pnpm/action-setup@v4
51+
- uses: actions/setup-node@v4
52+
with:
53+
node-version: '24'
54+
cache: 'pnpm'
55+
- run: pnpm install --frozen-lockfile
56+
- name: Install Playwright Browsers
57+
run: pnpm exec playwright install --with-deps chromium
58+
- name: Run E2E Tests
59+
run: pnpm test:e2e
60+
- uses: actions/upload-artifact@v4
61+
if: failure()
62+
with:
63+
name: playwright-report
64+
path: playwright-report/
65+
retention-days: 7
66+
67+
build:
68+
runs-on: ubuntu-latest
69+
steps:
70+
- uses: actions/checkout@v4
71+
- uses: pnpm/action-setup@v4
72+
- uses: actions/setup-node@v4
73+
with:
74+
node-version: '24'
75+
cache: 'pnpm'
76+
- run: pnpm install --frozen-lockfile
77+
- run: pnpm build
78+
env:
79+
NEXT_PUBLIC_ENABLE_MSW_MOCK: 'false'

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ storybook-static/
6666
test-results/
6767
playwright-report/
6868
playwright/.cache/
69+
tests/e2e/.auth/*.json
6970

7071
*storybook.log
7172

app/auth/callback/route.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,9 @@ import { cookies } from 'next/headers'
1313
import { NextResponse } from 'next/server'
1414

1515
import { createFirstBoardIfNeeded } from '@/lib/actions/board'
16+
import { getGitHubTokenCookieName } from '@/lib/constants/cookies'
1617
import { createRouteHandlerClient } from '@/lib/supabase/server'
1718

18-
// Cookie name
19-
const GITHUB_TOKEN_COOKIE = 'github_provider_token'
20-
2119
export async function GET(request: Request) {
2220
const { searchParams, origin } = new URL(request.url)
2321
const code = searchParams.get('code')
@@ -58,14 +56,15 @@ export async function GET(request: Request) {
5856
const providerToken = data.session?.provider_token
5957
if (providerToken) {
6058
const cookieStore = await cookies()
61-
cookieStore.set(GITHUB_TOKEN_COOKIE, providerToken, {
59+
const cookieName = getGitHubTokenCookieName()
60+
cookieStore.set(cookieName, providerToken, {
6261
httpOnly: true,
6362
secure: process.env.NODE_ENV === 'production',
6463
sameSite: 'lax',
6564
maxAge: 60 * 60 * 24 * 7, // 7 days (adjust to match GitHub token expiration)
6665
path: '/',
6766
})
68-
console.log('GitHub provider_token saved to cookie')
67+
console.log(`GitHub provider_token saved to cookie: ${cookieName}`)
6968
} else {
7069
console.warn(
7170
'No provider_token in session - GitHub API access may be limited',

app/layout.tsx

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* - Redux Provider
66
* - Theme application
77
* - Global keyboard shortcuts (ShortcutsHelp)
8+
* - MSW initialization (server-side for SSR, client-side via MSWProvider)
89
*/
910

1011
import '@/styles/globals.css'
@@ -30,6 +31,23 @@ import { CommandPalette } from '@/components/CommandPalette/CommandPalette'
3031
import { ShortcutsHelp } from '@/components/ShortcutsHelp'
3132
import { Providers } from '@/lib/redux/providers'
3233

34+
import { MSWProvider } from './msw-provider'
35+
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+
}
50+
3351
export default function RootLayout({
3452
children,
3553
}: {
@@ -48,10 +66,12 @@ export default function RootLayout({
4866
</head>
4967
<body>
5068
<Providers>
51-
{children}
52-
<ShortcutsHelp />
53-
<CommandPalette />
54-
<Toaster richColors position="bottom-right" />
69+
<MSWProvider>
70+
{children}
71+
<ShortcutsHelp />
72+
<CommandPalette />
73+
<Toaster richColors position="bottom-right" />
74+
</MSWProvider>
5575
</Providers>
5676
</body>
5777
</html>

app/msw-provider.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/**
2+
* MSW Provider Component
3+
*
4+
* Client component that initializes Mock Service Worker before rendering children.
5+
* Uses useLayoutEffect to ensure MSW is ready before any fetch requests.
6+
*
7+
* @description
8+
* - Blocks rendering until MSW is initialized (prevents race conditions)
9+
* - Dynamically imports mocks/browser.ts only when needed
10+
* - Shows loading state during initialization
11+
* - Gracefully handles initialization failures
12+
*
13+
* @see https://mswjs.io/docs/integrations/react
14+
*/
15+
'use client'
16+
17+
import { memo, useLayoutEffect, useState } from 'react'
18+
19+
import { isMSWEnabled } from '@/lib/utils/isMSWEnabled'
20+
21+
type MSWProviderProps = Readonly<{
22+
children: React.ReactNode
23+
}>
24+
25+
/**
26+
* Wraps children with MSW initialization logic
27+
*
28+
* @param children - React children to render after MSW is ready
29+
* @returns Children after MSW initialization, or loading state during init
30+
*
31+
* @example
32+
* // In layout.tsx:
33+
* <MSWProvider>{children}</MSWProvider>
34+
*/
35+
function MSWProviderComponent({ children }: MSWProviderProps): React.ReactNode {
36+
const [isMSWReady, setIsMSWReady] = useState(false)
37+
38+
useLayoutEffect(() => {
39+
const enabled = isMSWEnabled()
40+
41+
// If MSW is not enabled or we're not in the browser, skip initialization
42+
if (!enabled || typeof window === 'undefined') {
43+
setIsMSWReady(true)
44+
return
45+
}
46+
47+
// Dynamically import browser worker to avoid bundling in production
48+
import('../mocks/browser')
49+
.then(async ({ worker }) => {
50+
try {
51+
await worker.start({
52+
// 'bypass' allows unhandled requests to pass through to the network
53+
// Change to 'warn' or 'error' to debug missing handlers
54+
onUnhandledRequest: 'bypass',
55+
})
56+
setIsMSWReady(true)
57+
} catch {
58+
// Worker failed to start, but we should still render the app
59+
// This can happen if Service Worker is not supported
60+
setIsMSWReady(true)
61+
}
62+
})
63+
.catch(() => {
64+
// Import failed, but we should still render the app
65+
setIsMSWReady(true)
66+
})
67+
}, [])
68+
69+
// Block rendering until MSW is ready to prevent race conditions
70+
// where fetch requests are made before MSW can intercept them
71+
if (isMSWEnabled() && !isMSWReady) {
72+
return (
73+
<div className="min-h-screen flex items-center justify-center">
74+
<p className="text-lg">Initializing MSW...</p>
75+
</div>
76+
)
77+
}
78+
79+
return children
80+
}
81+
82+
export const MSWProvider = memo(MSWProviderComponent)

components/Modals/StatusListDialog.tsx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,11 +130,16 @@ export const StatusListDialog = memo(function StatusListDialog({
130130
<Label htmlFor="status-name">Name</Label>
131131
<Input
132132
id="status-name"
133+
name="status-column-title"
133134
value={name}
134135
onChange={(e) => setName(e.target.value)}
135136
placeholder="e.g., In Progress, Review"
136137
maxLength={50}
137138
autoFocus
139+
autoComplete="off"
140+
data-1p-ignore
141+
data-lpignore="true"
142+
data-form-type="other"
138143
/>
139144
</div>
140145

lib/actions/github.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@
99

1010
import { cookies } from 'next/headers'
1111

12+
import { getGitHubTokenCookieName } from '@/lib/constants/cookies'
13+
1214
const GITHUB_API_BASE_URL = 'https://api.github.com'
13-
const GITHUB_TOKEN_COOKIE = 'github_provider_token'
1415

1516
export interface GitHubRepository {
1617
id: number
@@ -38,7 +39,8 @@ export interface GitHubRepository {
3839
*/
3940
async function getGitHubToken(): Promise<string | null> {
4041
const cookieStore = await cookies()
41-
return cookieStore.get(GITHUB_TOKEN_COOKIE)?.value ?? null
42+
const cookieName = getGitHubTokenCookieName()
43+
return cookieStore.get(cookieName)?.value ?? null
4244
}
4345

4446
/**

lib/constants/cookies.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/**
2+
* Cookie Name Constants
3+
*
4+
* Environment-specific cookie names to prevent conflicts
5+
* when running dev and prod environments on localhost.
6+
*/
7+
8+
/**
9+
* Get environment-specific GitHub token cookie name
10+
*
11+
* Uses Supabase project ID (first 8 chars) to differentiate between environments.
12+
* This prevents cookie conflicts when switching between dev/prod on localhost.
13+
*
14+
* @returns Cookie name like "gh_token_jqtxjzdx" (dev) or "gh_token_mfeesjmt" (prod)
15+
* @example
16+
* // Dev: https://jqtxjzdxczqwsrvevmyk.supabase.co
17+
* getGitHubTokenCookieName() // => "gh_token_jqtxjzdx"
18+
*
19+
* // Prod: https://mfeesjmtofgayktirswf.supabase.co
20+
* getGitHubTokenCookieName() // => "gh_token_mfeesjmt"
21+
*/
22+
export function getGitHubTokenCookieName(): string {
23+
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || ''
24+
// Extract project ID from URL: https://PROJECT_ID.supabase.co
25+
const match = supabaseUrl.match(/https:\/\/([a-z0-9]+)\.supabase\.co/)
26+
const projectId = match?.[1]?.slice(0, 8) || 'default'
27+
return `gh_token_${projectId}`
28+
}

lib/env.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
/**
2+
* Environment Variables Schema (t3-env)
3+
*
4+
* Type-safe environment variable validation using @t3-oss/env-nextjs.
5+
* Provides runtime validation and TypeScript types for all env vars.
6+
*
7+
* @description
8+
* - NEXT_PUBLIC_* vars are exposed to the browser (client section)
9+
* - Server vars are only available on the server (server section)
10+
* - APP_ENV distinguishes test from development (NODE_ENV is auto-set to 'production' after build)
11+
* - NEXT_PUBLIC_ENABLE_MSW_MOCK controls MSW activation
12+
*/
13+
import { createEnv } from '@t3-oss/env-nextjs'
14+
import { z } from 'zod'
15+
16+
export const env = createEnv({
17+
/**
18+
* Server-side environment variables schema.
19+
* These are only available on the server.
20+
*/
21+
server: {
22+
NODE_ENV: z
23+
.enum(['development', 'test', 'production'])
24+
.default('development'),
25+
// APP_ENV is separate from NODE_ENV because Next.js sets NODE_ENV to 'production'
26+
// after running `next build`, regardless of the actual deployment target.
27+
// This allows MSW mocking in production builds for E2E testing.
28+
APP_ENV: z
29+
.enum(['development', 'test', 'production'])
30+
.optional()
31+
.default('development'),
32+
},
33+
34+
/**
35+
* Client-side environment variables schema.
36+
* These are exposed to the browser via NEXT_PUBLIC_ prefix.
37+
*/
38+
client: {
39+
NEXT_PUBLIC_SUPABASE_URL: z.string().min(1),
40+
NEXT_PUBLIC_SUPABASE_ANON_KEY: z.string().min(1),
41+
// MSW activation flag - only check this on client side
42+
// Server also requires APP_ENV=test for activation
43+
NEXT_PUBLIC_ENABLE_MSW_MOCK: z
44+
.string()
45+
.optional()
46+
.default('false')
47+
.transform((val) => val === 'true'),
48+
},
49+
50+
/**
51+
* Runtime environment variables.
52+
* For Next.js >= 13.4.4, you need to destructure manually.
53+
*/
54+
runtimeEnv: {
55+
NODE_ENV: process.env.NODE_ENV,
56+
APP_ENV: process.env.APP_ENV,
57+
NEXT_PUBLIC_SUPABASE_URL: process.env.NEXT_PUBLIC_SUPABASE_URL,
58+
NEXT_PUBLIC_SUPABASE_ANON_KEY: process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY,
59+
NEXT_PUBLIC_ENABLE_MSW_MOCK: process.env.NEXT_PUBLIC_ENABLE_MSW_MOCK,
60+
},
61+
62+
/**
63+
* Skip validation in certain environments.
64+
* Set SKIP_ENV_VALIDATION=1 to skip validation (useful for Docker builds).
65+
*/
66+
skipValidation: !!process.env.SKIP_ENV_VALIDATION,
67+
68+
/**
69+
* Treat empty strings as undefined.
70+
* Useful for optional env vars that might be set to empty string.
71+
*/
72+
emptyStringAsUndefined: true,
73+
})

0 commit comments

Comments
 (0)