diff --git a/docs/tests/playwright-agent-plan.md b/docs/tests/playwright-agent-plan.md new file mode 100644 index 0000000..0a54719 --- /dev/null +++ b/docs/tests/playwright-agent-plan.md @@ -0,0 +1,41 @@ +# Playwright 三 Agent 应用计划 + +## 目标 + +用 **Generator → Planner → Healer** 三个 Agent 完整走通一次人才测评的自动化测试。 + +## 阶段设计 + +``` +Phase 1: Generator 录制 + ↓ +Phase 2: Planner 编排(本次核心) + ↓ +Phase 3: Healer 验证 +``` + +## 本次测试内容 + +人才测评系统端到端考核流程: + +1. 登录页面 → 输入账号密码 → 提交 +2. 进入考核页 → 确认两个模板可见 +3. 选择技术人员模板 → 点开始评估 +4. 等题目出现 → 答选择题 +5. 答简答题 → 处理追问 +6. 完成考核 → 查看结果 +7. 管理员登录 → 设置页 → 查看测评模板配置 + +## 工具链 + +| Agent | 命令 | +|-------|------| +| Generator | `npx playwright codegen http://localhost:13001` | +| Planner | `@playwright/test` 框架 + `defineConfig` | +| Healer | `trace: 'on-first-retry'` + `retries: 2` | + +## 输出产物 + +- `playwright.config.ts` — 框架配置 +- `tests/assessment.e2e.spec.ts` — 主测试套件 +- 运行结果 + Trace 文件 diff --git a/playwright.config.ts b/playwright.config.ts new file mode 100644 index 0000000..5c8339a --- /dev/null +++ b/playwright.config.ts @@ -0,0 +1,38 @@ +import { defineConfig, devices } from '@playwright/test'; + +/** + * Playwright 配置 — 三 Agent 集成 + * Planner: 测试结构 + 并行执行 + HTML 报告 + * Healer: 自动重试 + Trace 快照 + * Generator: 通过 codegen 命令配合使用 + */ +export default defineConfig({ + testDir: './tests', + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 1, // ← Healer: 失败自动重试 + workers: process.env.CI ? 1 : 3, // ← Planner: 并行执行 + reporter: [ + ['html', { outputFolder: 'playwright-report' }], // ← Planner: HTML 报告 + ['list'], // ← Planner: 控制台实时输出 + ], + + // ← Healer: 失败时保存 Trace 和截图 + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'on-first-retry', + + timeout: 120000, // 单测超时 2 分钟 + expect: { timeout: 10000 }, + + use: { + baseURL: 'http://localhost:13001', + headless: true, + viewport: { width: 1440, height: 900 }, + ignoreHTTPSErrors: true, + }, + + projects: [ + { name: 'chromium', use: { ...devices['Desktop Chrome'] } }, + ], +}); diff --git a/tests/assessment.e2e.spec.ts b/tests/assessment.e2e.spec.ts new file mode 100644 index 0000000..58aeae5 --- /dev/null +++ b/tests/assessment.e2e.spec.ts @@ -0,0 +1,262 @@ +import { test, expect } from '@playwright/test'; + +/** + * 人才测评系统 — 端到端测试 + * + * Generator → 录制操作 + * Planner → 编排测试结构 (describe + test + expect) + * Healer → 自动重试 + Trace 快照 (通过 playwright.config.ts 配置) + */ + +const ADMIN = { username: 'admin', password: 'admin123' }; + +// ── 辅助函数 ── + +/** 通过 native setter 触发 React onChange */ +async function fillReactInput(page: any, selector: string, text: string) { + await page.waitForSelector(selector, { timeout: 10000 }); + await page.evaluate(({ sel, txt }: { sel: string; txt: string }) => { + const el = document.querySelector(sel); + if (!el) return; + const tag = el.tagName.toLowerCase(); + const setter = Object.getOwnPropertyDescriptor( + tag === 'textarea' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype, + 'value' + )?.set; + setter?.call(el, txt); + el.dispatchEvent(new Event('input', { bubbles: true })); + }, { sel: selector, txt: text }); +} + +/** 等待 spinner 消失 */ +async function waitForIdle(page: any) { + await page.waitForFunction(() => !document.querySelector('.animate-spin'), { timeout: 90000 }).catch(() => {}); + await page.waitForTimeout(1500); +} + +/** 关闭可能存在的确认弹窗 */ +async function dismissModal(page: any) { + await page.evaluate(() => { + document.querySelectorAll('[class*="fixed"][class*="inset-0"]').forEach(el => { + const btn = Array.from(el.querySelectorAll('button')).find(b => + b.textContent?.includes('继续答题') + ); + if (btn) btn.click(); + }); + }); +} + +// ══════════════════════════════════════════ +// 测试套件 +// ══════════════════════════════════════════ + +test.describe('人才测评系统 — 端到端验证', () => { + + // ── 1. 登录 ── + test.describe('1. 登录', () => { + test('admin 登录成功,页面跳转到工作台', async ({ page }) => { + await page.goto('/login'); + await page.waitForTimeout(1000); + await expect(page.locator('input[type="text"]').first()).toBeVisible(); + + await page.locator('input[type="text"]').first().fill(ADMIN.username); + await page.locator('input[type="password"]').first().fill(ADMIN.password); + await page.locator('button[type="submit"]').click(); + + // 登录成功后 URL 不再是 /login + await page.waitForURL('**/'); + expect(page.url()).not.toContain('/login'); + }); + + test('错误密码显示错误提示', async ({ page }) => { + await page.goto('/login'); + await page.waitForTimeout(500); + await page.locator('input[type="text"]').first().fill('admin'); + await page.locator('input[type="password"]').first().fill('wrongpass'); + await page.locator('button[type="submit"]').click(); + await page.waitForTimeout(2000); + + // 应该还停留在登录页且有错误提示 + expect(page.url()).toContain('/login'); + }); + }); + + // ── 2. 考核模板 ── + test.describe('2. 考核模板', () => { + test.beforeEach(async ({ page }) => { + await page.goto('/login'); + await page.waitForTimeout(500); + await page.locator('input[type="text"]').first().fill(ADMIN.username); + await page.locator('input[type="password"]').first().fill(ADMIN.password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/'); + }); + + test('两个考核模板均可见', async ({ page }) => { + await page.goto('/assessment'); + await page.waitForTimeout(3000); + + const techTpl = page.locator('button:has-text("AI协作技巧-对话测评")'); + const nonTechTpl = page.locator('button:has-text("AI协作-非技术人员测评")'); + + await expect(techTpl.first()).toBeVisible({ timeout: 10000 }); + await expect(nonTechTpl.first()).toBeVisible({ timeout: 10000 }); + }); + + test('选择模板后显示开始评估按钮', async ({ page }) => { + await page.goto('/assessment'); + await page.waitForTimeout(3000); + + await page.locator('button:has-text("AI协作技巧-对话测评")').first().click(); + await page.waitForTimeout(500); + + const startBtn = page.locator('button:has-text("开始专业评估")'); + await expect(startBtn).toBeVisible({ timeout: 5000 }); + }); + }); + + // ── 3. 选择题答题 ── + test.describe('3. 选择题答题', () => { + test('点击选择后确认答案按钮可用', async ({ page }) => { + // 登录 + await page.goto('/login'); + await page.waitForTimeout(500); + await page.locator('input[type="text"]').first().fill(ADMIN.username); + await page.locator('input[type="password"]').first().fill(ADMIN.password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/'); + + // 开始考核 + await page.goto('/assessment'); + await page.waitForTimeout(2000); + await page.locator('button:has-text("AI协作技巧-对话测评")').first().click(); + await page.waitForTimeout(500); + await page.locator('button:has-text("开始专业评估")').first().click(); + + // 等题目 + for (let i = 0; i < 60; i++) { + const text = await page.textContent('body').catch(() => ''); + if (text.includes('问题 ') || text.includes('Question ')) break; + await new Promise(r => setTimeout(r, 2000)); + } + await waitForIdle(page); + + // 检查是否是选择题 + const hasChoice = await page.evaluate(() => { + return document.querySelectorAll('button.w-full.text-left.px-5.py-4').length > 0; + }); + + if (hasChoice) { + await dismissModal(page); + + // 用 evaluate 点击选项 + await page.evaluate(() => { + const opts = Array.from(document.querySelectorAll('button.w-full.text-left.px-5.py-4')) + .filter(b => /^[A-D]/.test(b.textContent || '')); + if (opts.length > 0) (opts[0] as HTMLButtonElement).click(); + }); + await page.waitForTimeout(500); + + const confirmBtn = page.locator('button:has-text("确认答案")'); + await expect(confirmBtn).toBeEnabled({ timeout: 5000 }); + } else { + // 如果是简答题,测试 textarea 可见 + const ta = page.locator('textarea'); + await expect(ta.first()).toBeVisible({ timeout: 5000 }); + } + }); + }); + + // ── 4. 简答题输入 ── + test.describe('4. 简答题输入', () => { + test('简答题 textarea 可输入并发送', async ({ page }) => { + // 登录 + 开始考核 + await page.goto('/login'); + await page.waitForTimeout(500); + await page.locator('input[type="text"]').first().fill(ADMIN.username); + await page.locator('input[type="password"]').first().fill(ADMIN.password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/'); + + await page.goto('/assessment'); + await page.waitForTimeout(2000); + await page.locator('button:has-text("AI协作技巧-对话测评")').first().click(); + await page.waitForTimeout(500); + await page.locator('button:has-text("开始专业评估")').first().click(); + + for (let i = 0; i < 60; i++) { + const text = await page.textContent('body').catch(() => ''); + if (text.includes('问题 ') || text.includes('Question ')) break; + await new Promise(r => setTimeout(r, 2000)); + } + await waitForIdle(page); + + await dismissModal(page); + + // 检查如果是简答题 + const hasSA = await page.evaluate(() => { + const ta = document.querySelector('textarea'); + return ta !== null && ta.offsetParent !== null; + }); + + if (hasSA) { + await fillReactInput(page, 'textarea', '端到端测试回答 — 验证Playwright Planner编排功能'); + await page.waitForTimeout(500); + + // 发送按钮应可用 + const sendBtn = page.locator('button:has(svg.lucide-send)'); + await expect(sendBtn.last()).toBeEnabled({ timeout: 5000 }); + } else { + // 选择题——选一个 + await page.evaluate(() => { + const opts = Array.from(document.querySelectorAll('button.w-full.text-left.px-5.py-4')) + .filter(b => /^[A-D]/.test(b.textContent || '')); + if (opts.length > 0) (opts[0] as HTMLButtonElement).click(); + }); + await page.waitForTimeout(500); + const confirmBtn = page.locator('button:has-text("确认答案")'); + await expect(confirmBtn).toBeEnabled({ timeout: 5000 }); + } + }); + }); + + // ── 5. 设置页验证 ── + test.describe('5. 设置页 — 测评模板', () => { + test('设置页有测评模板 Tab', async ({ page }) => { + await page.goto('/login'); + await page.waitForTimeout(500); + await page.locator('input[type="text"]').first().fill(ADMIN.username); + await page.locator('input[type="password"]').first().fill(ADMIN.password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/'); + + await page.goto('/settings'); + await page.waitForTimeout(3000); + + const templateTab = page.locator('button:has-text("测评模板")'); + await expect(templateTab).toBeVisible({ timeout: 5000 }); + }); + + test('测评模板 Tab 可点击', async ({ page }) => { + await page.goto('/login'); + await page.waitForTimeout(500); + await page.locator('input[type="text"]').first().fill(ADMIN.username); + await page.locator('input[type="password"]').first().fill(ADMIN.password); + await page.locator('button[type="submit"]').click(); + await page.waitForURL('**/'); + + await page.goto('/settings'); + await page.waitForTimeout(3000); + + const templateTab = page.locator('button:has-text("测评模板")'); + if (await templateTab.isVisible().catch(() => false)) { + await templateTab.click(); + await page.waitForTimeout(2000); + + // 点开模板列表应该能看到模板 + const tplList = page.locator('text=AI协作技巧-对话测评'); + await expect(tplList.first()).toBeVisible({ timeout: 5000 }); + } + }); + }); +});