diff --git a/e2e/auth-guard.spec.ts b/e2e/auth-guard.spec.ts new file mode 100644 index 0000000..5a559df --- /dev/null +++ b/e2e/auth-guard.spec.ts @@ -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(); + }); +}); diff --git a/e2e/login.spec.ts b/e2e/login.spec.ts new file mode 100644 index 0000000..8914dd9 --- /dev/null +++ b/e2e/login.spec.ts @@ -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'); + }); +}); diff --git a/e2e/navigation.spec.ts b/e2e/navigation.spec.ts new file mode 100644 index 0000000..9bda75e --- /dev/null +++ b/e2e/navigation.spec.ts @@ -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(); + }); +}); diff --git a/e2e/review.spec.ts b/e2e/review.spec.ts new file mode 100644 index 0000000..bf46c5c --- /dev/null +++ b/e2e/review.spec.ts @@ -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'); + }); +}); diff --git a/e2e/study.spec.ts b/e2e/study.spec.ts new file mode 100644 index 0000000..44ed763 --- /dev/null +++ b/e2e/study.spec.ts @@ -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 }); + }); +}); diff --git a/e2e/task-form.spec.ts b/e2e/task-form.spec.ts new file mode 100644 index 0000000..76c2c73 --- /dev/null +++ b/e2e/task-form.spec.ts @@ -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/); + }); +}); diff --git a/e2e/welcome.spec.ts b/e2e/welcome.spec.ts new file mode 100644 index 0000000..abb93e5 --- /dev/null +++ b/e2e/welcome.spec.ts @@ -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/); + }); +}); diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..7024769 --- /dev/null +++ b/playwright.config.ts @@ -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, + }, +});