Skip to content

Commit 50ebd11

Browse files
fix(e2e): resolve E2E test failures and SSR hydration mismatch
## E2E Test Fixes - Set NEXT_PUBLIC_ENABLE_MSW_MOCK at build time in playwright.config.ts (NEXT_PUBLIC_* vars are inlined during build, not runtime) - Add mock data to GitHub server actions for E2E test mode - Extract shared isTestMode utility to tests/isTestMode.ts - Fix organization filter selector in add-repository-combobox.spec.ts ## SSR Hydration Fix - Add isMounted state to KanbanBoard for hydration-safe grid styles - Prevent SSR/CSR mismatch for dynamic gridTemplateColumns/Rows ## Cleanup - Remove unused test:e2e:ui script from package.json All 29 E2E tests now passing.
1 parent ce6f8b1 commit 50ebd11

File tree

7 files changed

+222
-48
lines changed

7 files changed

+222
-48
lines changed

components/Board/KanbanBoard.tsx

Lines changed: 51 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,12 @@ export const KanbanBoard = memo<KanbanBoardProps>(
148148
const [activeDragType, setActiveDragType] = useState<DragType>(null)
149149
// History stack for undo functionality (max 10 entries)
150150
const [history, setHistory] = useState<RepoCardForRedux[][]>([])
151+
152+
// Hydration-safe mounting state: prevents SSR/CSR mismatch for dynamic grid styles
153+
const [isMounted, setIsMounted] = useState(false)
154+
useEffect(() => {
155+
setIsMounted(true)
156+
}, [])
151157
const [columnHistory, setColumnHistory] = useState<StatusListDomain[][]>([])
152158
const [undoMessage, setUndoMessage] = useState<string | null>(null)
153159

@@ -628,43 +634,55 @@ export const KanbanBoard = memo<KanbanBoardProps>(
628634
<SortableContext items={columnIds} strategy={rectSortingStrategy}>
629635
<div
630636
className="grid gap-4 pb-4"
631-
style={{
632-
// Add extra column when dragging to allow insertion at end
633-
gridTemplateColumns: `repeat(${gridDimensions.maxCol + 1 + (activeDragType === 'column' ? 1 : 0)}, minmax(280px, 1fr))`,
634-
// Add extra row for NewRowDropZone when dragging
635-
gridTemplateRows: `repeat(${gridDimensions.maxRow + 1 + (activeDragType === 'column' ? 1 : 0)}, auto)`,
636-
}}
637+
suppressHydrationWarning
638+
style={
639+
isMounted
640+
? {
641+
// Add extra column when dragging to allow insertion at end
642+
gridTemplateColumns: `repeat(${gridDimensions.maxCol + 1 + (activeDragType === 'column' ? 1 : 0)}, minmax(280px, 1fr))`,
643+
// Add extra row for NewRowDropZone when dragging
644+
gridTemplateRows: `repeat(${gridDimensions.maxRow + 1 + (activeDragType === 'column' ? 1 : 0)}, auto)`,
645+
}
646+
: {
647+
// Stable initial styles for SSR hydration
648+
gridTemplateColumns: 'repeat(1, minmax(280px, 1fr))',
649+
gridTemplateRows: 'repeat(1, auto)',
650+
}
651+
}
637652
>
638-
{sortedStatuses.map((status) => (
639-
<SortableColumn
640-
key={status.id}
641-
status={status}
642-
cards={cards.filter((c) => c.statusId === status.id)}
643-
onEdit={onEditProjectInfo}
644-
onMaintenance={onMoveToMaintenance}
645-
onNote={onNote}
646-
onEditStatus={onEditStatus}
647-
onDeleteStatus={onDeleteStatus}
648-
onAddCard={onAddCard}
649-
gridStyle={{
650-
gridRow: status.gridRow + 1, // CSS grid is 1-indexed
651-
gridColumn: status.gridCol + 1,
652-
}}
653-
/>
654-
))}
653+
{/* Render columns only after hydration to prevent SSR mismatch */}
654+
{isMounted &&
655+
sortedStatuses.map((status) => (
656+
<SortableColumn
657+
key={status.id}
658+
status={status}
659+
cards={cards.filter((c) => c.statusId === status.id)}
660+
onEdit={onEditProjectInfo}
661+
onMaintenance={onMoveToMaintenance}
662+
onNote={onNote}
663+
onEditStatus={onEditStatus}
664+
onDeleteStatus={onDeleteStatus}
665+
onAddCard={onAddCard}
666+
gridStyle={{
667+
gridRow: status.gridRow + 1, // CSS grid is 1-indexed
668+
gridColumn: status.gridCol + 1,
669+
}}
670+
/>
671+
))}
655672

656673
{/* Column Insert Zones - empty grid positions during column drag */}
657-
{insertionZones.map((zone) => (
658-
<ColumnInsertZone
659-
key={`insert-${zone.gridRow}-${zone.gridCol}`}
660-
gridRow={zone.gridRow}
661-
gridCol={zone.gridCol}
662-
activeColumnId={activeId?.toString()}
663-
/>
664-
))}
674+
{isMounted &&
675+
insertionZones.map((zone) => (
676+
<ColumnInsertZone
677+
key={`insert-${zone.gridRow}-${zone.gridCol}`}
678+
gridRow={zone.gridRow}
679+
gridCol={zone.gridCol}
680+
activeColumnId={activeId?.toString()}
681+
/>
682+
))}
665683

666684
{/* New Row Drop Zone - only visible during column drag */}
667-
{activeDragType === 'column' && (
685+
{isMounted && activeDragType === 'column' && (
668686
<NewRowDropZone
669687
targetRow={gridDimensions.maxRow + 1}
670688
columnCount={gridDimensions.maxCol + 1 + 1} // +1 for expanded grid
@@ -677,7 +695,7 @@ export const KanbanBoard = memo<KanbanBoardProps>(
677695
<DragOverlay>
678696
{activeDragType === 'column' && activeId ? (
679697
// Column drag preview for 2D grid layout
680-
<div className="w-[280px] max-w-full bg-background/80 backdrop-blur-sm rounded-xl p-4 border-2 border-primary shadow-2xl opacity-90 rotate-2">
698+
<div className="w-70 max-w-full bg-background/80 backdrop-blur-sm rounded-xl p-4 border-2 border-primary shadow-2xl opacity-90 rotate-2">
681699
<h3 className="font-semibold text-foreground">
682700
{sortedStatuses.find((s) => s.id === activeId)?.title}
683701
</h3>

lib/actions/github.ts

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,100 @@
1010
import { cookies } from 'next/headers'
1111

1212
import { getGitHubTokenCookieName } from '@/lib/constants/cookies'
13+
import { isTestMode } from '@/tests/isTestMode'
1314

1415
const GITHUB_API_BASE_URL = 'https://api.github.com'
1516

17+
/**
18+
* Mock data for E2E testing
19+
* Matches the mock data in mocks/handlers.ts
20+
*/
21+
const MOCK_GITHUB_USER = {
22+
id: 12345,
23+
login: 'testuser',
24+
avatar_url: 'https://avatars.githubusercontent.com/u/12345?v=4',
25+
name: 'Test User',
26+
type: 'User' as const,
27+
}
28+
29+
const MOCK_GITHUB_REPOS = [
30+
{
31+
id: 1,
32+
node_id: 'R_kgDOGq0qMQ',
33+
name: 'test-repo',
34+
full_name: 'testuser/test-repo',
35+
owner: {
36+
login: 'testuser',
37+
avatar_url: 'https://avatars.githubusercontent.com/u/12345?v=4',
38+
},
39+
description: 'A test repository for GitBox',
40+
html_url: 'https://github.com/testuser/test-repo',
41+
homepage: 'https://test-repo.dev',
42+
stargazers_count: 42,
43+
watchers_count: 42,
44+
language: 'TypeScript',
45+
topics: ['react', 'nextjs'],
46+
visibility: 'public' as const,
47+
updated_at: '2024-01-15T00:00:00.000Z',
48+
created_at: '2023-01-01T00:00:00.000Z',
49+
},
50+
{
51+
id: 2,
52+
node_id: 'R_kgDOGq0qMg',
53+
name: 'another-repo',
54+
full_name: 'testuser/another-repo',
55+
owner: {
56+
login: 'testuser',
57+
avatar_url: 'https://avatars.githubusercontent.com/u/12345?v=4',
58+
},
59+
description: 'Another test repository',
60+
html_url: 'https://github.com/testuser/another-repo',
61+
homepage: null,
62+
stargazers_count: 128,
63+
watchers_count: 128,
64+
language: 'JavaScript',
65+
topics: ['nodejs', 'api'],
66+
visibility: 'public' as const,
67+
updated_at: '2024-01-10T00:00:00.000Z',
68+
created_at: '2023-06-01T00:00:00.000Z',
69+
},
70+
{
71+
id: 3,
72+
node_id: 'R_kgDOGq0qMz',
73+
name: 'private-project',
74+
full_name: 'testuser/private-project',
75+
owner: {
76+
login: 'testuser',
77+
avatar_url: 'https://avatars.githubusercontent.com/u/12345?v=4',
78+
},
79+
description: 'A private project',
80+
html_url: 'https://github.com/testuser/private-project',
81+
homepage: null,
82+
stargazers_count: 0,
83+
watchers_count: 1,
84+
language: 'Python',
85+
topics: ['private', 'internal'],
86+
visibility: 'private' as const,
87+
updated_at: '2024-01-20T00:00:00.000Z',
88+
created_at: '2024-01-01T00:00:00.000Z',
89+
},
90+
]
91+
92+
const MOCK_GITHUB_ORGS = [
93+
{
94+
id: 100,
95+
login: 'laststance',
96+
avatar_url: 'https://avatars.githubusercontent.com/u/100?v=4',
97+
description: 'Laststance.io organization',
98+
},
99+
{
100+
id: 101,
101+
login: 'test-org',
102+
avatar_url: 'https://avatars.githubusercontent.com/u/101?v=4',
103+
description: 'Test organization for development',
104+
},
105+
]
106+
16107
export interface GitHubRepository {
17108
id: number
18109
node_id: string
@@ -51,6 +142,11 @@ export async function getAuthenticatedUserRepositories(params?: {
51142
per_page?: number
52143
page?: number
53144
}): Promise<{ data: GitHubRepository[] | null; error: string | null }> {
145+
// E2E test mode: return mock data
146+
if (isTestMode()) {
147+
return { data: MOCK_GITHUB_REPOS, error: null }
148+
}
149+
54150
const token = await getGitHubToken()
55151

56152
if (!token) {
@@ -119,6 +215,20 @@ export async function searchRepositories(params: {
119215
data: { total_count: number; items: GitHubRepository[] } | null
120216
error: string | null
121217
}> {
218+
// E2E test mode: return filtered mock data
219+
if (isTestMode()) {
220+
const query = params.q.toLowerCase()
221+
const filtered = MOCK_GITHUB_REPOS.filter(
222+
(repo) =>
223+
repo.name.toLowerCase().includes(query) ||
224+
(repo.description?.toLowerCase().includes(query) ?? false),
225+
)
226+
return {
227+
data: { total_count: filtered.length, items: filtered },
228+
error: null,
229+
}
230+
}
231+
122232
const token = await getGitHubToken()
123233

124234
if (!token) {
@@ -185,6 +295,15 @@ export async function getRepository(
185295
owner: string,
186296
repo: string,
187297
): Promise<{ data: GitHubRepository | null; error: string | null }> {
298+
// E2E test mode: return mock data
299+
if (isTestMode()) {
300+
const fullName = `${owner}/${repo}`
301+
const found = MOCK_GITHUB_REPOS.find((r) => r.full_name === fullName)
302+
return found
303+
? { data: found, error: null }
304+
: { data: null, error: 'Repository not found.' }
305+
}
306+
188307
const token = await getGitHubToken()
189308

190309
if (!token) {
@@ -247,6 +366,11 @@ export async function checkGitHubTokenValidity(): Promise<{
247366
valid: boolean
248367
error: string | null
249368
}> {
369+
// E2E test mode: always valid
370+
if (isTestMode()) {
371+
return { valid: true, error: null }
372+
}
373+
250374
const token = await getGitHubToken()
251375

252376
if (!token) {
@@ -307,6 +431,11 @@ export async function getAuthenticatedUser(): Promise<{
307431
data: GitHubUser | null
308432
error: string | null
309433
}> {
434+
// E2E test mode: return mock user
435+
if (isTestMode()) {
436+
return { data: MOCK_GITHUB_USER, error: null }
437+
}
438+
310439
const token = await getGitHubToken()
311440

312441
if (!token) {
@@ -368,6 +497,11 @@ export async function getAuthenticatedUserOrganizations(): Promise<{
368497
data: GitHubOrganization[] | null
369498
error: string | null
370499
}> {
500+
// E2E test mode: return mock organizations
501+
if (isTestMode()) {
502+
return { data: MOCK_GITHUB_ORGS, error: null }
503+
}
504+
371505
const token = await getGitHubToken()
372506

373507
if (!token) {

package.json

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
"lint:fix": "eslint . --fix --concurrency=auto --max-warnings 0",
2929
"test": "vitest",
3030
"test:e2e": "playwright test",
31-
"test:e2e:ui": "playwright test --ui",
3231
"prepare": "husky",
3332
"prettier": "prettier --ignore-unknown --write .",
3433
"storybook": "storybook dev -p 6006",

playwright.config.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,18 @@ export default defineConfig({
7373
],
7474

7575
webServer: {
76-
command: 'pnpm build && pnpm start',
76+
/**
77+
* Build and start with test environment variables.
78+
*
79+
* CRITICAL: NEXT_PUBLIC_* vars must be set at BUILD time because Next.js
80+
* inlines them during the build process. Setting them only at runtime
81+
* (via env property below) is NOT sufficient.
82+
*
83+
* The env vars are explicitly set in the command to ensure they're
84+
* available during both build and start phases.
85+
*/
86+
command:
87+
'NEXT_PUBLIC_ENABLE_MSW_MOCK=true APP_ENV=test pnpm build && pnpm start',
7788
url: 'http://localhost:3008',
7889
reuseExistingServer: !process.env.CI,
7990
timeout: 120000,

proxy.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -11,19 +11,14 @@
1111
import { createServerClient, type CookieOptions } from '@supabase/ssr'
1212
import { NextResponse, type NextRequest } from 'next/server'
1313

14+
import { isTestMode } from '@/tests/isTestMode'
15+
1416
// Public paths that don't require authentication
1517
const publicPaths = ['/', '/login', '/auth/callback']
1618

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-
2419
export async function proxy(request: NextRequest) {
2520
// In E2E test mode, bypass authentication and allow all requests
26-
if (isE2ETestMode()) {
21+
if (isTestMode()) {
2722
return NextResponse.next({
2823
request: {
2924
headers: request.headers,

tests/e2e/add-repository-combobox.spec.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -142,15 +142,25 @@ test.describe('AddRepositoryCombobox - GITBOX-1 Fix', () => {
142142
await expect(searchInput).toBeVisible({ timeout: 10000 })
143143

144144
// Verify organization filter selector is present
145-
const orgFilterTrigger = page.getByRole('combobox').first()
145+
// Note: Using aria-label to target the specific organization filter combobox
146+
const orgFilterTrigger = page.getByRole('combobox', {
147+
name: /organization filter/i,
148+
})
146149
await expect(orgFilterTrigger).toBeVisible()
147150

148-
// Click the organization filter
151+
// Click the organization filter to open dropdown
149152
await orgFilterTrigger.click()
150153

151-
// Wait for dropdown options to appear
152-
const allOption = page.getByRole('option', { name: /all organizations/i })
153-
await expect(allOption).toBeVisible({ timeout: 5000 })
154+
// Wait for dropdown to appear - Radix Select uses listbox role for the dropdown content
155+
// The options are rendered in a portal, so we need to look for them in the page
156+
await page.waitForTimeout(500) // Give time for dropdown animation
157+
158+
// Look for "All Organizations" option in the dropdown
159+
// Radix Select items have role="option" when the dropdown is open
160+
const allOption = page.locator('[role="option"]', {
161+
hasText: /all organizations/i,
162+
})
163+
await expect(allOption.first()).toBeVisible({ timeout: 5000 })
154164
})
155165

156166
/**

tests/isTestMode.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
/**
2+
* Check if E2E test mode is enabled
3+
* When true, GitHub actions return mock data to bypass real API calls
4+
*/
5+
export const isTestMode = () =>
6+
process.env.NEXT_PUBLIC_ENABLE_MSW_MOCK === 'true' &&
7+
(process.env.APP_ENV === 'test' || process.env.NODE_ENV === 'test')

0 commit comments

Comments
 (0)