Skip to content

Commit bf8352e

Browse files
Dev Userclaude
andcommitted
fix(e2e): cherry-pick auth test fixes from TASK_12013 eval (models A+B)
Best-of-both from the A/B evaluation: Model B's dual-network Docker setup, dynamic auth origin matching, and scoped test assertions combined with Model A's admin UUID migration fix and container-aware seed script. Key fixes: - supabase-up.sh writes Docker service name to .env.supabase.local - e2e-tests container gets NEXT_PUBLIC_SUPABASE_URL for test-user-factory - Dockerfile.e2e pnpm version updated for lockfile compatibility - auth.setup.ts uses dynamic BASE_URL origin instead of hardcoded localhost Verified: 405 passed, 11 failed (firefox NS_BINDING_ABORTED), 13 flaky (webkit timeouts). Chromium and all mobile projects clean. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 4aebdc8 commit bf8352e

File tree

9 files changed

+108
-46
lines changed

9 files changed

+108
-46
lines changed

docker/Dockerfile.e2e

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
FROM mcr.microsoft.com/playwright:v1.48.0-focal
33

44
# Install pnpm
5-
RUN npm install -g pnpm@8
5+
RUN npm install -g pnpm@latest
66

77
WORKDIR /app
88

docker/docker-compose.e2e.yml

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@
44
#
55
# Discover dynamically assigned ports:
66
# docker compose -f docker/docker-compose.e2e.yml port spoketowork 3000
7+
#
8+
# The spoketowork-net external network is created by the main docker-compose.yml
9+
# (network name: ${COMPOSE_PROJECT_NAME:-spoketowork}_SpokeToWork).
10+
# Both services join it so e2e-tests can reach supabase-kong for admin API calls.
711

812
services:
913
# Main application service
@@ -20,17 +24,22 @@ services:
2024
- ..:/app
2125
- /app/node_modules
2226
- /app/.next
27+
env_file:
28+
- ../.env
29+
- path: ../.env.supabase.local
30+
required: false
2331
environment:
2432
- WATCHPACK_POLLING=true
2533
- CHOKIDAR_USEPOLLING=true
2634
healthcheck:
27-
test: ['CMD', 'curl', '-f', 'http://localhost:3000/SpokeToWork']
35+
test: ['CMD', 'curl', '-f', 'http://localhost:3000/']
2836
interval: 30s
2937
timeout: 10s
3038
retries: 3
3139
start_period: 40s
3240
networks:
3341
- e2e-network
42+
- spoketowork-net
3443

3544
# E2E testing service with Playwright
3645
e2e-tests:
@@ -44,12 +53,18 @@ services:
4453
- ../tests/e2e:/app/tests/e2e
4554
- ../test-results:/app/test-results
4655
- ../playwright-report:/app/playwright-report
56+
env_file:
57+
- ../.env
4758
environment:
48-
- BASE_URL=http://spoketowork:3000/SpokeToWork
59+
- BASE_URL=http://spoketowork:3000
4960
- CI=false
5061
- PWDEBUG=${PWDEBUG:-0}
62+
# Override to use Docker service name (container→container, not host-mapped port)
63+
- NEXT_PUBLIC_SUPABASE_URL=http://supabase-kong:8000
64+
- SUPABASE_INTERNAL_URL=http://supabase-kong:8000
5165
networks:
5266
- e2e-network
67+
- spoketowork-net
5368
command: pnpm test:e2e
5469

5570
# Optional: Playwright UI mode service
@@ -66,18 +81,27 @@ services:
6681
- ../tests/e2e:/app/tests/e2e
6782
- ../test-results:/app/test-results
6883
- ../playwright-report:/app/playwright-report
84+
env_file:
85+
- ../.env
6986
environment:
70-
- BASE_URL=http://spoketowork:3000/SpokeToWork
87+
- BASE_URL=http://spoketowork:3000
7188
- DISPLAY=:99
89+
- NEXT_PUBLIC_SUPABASE_URL=http://supabase-kong:8000
90+
- SUPABASE_INTERNAL_URL=http://supabase-kong:8000
7291
networks:
7392
- e2e-network
93+
- spoketowork-net
7494
command: pnpm test:e2e:ui
7595
profiles:
7696
- ui
7797

