test: add playwright config and e2e tests
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user