Enhance auth flows and improve e2e test reliability
- Remove redundant `'use client'` directives in auth pages to streamline code. - Refine Playwright config: adjust worker limits and add video recording for failed tests. - Improve session management in e2e tests with isolated state clearing, console log collection, and detailed failure attachments. - Update API client: better handle auth routes, ensure safe token refresh, and prevent unnecessary redirects.
This commit is contained in:
@@ -1,11 +1,81 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Login Flow', () => {
|
test.describe('Login Flow', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
// Collect browser console logs per test for debugging
|
||||||
|
let consoleLogs: string[] = [];
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page, context }) => {
|
||||||
|
consoleLogs = [];
|
||||||
|
|
||||||
|
// Capture console logs
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
try {
|
||||||
|
consoleLogs.push(`[${msg.type()}] ${msg.text()}`);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure clean state across parallel workers
|
||||||
|
await context.clearCookies();
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
// Navigate to login page before each test
|
// Navigate to login page before each test
|
||||||
await page.goto('/login');
|
await page.goto('/login');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.status !== 'passed') {
|
||||||
|
// Attach current URL
|
||||||
|
await testInfo.attach('page-url.txt', {
|
||||||
|
body: page.url(),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach skeleton count to see if page stuck on loading state
|
||||||
|
try {
|
||||||
|
const skeletonCount = await page.locator('.animate-pulse').count();
|
||||||
|
await testInfo.attach('skeleton-count.txt', {
|
||||||
|
body: String(skeletonCount),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Attach full DOM snapshot
|
||||||
|
try {
|
||||||
|
const html = await page.content();
|
||||||
|
await testInfo.attach('dom.html', {
|
||||||
|
body: html,
|
||||||
|
contentType: 'text/html',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Attach full page screenshot
|
||||||
|
try {
|
||||||
|
const img = await page.screenshot({ fullPage: true });
|
||||||
|
await testInfo.attach('screenshot.png', {
|
||||||
|
body: img,
|
||||||
|
contentType: 'image/png',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Attach console logs
|
||||||
|
try {
|
||||||
|
await testInfo.attach('console.log', {
|
||||||
|
body: consoleLogs.join('\n'),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('should display login form', async ({ page }) => {
|
test('should display login form', async ({ page }) => {
|
||||||
// Check page title
|
// Check page title
|
||||||
await expect(page.locator('h2')).toContainText('Sign in to your account');
|
await expect(page.locator('h2')).toContainText('Sign in to your account');
|
||||||
|
|||||||
@@ -1,11 +1,81 @@
|
|||||||
import { test, expect } from '@playwright/test';
|
import { test, expect } from '@playwright/test';
|
||||||
|
|
||||||
test.describe('Registration Flow', () => {
|
test.describe('Registration Flow', () => {
|
||||||
test.beforeEach(async ({ page }) => {
|
test.describe.configure({ mode: 'serial' });
|
||||||
|
|
||||||
|
// Collect browser console logs per test for debugging
|
||||||
|
let consoleLogs: string[] = [];
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page, context }) => {
|
||||||
|
consoleLogs = [];
|
||||||
|
|
||||||
|
// Capture console logs
|
||||||
|
page.on('console', (msg) => {
|
||||||
|
try {
|
||||||
|
consoleLogs.push(`[${msg.type()}] ${msg.text()}`);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Ensure clean state across parallel workers
|
||||||
|
await context.clearCookies();
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
try {
|
||||||
|
localStorage.clear();
|
||||||
|
sessionStorage.clear();
|
||||||
|
} catch {}
|
||||||
|
});
|
||||||
|
|
||||||
// Navigate to register page before each test
|
// Navigate to register page before each test
|
||||||
await page.goto('/register');
|
await page.goto('/register');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test.afterEach(async ({ page }, testInfo) => {
|
||||||
|
if (testInfo.status !== 'passed') {
|
||||||
|
// Attach current URL
|
||||||
|
await testInfo.attach('page-url.txt', {
|
||||||
|
body: page.url(),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Attach skeleton count to see if page stuck on loading state
|
||||||
|
try {
|
||||||
|
const skeletonCount = await page.locator('.animate-pulse').count();
|
||||||
|
await testInfo.attach('skeleton-count.txt', {
|
||||||
|
body: String(skeletonCount),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Attach full DOM snapshot
|
||||||
|
try {
|
||||||
|
const html = await page.content();
|
||||||
|
await testInfo.attach('dom.html', {
|
||||||
|
body: html,
|
||||||
|
contentType: 'text/html',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Attach full page screenshot
|
||||||
|
try {
|
||||||
|
const img = await page.screenshot({ fullPage: true });
|
||||||
|
await testInfo.attach('screenshot.png', {
|
||||||
|
body: img,
|
||||||
|
contentType: 'image/png',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
// Attach console logs
|
||||||
|
try {
|
||||||
|
await testInfo.attach('console.log', {
|
||||||
|
body: consoleLogs.join('\n'),
|
||||||
|
contentType: 'text/plain',
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('should display registration form', async ({ page }) => {
|
test('should display registration form', async ({ page }) => {
|
||||||
// Check page title
|
// Check page title
|
||||||
await expect(page.locator('h2')).toContainText('Create your account');
|
await expect(page.locator('h2')).toContainText('Create your account');
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ export default defineConfig({
|
|||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI and locally to handle flaky tests */
|
/* Retry on CI and locally to handle flaky tests */
|
||||||
retries: process.env.CI ? 2 : 1,
|
retries: process.env.CI ? 2 : 1,
|
||||||
/* Limit workers to prevent test interference */
|
/* Limit workers to prevent test interference and Next dev server overload */
|
||||||
workers: process.env.CI ? 1 : 12,
|
workers: process.env.CI ? 1 : 8,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: process.env.CI ? 'github' : 'list',
|
reporter: process.env.CI ? 'github' : 'list',
|
||||||
/* Suppress console output unless VERBOSE=true */
|
/* Suppress console output unless VERBOSE=true */
|
||||||
@@ -31,6 +31,8 @@ export default defineConfig({
|
|||||||
trace: 'on-first-retry',
|
trace: 'on-first-retry',
|
||||||
/* Screenshot on failure */
|
/* Screenshot on failure */
|
||||||
screenshot: 'only-on-failure',
|
screenshot: 'only-on-failure',
|
||||||
|
/* Record video for failed tests to diagnose flakiness */
|
||||||
|
video: 'retain-on-failure',
|
||||||
},
|
},
|
||||||
|
|
||||||
/* Configure projects for major browsers */
|
/* Configure projects for major browsers */
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
// Code-split LoginForm - heavy with react-hook-form + validation
|
// Code-split LoginForm - heavy with react-hook-form + validation
|
||||||
|
|||||||
@@ -3,8 +3,6 @@
|
|||||||
* Users enter their email to receive reset instructions
|
* Users enter their email to receive reset instructions
|
||||||
*/
|
*/
|
||||||
|
|
||||||
'use client';
|
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
// Code-split PasswordResetRequestForm
|
// Code-split PasswordResetRequestForm
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import dynamic from 'next/dynamic';
|
import dynamic from 'next/dynamic';
|
||||||
|
|
||||||
// Code-split RegisterForm (313 lines)
|
// Code-split RegisterForm (313 lines)
|
||||||
|
|||||||
@@ -95,18 +95,19 @@ async function refreshAccessToken(): Promise<string> {
|
|||||||
console.error('[API Client] Token refresh failed:', error);
|
console.error('[API Client] Token refresh failed:', error);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear auth and redirect to login
|
// Clear auth state
|
||||||
const authStore = await getAuthStore();
|
const authStore = await getAuthStore();
|
||||||
await authStore.clearAuth();
|
await authStore.clearAuth();
|
||||||
|
|
||||||
// Redirect to login if we're in browser
|
// Only redirect to login when not already on an auth route
|
||||||
if (typeof window !== 'undefined') {
|
if (typeof window !== 'undefined') {
|
||||||
const currentPath = window.location.pathname;
|
const currentPath = window.location.pathname;
|
||||||
const returnUrl = currentPath !== '/login' && currentPath !== '/register'
|
const onAuthRoute = currentPath === '/login' || currentPath === '/register' || currentPath.startsWith('/password-reset');
|
||||||
? `?returnUrl=${encodeURIComponent(currentPath)}`
|
if (!onAuthRoute) {
|
||||||
: '';
|
const returnUrl = currentPath ? `?returnUrl=${encodeURIComponent(currentPath)}` : '';
|
||||||
window.location.href = `/login${returnUrl}`;
|
window.location.href = `/login${returnUrl}`;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
throw error;
|
throw error;
|
||||||
} finally {
|
} finally {
|
||||||
@@ -129,8 +130,12 @@ client.instance.interceptors.request.use(
|
|||||||
const authStore = await getAuthStore();
|
const authStore = await getAuthStore();
|
||||||
const { accessToken } = authStore;
|
const { accessToken } = authStore;
|
||||||
|
|
||||||
// Add Authorization header if token exists
|
// Do not attach Authorization header for auth endpoints
|
||||||
if (accessToken && requestConfig.headers) {
|
const url = requestConfig.url || '';
|
||||||
|
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/refresh') || url.includes('/auth/password') || url.includes('/password');
|
||||||
|
|
||||||
|
// Add Authorization header if token exists and not hitting auth endpoints
|
||||||
|
if (accessToken && requestConfig.headers && !isAuthEndpoint) {
|
||||||
requestConfig.headers.Authorization = `Bearer ${accessToken}`;
|
requestConfig.headers.Authorization = `Bearer ${accessToken}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -170,13 +175,27 @@ client.instance.interceptors.response.use(
|
|||||||
|
|
||||||
// Handle 401 Unauthorized - Token expired
|
// Handle 401 Unauthorized - Token expired
|
||||||
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
if (error.response?.status === 401 && originalRequest && !originalRequest._retry) {
|
||||||
|
const url = originalRequest.url || '';
|
||||||
|
const isAuthEndpoint = url.includes('/auth/login') || url.includes('/auth/register') || url.includes('/auth/password') || url.includes('/password');
|
||||||
|
|
||||||
|
// If the 401 is from auth endpoints, do not attempt refresh
|
||||||
|
if (isAuthEndpoint) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
// If refresh endpoint itself fails with 401, clear auth and reject
|
// If refresh endpoint itself fails with 401, clear auth and reject
|
||||||
if (originalRequest.url?.includes('/auth/refresh')) {
|
if (url.includes('/auth/refresh')) {
|
||||||
const authStore = await getAuthStore();
|
const authStore = await getAuthStore();
|
||||||
await authStore.clearAuth();
|
await authStore.clearAuth();
|
||||||
return Promise.reject(error);
|
return Promise.reject(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Ensure we have a refresh token before attempting refresh
|
||||||
|
const authStore = await getAuthStore();
|
||||||
|
if (!authStore.refreshToken) {
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
|
||||||
originalRequest._retry = true;
|
originalRequest._retry = true;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user