feat: integrate Playwright for E2E testing and enhance testing framework

- Added Playwright for end-to-end testing in the `apps/web` module, including a new `login.spec.ts` for testing login functionality.
- Updated package.json to include Playwright dependencies and new test commands for E2E testing.
- Enhanced .gitignore to exclude Playwright test results and reports.
- Modified existing documentation to reflect the integration of Playwright and updated testing guidelines.
- Refactored test commands to streamline the testing process, including a dedicated command for E2E tests.

These changes improve the testing framework by providing robust E2E testing capabilities, enhancing the reliability and quality of the application.
This commit is contained in:
shancheas
2026-08-31 11:52:27 +07:00
parent 166e0d40ac
commit 67e1e0a74f
23 changed files with 318 additions and 108 deletions
+4
View File
@@ -0,0 +1,4 @@
export const E2E_LOGIN = {
username: 'administrator',
password: 'password123',
} as const;
+11
View File
@@ -0,0 +1,11 @@
export function createFakeJwt(expiresInSeconds = 3600): string {
const header = Buffer.from(JSON.stringify({ alg: 'none', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(
JSON.stringify({
sub: 'e2e-user',
exp: Math.floor(Date.now() / 1000) + expiresInSeconds,
}),
).toString('base64url');
return `${header}.${payload}.e2e`;
}
+91
View File
@@ -0,0 +1,91 @@
import type { Page, Route } from '@playwright/test';
import { E2E_LOGIN } from './credentials';
import { createFakeJwt } from './fake-jwt';
const APP_HOSTS = new Set(['127.0.0.1', 'localhost']);
const APP_PORT = Number(process.env.E2E_PORT ?? 4173);
const EMPTY_LIST = {
data: [],
meta: {
currentPage: 1,
itemsPerPage: 10,
totalItems: 0,
totalPages: 0,
itemCount: 0,
},
};
const E2E_USER = {
id: 'e2e-user-id',
username: E2E_LOGIN.username,
isSuperadmin: true,
privilege: null,
permissions: {},
};
export type LoginApiOutcome = 'success' | 'unauthorized';
export async function mockBackend(page: Page, login: LoginApiOutcome = 'success'): Promise<void> {
await page.route('**/*', async (route) => {
const requestUrl = new URL(route.request().url());
if (isAppRequest(requestUrl)) {
await route.continue();
return;
}
await fulfillBackend(route, login);
});
}
function isAppRequest(requestUrl: URL): boolean {
const port = Number(requestUrl.port || (requestUrl.protocol === 'https:' ? 443 : 80));
return APP_HOSTS.has(requestUrl.hostname) && port === APP_PORT;
}
async function fulfillBackend(route: Route, login: LoginApiOutcome): Promise<void> {
const request = route.request();
const pathname = new URL(request.url()).pathname.replace(/\/$/, '');
if (pathname.endsWith('/auth/login') && request.method() === 'POST') {
if (login === 'unauthorized') {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ statusCode: 401, message: 'Unauthorized' }),
});
return;
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
accessToken: createFakeJwt(),
refreshToken: createFakeJwt(86_400),
}),
});
return;
}
if (pathname.endsWith('/auth/me') && request.method() === 'GET') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(E2E_USER),
});
return;
}
const resourceType = request.resourceType();
if (resourceType === 'xhr' || resourceType === 'fetch') {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(EMPTY_LIST),
});
return;
}
await route.abort();
}
+40
View File
@@ -0,0 +1,40 @@
import { expect, test } from '@playwright/test';
import { E2E_LOGIN } from './fixtures/credentials';
import { mockBackend } from './fixtures/mock-api';
test.describe('login', () => {
test('renders the login form', async ({ page }) => {
await mockBackend(page);
await page.goto('/auth/login');
await expect(page.getByRole('heading', { name: 'Login to your account!' })).toBeVisible();
await expect(page.getByLabel('Username')).toBeVisible();
await expect(page.getByLabel('Password')).toBeVisible();
await expect(page.getByRole('button', { name: 'Login' })).toBeVisible();
});
test('signs in with valid credentials and lands in the app', async ({ page }) => {
await mockBackend(page, 'success');
await page.goto('/auth/login');
await page.getByLabel('Username').fill(E2E_LOGIN.username);
await page.getByLabel('Password').fill(E2E_LOGIN.password);
await page.getByRole('button', { name: 'Login' }).click();
await expect(page).toHaveURL(/\/app(\/|$)/);
await expect(page.getByRole('heading', { name: 'Full Page' })).toBeVisible();
});
test('shows an error and stays on login when credentials are rejected', async ({ page }) => {
await mockBackend(page, 'unauthorized');
await page.goto('/auth/login');
await page.getByLabel('Username').fill(E2E_LOGIN.username);
await page.getByLabel('Password').fill(E2E_LOGIN.password);
await page.getByRole('button', { name: 'Login' }).click();
await expect(page.getByText('Invalid username or password')).toBeVisible();
await expect(page).toHaveURL(/\/auth\/login/);
await expect(page.getByRole('heading', { name: 'Login to your account!' })).toBeVisible();
});
});
+5 -1
View File
@@ -8,9 +8,12 @@
"dev": "pnpm run copy:brand && vite --clearScreen false",
"build": "pnpm run copy:brand && tsc && vite build",
"preview": "vite preview",
"lint": "eslint \"src/**/*.ts\"",
"lint": "eslint \"src/**/*.ts\" \"e2e/**/*.ts\" \"playwright.config.ts\"",
"test": "vitest run",
"test:watch": "vitest --watch",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui",
"test:e2e:install": "playwright install chromium",
"typecheck": "tsc --noEmit"
},
"dependencies": {
@@ -37,6 +40,7 @@
"zustand": "^5.0.14"
},
"devDependencies": {
"@playwright/test": "^1.55.0",
"@repo/eslint-config": "workspace:*",
"@repo/typescript-config": "workspace:*",
"@types/react": "^19.2.7",
+25
View File
@@ -0,0 +1,25 @@
import { defineConfig, devices } from '@playwright/test';
const E2E_PORT = Number(process.env.E2E_PORT ?? 4173);
const E2E_ORIGIN = `http://127.0.0.1:${E2E_PORT}`;
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
reporter: 'list',
timeout: 60_000,
use: {
baseURL: E2E_ORIGIN,
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
webServer: {
command: `pnpm run copy:brand && vite --clearScreen false --host 127.0.0.1 --port ${E2E_PORT} --strictPort`,
url: E2E_ORIGIN,
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
});
+1
View File
@@ -21,5 +21,6 @@ export default defineConfig({
test: {
environment: 'node',
globals: false,
exclude: ['**/node_modules/**', '**/dist/**', '**/e2e/**'],
},
});