test: add playwright config and e2e tests

This commit is contained in:
2026-05-27 23:28:44 +08:00
parent 13846a0b61
commit 88ec132579
8 changed files with 515 additions and 0 deletions
+38
View File
@@ -0,0 +1,38 @@
import { test, expect } from '@playwright/test';
test.describe('Auth guard', () => {
test('redirects to /login when not authenticated', async ({ page }) => {
// Ensure no auth state
await page.goto('/login');
await page.evaluate(() => localStorage.removeItem('isLoggedIn'));
await page.goto('/welcome');
await expect(page).toHaveURL(/\/login/);
await expect(page.locator('.login-page')).toBeVisible();
});
test('redirects / to /login when not authenticated', async ({ page }) => {
await page.goto('/login');
await page.evaluate(() => localStorage.removeItem('isLoggedIn'));
await page.goto('/');
await expect(page).toHaveURL(/\/login/);
});
test('allows access to /welcome when authenticated', async ({ page }) => {
await page.route('**/api/review/feed', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 200, data: [] }),
}),
);
await page.goto('/login');
await page.evaluate(() => localStorage.setItem('isLoggedIn', 'true'));
await page.goto('/welcome');
await expect(page).toHaveURL(/\/welcome/);
await expect(page.locator('.welcome-page')).toBeVisible();
});
});
+80
View File
@@ -0,0 +1,80 @@
import { test, expect } from '@playwright/test';
const mockLoginSuccess = { code: 200, message: '登录成功' };
const mockLoginFailure = { code: 401, message: '账号或密码错误' };
const mockTasks = { code: 200, data: { records: [] } };
const mockFeed = { code: 200, data: [] };
test.describe('Login page', () => {
test.beforeEach(async ({ page }) => {
await page.goto('/login');
await page.waitForSelector('.login-page', { timeout: 10_000 });
});
test('renders the login form with title and inputs', async ({ page }) => {
await expect(page.locator('h2')).toContainText('账号登录');
await expect(page.locator('.login-form')).toBeVisible();
// Element Plus inputs
const inputs = page.locator('.el-input');
await expect(inputs).toHaveCount(2);
await expect(page.locator('.submit-btn')).toBeVisible();
});
test('shows validation errors when fields are empty', async ({ page }) => {
await page.locator('.submit-btn').click();
// Element Plus validation shows error messages
await expect(page.locator('.el-form-item__error').first()).toBeVisible({ timeout: 5000 });
});
test('shows error on invalid credentials', async ({ page }) => {
await page.route('**/api/login**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockLoginFailure),
}),
);
await page.route('**/api/tasks**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockTasks) }),
);
await page.route('**/api/review/feed**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockFeed) }),
);
// Fill in inputs using Element Plus el-input inner input
const usernameInput = page.locator('.el-form-item').filter({ hasText: '账号' }).locator('input');
const passwordInput = page.locator('.el-form-item').filter({ hasText: '密码' }).locator('input');
await usernameInput.fill('wronguser');
await passwordInput.fill('wrongpass');
await page.locator('.submit-btn').click();
// Element Plus error message appears in a popup
await expect(page.locator('.el-message--error')).toBeVisible({ timeout: 5000 });
});
test('successful login navigates to /welcome', async ({ page }) => {
await page.route('**/api/login**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockLoginSuccess),
}),
);
await page.route('**/api/tasks**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockTasks) }),
);
await page.route('**/api/review/feed**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockFeed) }),
);
const usernameInput = page.locator('.el-form-item').filter({ hasText: '账号' }).locator('input');
const passwordInput = page.locator('.el-form-item').filter({ hasText: '密码' }).locator('input');
await usernameInput.fill('admin');
await passwordInput.fill('password123');
await page.locator('.submit-btn').click();
await expect(page).toHaveURL(/\/welcome/, { timeout: 10_000 });
const isLoggedIn = await page.evaluate(() => localStorage.getItem('isLoggedIn'));
expect(isLoggedIn).toBe('true');
});
});
+64
View File
@@ -0,0 +1,64 @@
import { test, expect } from '@playwright/test';
const mockTasks = { code: 200, data: { records: [] } };
const mockFeed = { code: 200, data: [] };
test.describe('Header navigation', () => {
test('shows brand title on login page', async ({ page }) => {
await page.goto('/login');
await expect(page.locator('.login-page')).toBeVisible({ timeout: 10_000 });
await expect(page.locator('.login-panel h1')).toBeVisible();
});
test('shows logout button on authenticated pages', async ({ page }) => {
await page.route('**/api/review/feed**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockFeed) }),
);
await page.goto('/login');
await page.evaluate(() => localStorage.setItem('isLoggedIn', 'true'));
await page.goto('/welcome');
await page.waitForSelector('.welcome-page', { timeout: 10_000 });
await expect(page.locator('.head .logout-btn')).toBeVisible();
});
test('logout clears auth and redirects to /login', async ({ page }) => {
await page.route('**/api/review/feed**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockFeed) }),
);
await page.goto('/login');
await page.evaluate(() => localStorage.setItem('isLoggedIn', 'true'));
await page.goto('/welcome');
await page.waitForSelector('.welcome-page', { timeout: 10_000 });
await page.locator('.head .logout-btn').click();
await expect(page).toHaveURL(/\/login/, { timeout: 5000 });
const isLoggedIn = await page.evaluate(() => localStorage.getItem('isLoggedIn'));
expect(isLoggedIn).toBeNull();
});
test('back button shows on sub-pages and navigates to /welcome', async ({ page }) => {
await page.route('**/api/tasks**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockTasks) }),
);
await page.route('**/api/review/feed**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockFeed) }),
);
await page.goto('/login');
await page.evaluate(() => localStorage.setItem('isLoggedIn', 'true'));
await page.goto('/study');
await page.waitForSelector('.study-page', { timeout: 10_000 });
await expect(page.locator('.head .back-btn')).toBeVisible();
await page.locator('.head .back-btn').click();
await expect(page).toHaveURL(/\/welcome/);
});
test('no back button on welcome page', async ({ page }) => {
await page.route('**/api/review/feed**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockFeed) }),
);
await page.goto('/login');
await page.evaluate(() => localStorage.setItem('isLoggedIn', 'true'));
await page.goto('/welcome');
await page.waitForSelector('.welcome-page', { timeout: 10_000 });
await expect(page.locator('.head .back-btn')).not.toBeVisible();
});
});
+67
View File
@@ -0,0 +1,67 @@
import { test, expect } from '@playwright/test';
const mockTasksData = {
code: 200,
data: {
records: [
{
taskNum: 'T001',
taskName: '学习 Vue3',
reportCount: 3,
fragmentCount: 5,
effectiveTime: '2h 30m',
},
{
taskNum: 'T002',
taskName: '学习 TypeScript',
reportCount: 1,
fragmentCount: 2,
effectiveTime: '1h 15m',
},
],
},
};
const mockTasksEmpty = { code: 200, data: { records: [] } };
const mockFeed = { code: 200, data: [] };
test.describe('Review page', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/tasks**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTasksData),
}),
);
await page.route('**/api/review/feed**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockFeed) }),
);
await page.goto('/login');
await page.evaluate(() => localStorage.setItem('isLoggedIn', 'true'));
await page.goto('/review');
await page.waitForSelector('.review-page', { timeout: 10_000 });
});
test('displays summary metrics and task table', async ({ page }) => {
await expect(page.locator('.metric-card')).toHaveCount(3);
// Check metric values
await expect(page.locator('.metric-card').first().locator('strong')).toContainText('2');
// Check table rows
await expect(page.locator('.el-table__row')).toHaveCount(2);
});
test('shows empty state when no tasks', async ({ page }) => {
await page.route('**/api/tasks**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTasksEmpty),
}),
);
await page.goto('/review');
await page.waitForSelector('.review-page', { timeout: 10_000 });
await expect(page.locator('.metric-card').first().locator('strong')).toContainText('0');
});
});
+111
View File
@@ -0,0 +1,111 @@
import { test, expect } from '@playwright/test';
const mockTasks = {
code: 200,
data: {
records: [
{
id: 1,
taskNum: 'T001',
taskName: '学习 Vue3 组合式 API',
taskDescription: '掌握 Composition API 的核心概念',
taskPriority: 5,
materialUrl: 'https://vuejs.org/guide',
lastLearningStatus: '进行中',
},
{
id: 2,
taskNum: 'T002',
taskName: 'TypeScript 类型体操',
taskDescription: '练习 TypeScript 高级类型',
taskPriority: 3,
materialUrl: '',
lastLearningStatus: '未开始',
},
],
},
};
const mockTasksEmpty = { code: 200, data: { records: [] } };
const mockFeed = { code: 200, data: [] };
const authAndNavigate = async (page: any) => {
await page.goto('/login');
await page.evaluate(() => localStorage.setItem('isLoggedIn', 'true'));
};
test.describe('Study page', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/tasks**', (route) => {
const url = route.request().url();
if (url.includes('/tasks') && !url.includes('/tasks/')) {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTasks),
});
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 200, data: {} }),
});
});
await page.route('**/api/review/feed**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockFeed) }),
);
await authAndNavigate(page);
await page.goto('/study');
await page.waitForSelector('.study-page', { timeout: 10_000 });
});
test('displays task list with names and priorities', async ({ page }) => {
const taskItems = page.locator('.task-list-item');
await expect(taskItems).toHaveCount(2);
await expect(taskItems.first().locator('.task-name')).toContainText('学习 Vue3 组合式 API');
await expect(taskItems.nth(1).locator('.task-name')).toContainText('TypeScript 类型体操');
});
test('selecting a task shows its details', async ({ page }) => {
const taskItems = page.locator('.task-list-item');
await taskItems.nth(1).click();
await expect(page.locator('.detail-head h3')).toContainText('TypeScript 类型体操');
});
test('clicking add navigates to add-task form', async ({ page }) => {
await page.locator('.action-card').locator('button', { hasText: '添加任务' }).click();
await expect(page).toHaveURL(/\/add-task/);
});
test('clicking update navigates to update-task form', async ({ page }) => {
await page.locator('.action-card').locator('button', { hasText: '更新任务' }).click();
await expect(page).toHaveURL(/\/update-task/);
});
test('delete task removes it from the list', async ({ page }) => {
await page.route('**/api/tasks/1', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 200, message: '删除成功' }),
}),
);
await page.locator('.action-card').locator('button', { hasText: '删除任务' }).click();
await expect(page.locator('.task-list-item')).toHaveCount(1, { timeout: 5000 });
});
test('shows empty state when no tasks', async ({ page }) => {
await page.route('**/api/tasks**', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTasksEmpty),
}),
);
await page.goto('/study');
await page.waitForSelector('.study-page', { timeout: 10_000 });
await expect(page.locator('.el-empty')).toBeVisible({ timeout: 5000 });
});
});
+92
View File
@@ -0,0 +1,92 @@
import { test, expect } from '@playwright/test';
const mockFeed = { code: 200, data: [] };
const loginAndGo = async (page: any, url: string) => {
await page.goto('/login');
await page.evaluate(() => localStorage.setItem('isLoggedIn', 'true'));
await page.goto(url);
await page.waitForSelector('.task-form-page', { timeout: 10_000 });
};
test.describe('Task form', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/review/feed**', (route) =>
route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(mockFeed) }),
);
});
test('add-task form renders empty fields and "添加" button', async ({ page }) => {
await loginAndGo(page, '/add-task');
await expect(page.locator('.el-form-item').filter({ hasText: '任务名称' }).locator('input')).toHaveValue('');
await expect(page.locator('.el-form-item').filter({ hasText: '任务描述' }).locator('textarea')).toHaveValue('');
await expect(page.locator('.action-row button').first()).toContainText('添加');
});
test('submitting add-task sends POST and navigates to /study', async ({ page }) => {
await page.route('**/api/tasks', (route) => {
if (route.request().method() === 'POST') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 200, message: '创建成功' }),
});
}
return route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: 200, data: { records: [] } }) });
});
await loginAndGo(page, '/add-task');
await page.locator('.el-form-item').filter({ hasText: '任务名称' }).locator('input').fill('新学习任务');
await page.locator('.el-form-item').filter({ hasText: '任务描述' }).locator('textarea').fill('这是一个测试任务');
await page.locator('.action-row button').first().click();
await expect(page).toHaveURL(/\/study/, { timeout: 10_000 });
});
test('update-task loads existing data and submits PUT', async ({ page }) => {
const mockTask = {
code: 200,
data: {
id: 1,
taskName: '已有任务',
taskDescription: '已有描述',
materialURL: 'https://example.com',
urgency: 3,
importance: 4,
contentDifficulty: 2,
futureValue: 5,
subjectivePriority: 1,
},
};
await page.route('**/api/tasks/1', (route) => {
if (route.request().method() === 'PUT') {
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 200, message: '更新成功' }),
});
}
return route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(mockTask),
});
});
await loginAndGo(page, '/update-task/1');
await expect(page.locator('.el-form-item').filter({ hasText: '任务名称' }).locator('input')).toHaveValue('已有任务');
await expect(page.locator('.action-row button').first()).toContainText('更新');
await page.locator('.el-form-item').filter({ hasText: '任务名称' }).locator('input').fill('更新后的任务');
await page.locator('.action-row button').first().click();
await expect(page).toHaveURL(/\/study/, { timeout: 10_000 });
});
test('cancel navigates back to /study', async ({ page }) => {
await loginAndGo(page, '/add-task');
await page.locator('.action-row button').nth(1).click();
await expect(page).toHaveURL(/\/study/);
});
});
+34
View File
@@ -0,0 +1,34 @@
import { test, expect } from '@playwright/test';
test.describe('Welcome page', () => {
test.beforeEach(async ({ page }) => {
await page.route('**/api/review/feed', (route) =>
route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ code: 200, data: [] }),
}),
);
await page.goto('/login');
await page.evaluate(() => localStorage.setItem('isLoggedIn', 'true'));
await page.goto('/welcome');
await page.waitForSelector('.welcome-page', { timeout: 10_000 });
});
test('shows welcome banner and quick action cards', async ({ page }) => {
await expect(page.locator('.page-title')).toContainText('欢迎回来');
await expect(page.locator('.page-subtitle')).toBeVisible();
await expect(page.locator('.quick-card')).toHaveCount(2);
});
test('clicking start study navigates to /study', async ({ page }) => {
await page.locator('.quick-card').first().locator('button').click();
await expect(page).toHaveURL(/\/study/);
});
test('clicking start review navigates to /review', async ({ page }) => {
await page.locator('.quick-card').nth(1).locator('button').click();
await expect(page).toHaveURL(/\/review/);
});
});
+29
View File
@@ -0,0 +1,29 @@
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: 1,
reporter: 'list',
use: {
baseURL: 'http://localhost:5158',
trace: 'on-first-retry',
},
projects: [
{
name: 'chromium',
use: {
...devices['Desktop Chrome'],
channel: 'chrome',
},
},
],
webServer: {
command: 'pnpm dev',
url: 'http://localhost:5158',
reuseExistingServer: !process.env.CI,
timeout: 30_000,
},
});