7898
networks:
7999
e2e-network:
80100
driver: bridge
101+
# External network created by the main docker-compose.yml Supabase stack
102+
spoketowork-net:
103+
external: true
104+
name: ${COMPOSE_PROJECT_NAME:-spoketowork}_SpokeToWork
81105

82106
volumes:
83107
test-results:

scripts/seed-test-users.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@
2323
import { createClient } from '@supabase/supabase-js';
2424
import * as crypto from 'crypto';
2525

26-
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL;
26+
// Prefer SUPABASE_INTERNAL_URL when running inside Docker (container→container)
27+
// Fall back to NEXT_PUBLIC_SUPABASE_URL for host-based runs
28+
const supabaseUrl =
29+
process.env.SUPABASE_INTERNAL_URL ?? process.env.NEXT_PUBLIC_SUPABASE_URL;
2730
const supabaseServiceKey = process.env.SUPABASE_SERVICE_ROLE_KEY;
2831

2932
// Get credentials from env vars - REQUIRED, no fallbacks
@@ -139,6 +142,7 @@ async function setupAdminUser(): Promise<boolean> {
139142
console.log(' 🔐 Creating admin auth user...');
140143
const { data: authData, error: authError } =
141144
await supabase.auth.admin.createUser({
145+
id: ADMIN_USER.id, // Fixed UUID for consistent welcome message sender
142146
email: ADMIN_USER.email,
143147
password: 'AdminPassword123!', // Not used - no login needed
144148
email_confirm: true,

scripts/supabase-up.sh

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,12 @@ if [ -z "$KONG_PORT" ]; then
3030
exit 1
3131
fi
3232

33-
# Write dynamic Supabase config for the app container
33+
# Write Supabase config for the app container.
34+
# Use the Docker service name (supabase-kong:8000) so that Playwright/Next.js
35+
# running INSIDE the container can reach Supabase via the internal network.
36+
# Host-side browser access uses the host-mapped port printed at the end.
3437
cat > .env.supabase.local <<EOF
35-
NEXT_PUBLIC_SUPABASE_URL=http://localhost:$KONG_PORT
38+
NEXT_PUBLIC_SUPABASE_URL=http://supabase-kong:8000
3639
NEXT_PUBLIC_SUPABASE_ANON_KEY=$ANON_KEY
3740
EOF
3841

supabase/migrations/20251006_complete_monolithic_setup.sql

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1285,11 +1285,11 @@ COMMENT ON TABLE group_keys IS 'Encrypted symmetric group keys per member per ve
12851285
-- ============================================================================
12861286

12871287
-- Admin profile for system welcome messages (Feature 002)
1288-
-- Fixed UUID: a30ac480-9050-4853-b0ae-4e3d9e24259d
1288+
-- Fixed UUID: 00000000-0000-0000-0000-000000000001 (matches seed-test-users.ts)
12891289
-- Only insert if admin user exists in auth.users (created via Supabase Auth)
12901290
INSERT INTO user_profiles (id, username, display_name, welcome_message_sent)
1291-
SELECT 'a30ac480-9050-4853-b0ae-4e3d9e24259d', 'spoketowork', 'SpokeToWork', TRUE
1292-
WHERE EXISTS (SELECT 1 FROM auth.users WHERE id = 'a30ac480-9050-4853-b0ae-4e3d9e24259d')
1291+
SELECT '00000000-0000-0000-0000-000000000001', 'spoketowork', 'SpokeToWork', TRUE
1292+
WHERE EXISTS (SELECT 1 FROM auth.users WHERE id = '00000000-0000-0000-0000-000000000001')
12931293
ON CONFLICT (id) DO NOTHING;
12941294

12951295
-- ============================================================================

tests/e2e/auth.setup.ts

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -49,12 +49,23 @@ function isAuthStateValid(): boolean {
4949

5050
const state = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8'));
5151

52-
// Find origin for localhost (support both 3000 and 3001)
53-
const origin = state.origins?.find(
54-
(o: { origin: string }) =>
55-
o.origin.includes('localhost:3000') ||
56-
o.origin.includes('localhost:3001')
57-
);
52+
// Find origin matching the base URL host (localhost or Docker service name).
53+
// BASE_URL may be http://localhost:3000 or http://spoketowork:3000 etc.
54+
const baseHost = (() => {
55+
try {
56+
return new URL(process.env.BASE_URL || 'http://localhost:3000').host;
57+
} catch {
58+
return 'localhost:3000';
59+
}
60+
})();
61+
62+
const origin = state.origins?.find((o: { origin: string }) => {
63+
try {
64+
return new URL(o.origin).host === baseHost;
65+
} catch {
66+
return false;
67+
}
68+
});
5869

5970
if (!origin) {
6071
console.log('No localhost origin found in auth state');

tests/e2e/auth/session-persistence.spec.ts

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -136,8 +136,11 @@ test.describe('Session Persistence E2E', () => {
136136

137137
// Tokens might be same if not near expiry, but refresh mechanism should exist
138138
// The important part is that navigation doesn't break authentication
139-
await expect(page).toHaveURL('/profile');
140-
await expect(page.getByText(testUser.email)).toBeVisible();
139+
await expect(page).toHaveURL(/\/profile/);
140+
// Email appears in multiple places (nav, card title, card body) — target heading
141+
await expect(
142+
page.getByRole('heading', { name: testUser.email })
143+
).toBeVisible();
141144

142145
// Sign out
143146
await signOut(page);
@@ -173,8 +176,10 @@ test.describe('Session Persistence E2E', () => {
173176
await newPage.goto('/profile');
174177

175178
// Verify still authenticated
176-
await expect(newPage).toHaveURL('/profile');
177-
await expect(newPage.getByText(testUser.email)).toBeVisible();
179+
await expect(newPage).toHaveURL(/\/profile/);
180+
await expect(
181+
newPage.getByRole('heading', { name: testUser.email })
182+
).toBeVisible();
178183

179184
await newContext.close();
180185
});
@@ -195,6 +200,8 @@ test.describe('Session Persistence E2E', () => {
195200

196201
// Sign out (with verify: false since we verify manually below)
197202
await signOut(page, { verify: false });
203+
// Wait for page to fully settle after sign-out navigation (window.location.href = '/')
204+
await page.waitForLoadState('networkidle');
198205

199206
// Verify session cleared from storage
200207
const afterSignOut = await page.evaluate(() =>
@@ -232,17 +239,22 @@ test.describe('Session Persistence E2E', () => {
232239

233240
// Page 2 should also be authenticated (shared storage)
234241
await page2.goto('/profile');
235-
await expect(page2).toHaveURL('/profile');
236-
await expect(page2.getByText(testUser.email)).toBeVisible();
242+
await expect(page2).toHaveURL(/\/profile/);
243+
await expect(
244+
page2.getByRole('heading', { name: testUser.email })
245+
).toBeVisible();
237246

238247
// Sign out on page 1 (using signOut helper with page1)
239248
await signOut(page1, { verify: false });
240249

241-
// Page 2 should detect sign out (if using realtime sync)
242-
// Note: This depends on implementation - may require page reload
243-
await page2.reload();
244-
await page2.waitForURL(/\/sign-in/);
245-
await expect(page2).toHaveURL(/\/sign-in/);
250+
// Page 2 detects sign out via cross-tab SIGNED_OUT event (FR-009)
251+
// AuthContext.onAuthStateChange redirects to home '/', not '/sign-in'
252+
await page2.waitForURL(
253+
(url) => url.pathname === '/' || url.pathname.includes('/sign-in'),
254+
{ timeout: 10000 }
255+
);
256+
// Verify we're no longer on the profile page
257+
await expect(page2).not.toHaveURL(/\/profile/);
246258

247259
await context.close();
248260
});
@@ -260,12 +272,14 @@ test.describe('Session Persistence E2E', () => {
260272
// Reload page
261273
await page.reload();
262274

263-
// Verify still authenticated
264-
await expect(page.getByText(testUser.email)).toBeVisible();
275+
// Verify still authenticated (email appears in nav, card title, card body — target heading)
276+
await expect(
277+
page.getByRole('heading', { name: testUser.email })
278+
).toBeVisible();
265279

266280
// Navigate to another protected route
267281
await page.goto('/account');
268-
await expect(page).toHaveURL('/account');
282+
await expect(page).toHaveURL(/\/account/);
269283

270284
// Sign out
271285
await signOut(page);

tests/e2e/auth/user-registration.spec.ts

Lines changed: 20 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,21 +21,24 @@ test.describe('User Registration E2E', () => {
2121
}) => {
2222
// Step 1: Navigate to sign-up page
2323
await page.goto('/sign-up');
24-
await expect(page).toHaveURL('/sign-up');
25-
await expect(page.getByRole('heading', { name: 'Sign Up' })).toBeVisible();
24+
await expect(page).toHaveURL(/\/sign-up/);
25+
// Page heading is "Create Account"
26+
await expect(
27+
page.getByRole('heading', { name: 'Create Account' })
28+
).toBeVisible();
2629

27-
// Step 2: Fill sign-up form
30+
// Step 3: Fill sign-up form
2831
await page.getByLabel('Email').fill(testEmail);
2932
await page.getByLabel('Password', { exact: true }).fill(testPassword);
3033
await page.getByLabel('Confirm Password').fill(testPassword);
3134

32-
// Step 3: Check Remember Me (optional)
35+
// Step 4: Check Remember Me (optional)
3336
await page.getByLabel('Remember me').check();
3437

35-
// Step 4: Submit sign-up form
38+
// Step 5: Submit sign-up form
3639
await page.getByRole('button', { name: 'Sign Up' }).click();
3740

38-
// Step 5: Verify redirected to verify-email or profile
41+
// Step 6: Verify redirected to verify-email or profile
3942
// Note: In production, email verification is required
4043
await page.waitForURL(/\/(verify-email|profile)/);
4144

@@ -74,16 +77,18 @@ test.describe('User Registration E2E', () => {
7477
test('should show validation errors for invalid email', async ({ page }) => {
7578
await page.goto('/sign-up');
7679

77-
// Fill with invalid email
78-
await page.getByLabel('Email').fill('not-an-email');
80+
// Use email that passes HTML5 validation but fails app's TLD validation
81+
await page.getByLabel('Email').fill('user@invalid.badtld');
7982
await page.getByLabel('Password', { exact: true }).fill(testPassword);
8083
await page.getByLabel('Confirm Password').fill(testPassword);
8184

8285
// Submit form
8386
await page.getByRole('button', { name: 'Sign Up' }).click();
8487

85-
// Verify validation error shown
86-
await expect(page.getByText(/invalid email/i)).toBeVisible();
88+
// Verify validation error shown (TLD validation message)
89+
await expect(
90+
page.getByText(/invalid|missing.*TLD|top-level domain/i)
91+
).toBeVisible();
8792
});
8893

8994
test('should show validation errors for weak password', async ({ page }) => {
@@ -121,8 +126,8 @@ test.describe('User Registration E2E', () => {
121126
test('should navigate to sign-in from sign-up page', async ({ page }) => {
122127
await page.goto('/sign-up');
123128

124-
// Click sign-in link
125-
await page.getByRole('link', { name: /already have an account/i }).click();
129+
// Click inline sign-in link (text is "Sign in", not "Already have an account")
130+
await page.getByRole('link', { name: 'Sign in', exact: true }).click();
126131

127132
// Verify navigated to sign-in
128133
await expect(page).toHaveURL(/\/sign-in/);
@@ -131,12 +136,12 @@ test.describe('User Registration E2E', () => {
131136
test('should display OAuth buttons on sign-up page', async ({ page }) => {
132137
await page.goto('/sign-up');
133138

134-
// Verify OAuth buttons present
139+
// Verify OAuth buttons present (text is "Continue with GitHub/Google")
135140
await expect(
136-
page.getByRole('button', { name: /sign up with github/i })
141+
page.getByRole('button', { name: /continue with github/i })
137142
).toBeVisible();
138143
await expect(
139-
page.getByRole('button', { name: /sign up with google/i })
144+
page.getByRole('button', { name: /continue with google/i })
140145
).toBeVisible();
141146
});
142147
});

tests/e2e/utils/auth-helpers.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,8 @@ export async function loginAndVerify(
4848
credentials: LoginCredentials,
4949
options: LoginOptions = {}
5050
): Promise<void> {
51-
const { urlTimeout = 10000, elementTimeout = 15000 } = options;
51+
// Use longer timeouts for local Supabase which can be slow under concurrent load
52+
const { urlTimeout = 30000, elementTimeout = 30000 } = options;
5253

5354
// Navigate to sign-in page
5455
await page.goto('/sign-in');

0 commit comments

Comments
 (0)