初始提交:ai-review 项目当前版本(含赛道一/二提交规范修订与时间节点文档)

This commit is contained in:
hangshuo652
2026-08-23 11:52:45 +08:00
commit 4da7044c4b
152 changed files with 33490 additions and 0 deletions
+794
View File
@@ -0,0 +1,794 @@
import { test, expect, type Page, type APIRequestContext } from '@playwright/test';
const PASSWORD = 'test123';
const UNIQUE = Date.now().toString(36);
const BASE = 'http://localhost:3002/api';
let apiCtx: APIRequestContext;
// 赛道必选后项目创建自动带入赛道默认标准;若测试只用自定义标准,需先清空自动标准,
// 让条目创建时 resolveStandard 回退到用户上传的标准(category_tag=''
async function apiClearStandards(request: APIRequestContext, token: string, pid: string): Promise<void> {
const list = await request.fetch(`${BASE}/projects/${pid}/standards`, { headers: { Authorization: `Bearer ${token}` } });
const standards = await list.json();
for (const s of standards) {
await request.fetch(`${BASE}/projects/${pid}/standards/${s.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
}
}
// ─── Helper: login via UI, then create API context ───
async function login(page: Page) {
await page.goto('/login');
await page.fill('input[type="password"]', PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL('/');
}
// ─── Helper: api call via page.request ───
async function api(page: Page, method: string, path: string, body?: any) {
const res = await page.request.fetch(`http://localhost:3002${path}`, {
method,
headers: { 'Content-Type': 'application/json' },
data: body,
});
return { status: res.status(), data: await res.json() };
}
// ══════════════════════════════════════
// 1. 认证测试
// ══════════════════════════════════════
test.describe('认证', () => {
test('1.1 未登录跳转到登录页', async ({ page }) => {
await page.goto('/');
await page.waitForURL('/login');
expect(page.url()).toContain('/login');
});
test('1.2 空密码按钮禁用', async ({ page }) => {
await page.goto('/login');
await expect(page.locator('button[type="submit"]')).toBeDisabled();
});
test('1.3 错误密码显示错误', async ({ page }) => {
await page.goto('/login');
await page.fill('input[type="password"]', 'wrongpassword');
await page.click('button[type="submit"]');
await expect(page.locator('.error')).toContainText('密码错误');
});
test('1.4 正确密码登录成功', async ({ page }) => {
await login(page);
await expect(page.locator('.sidebar-header h2')).toHaveText('AI-Review');
});
test('1.5 token 持久化(刷新后仍登录)', async ({ page }) => {
await login(page);
await page.reload();
await expect(page.locator('.sidebar-header h2')).toHaveText('AI-Review');
});
test('1.6 退出登录', async ({ page }) => {
await login(page);
await page.click('.sidebar-header button:has-text("退出")');
await page.waitForURL('/login');
});
});
// ══════════════════════════════════════
// 2. 侧边栏 & 项目
// ══════════════════════════════════════
test.describe('侧边栏 & 项目', () => {
test.beforeEach(async ({ page }) => { await login(page); });
test('2.1 空项目列表显示"新建项目"按钮', async ({ page }) => {
await expect(page.locator('.btn-new-project')).toHaveText('+ 新建项目');
});
test('2.2 取消创建项目', async ({ page }) => {
await page.click('.btn-new-project');
await expect(page.locator('.new-project-form input')).toBeVisible();
await page.click('.new-project-actions button:has-text("取消")');
await expect(page.locator('.btn-new-project')).toBeVisible();
});
test('2.3 创建项目', async ({ page }) => {
await page.click('.btn-new-project');
await page.fill('.new-project-form input', `测试项目-${UNIQUE}`);
await page.selectOption('.new-project-form select', '赛道一');
await page.click('.new-project-actions button:first-child');
await page.waitForURL(/\/project\//);
await expect(page.locator('.project-view h2')).toContainText(`测试项目-${UNIQUE}`);
});
test('2.4 项目统计显示', async ({ page }) => {
await page.goto('/');
const first = page.locator('.project-stats').first();
await expect(first).toBeVisible();
});
test('2.5 项目激活高亮', async ({ page }) => {
await page.click('.project-item:first-child');
await expect(page.locator('.project-item.active')).toBeVisible();
});
test('2.6 仪表盘显示', async ({ page }) => {
await page.goto('/');
await expect(page.locator('.dashboard h1')).toContainText('仪表盘');
});
});
// ══════════════════════════════════════
// 3. 评审标准
// ══════════════════════════════════════
test.describe('评审标准', () => {
let pid = '';
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: `标准测试-${UNIQUE}`, track: '赛道一' },
});
expect(pr.status()).toBe(200);
pid = (await pr.json()).id;
await apiClearStandards(request, token, pid);
});
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("标准")');
});
test('3.1 空标准显示提示', async ({ page }) => {
await expect(page.locator('.empty')).toContainText('暂无评审标准');
});
test('3.2 上传标准 - 显示表单', async ({ page }) => {
await page.click('.section-header button:has-text("上传标准")');
await expect(page.locator('.standard-form')).toBeVisible();
});
test('3.3 上传标准 - 无效格式', async ({ page }) => {
await page.click('.section-header button:has-text("上传标准")');
await page.fill('.standard-form input:first-child', '无效标准');
await page.fill('.standard-form textarea', '没有格式的普通文本');
await page.click('.form-actions button:has-text("保存")');
await expect(page.locator('.standard-form')).toBeVisible();
});
test('3.4 上传标准 - 总分超过上限(150)被拒', async ({ page }) => {
await page.click('.section-header button:has-text("上传标准")');
await page.fill('.standard-form input:first-child', '超分标准');
await page.fill('.standard-form textarea', '## 维度一(100分)\n内容\n## 维度二(60分)\n内容');
await page.click('.form-actions button:has-text("保存")');
// 被拒绝 → 表单保持打开
await expect(page.locator('.standard-form')).toBeVisible();
});
test('3.5 上传标准 - 正常创建(含分类标签)', async ({ page }) => {
await page.click('.section-header button:has-text("上传标准")');
await page.fill('.standard-form input:first-child', '算法标准');
await page.fill('.standard-form input:nth-child(2)', '算法');
await page.fill('.standard-form textarea',
'## 代码质量(30分)\n代码整洁度\n## 架构设计(40分)\n模块化\n## 测试覆盖(30分)\n覆盖率');
await page.click('.form-actions button:has-text("保存")');
await expect(page.locator('.standard-card')).toHaveCount(1);
await expect(page.locator('.standard-card')).toContainText('算法标准');
await expect(page.locator('.standard-card')).toContainText('算法');
await expect(page.locator('.standard-card')).toContainText('代码质量(30分)');
await expect(page.locator('.standard-card')).toContainText('架构设计(40分)');
});
test('3.6 标准数量显示', async ({ page }) => {
await expect(page.locator('.section-header h3')).toContainText('评审标准 (1)');
});
test('3.7 删除标准', async ({ page }) => {
page.on('dialog', d => d.accept());
await page.click('.btn-danger');
await expect(page.locator('.empty')).toContainText('暂无评审标准');
});
});
// ══════════════════════════════════════
// 4. 条目管理
// ══════════════════════════════════════
test.describe('条目管理', () => {
let pid = '';
const entryTitle = `选手A-${UNIQUE}`;
const repoUrl = `https://example.com/${UNIQUE}.git`;
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST', headers: auth, data: { name: `条目测试-${UNIQUE}`, track: '赛道一' },
});
pid = (await pr.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
method: 'POST', headers: auth,
data: { name: '默认标准', content: '## 代码质量(50分)\n整洁\n## 架构设计(50分)\n设计' },
});
});
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
});
test('4.1 空条目显示提示', async ({ page }) => {
await expect(page.locator('.empty-row')).toContainText('暂无条目');
});
test('4.2 筛选器存在', async ({ page }) => {
await expect(page.locator('.filter-select')).toBeVisible();
await expect(page.locator('.search-input')).toBeVisible();
});
test('4.3 全部筛选选项', async ({ page }) => {
const opts = await page.locator('.filter-select option').allTextContents();
expect(opts).toContain('全部');
expect(opts).toContain('待评审');
expect(opts).toContain('排队中');
expect(opts).toContain('克隆中');
expect(opts).toContain('分析中');
expect(opts).toContain('已完成');
expect(opts).toContain('已修正');
expect(opts).toContain('克隆失败');
expect(opts).toContain('分析失败');
expect(opts).toContain('失败');
});
test('4.4 通过API创建条目 → 页面可见', async ({ page, request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: entryTitle, repo_url: repoUrl, participant: '张三', difficulty: '★★★' },
});
await page.reload();
await expect(page.locator('.entry-table')).toContainText(entryTitle);
});
test('4.5 条目列表显示字段', async ({ page }) => {
await expect(page.locator('.entry-table')).toContainText('张三');
await expect(page.locator('.entry-table')).toContainText('待评审');
await expect(page.locator('.entry-table')).toContainText('-');
});
test('4.6 "启动"按钮存在', async ({ page }) => {
await expect(page.locator('.btn-action:has-text("启动")')).toBeVisible();
});
test('4.7 启动条目', async ({ page }) => {
await page.click('.entry-table .btn-action:has-text("启动")');
await expect(page.locator('.entry-table .badge').first()).toBeVisible();
});
test('4.8 取消评审', async ({ page, request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
method: 'POST', headers: auth,
data: { title: `取消测试-${UNIQUE}`, repo_url: `https://cancel-${UNIQUE}.git` },
});
const eid = (await er.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries/${eid}/start`, {
method: 'POST', headers: auth,
});
await page.reload();
await expect(page.locator('.entry-table')).toContainText('取消测试');
await expect(page.locator('.entry-table .badge').first()).toBeVisible();
});
test('4.9 搜索功能', async ({ page }) => {
await page.fill('.search-input', '选手A');
await page.waitForTimeout(500);
await page.press('.search-input', 'Enter');
await expect(page.locator('.entry-table')).toContainText('选手A', { timeout: 10000 });
});
test('4.10 状态筛选', async ({ page }) => {
await page.selectOption('.filter-select', 'pending');
await page.waitForTimeout(500);
const badges = await page.locator('.entry-table .badge').allTextContents();
const statusTexts = badges.filter(b => !['赛道一', '新規', '修正'].includes(b.trim()));
for (const b of statusTexts) {
expect(b.trim()).toBe('待评审');
}
});
});
// ══════════════════════════════════════
// 5. 批量导入
// ══════════════════════════════════════
test.describe('批量导入', () => {
let pid = '';
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST', headers: auth, data: { name: `导入测试-${UNIQUE}`, track: '赛道一' },
});
pid = (await pr.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
method: 'POST', headers: auth,
data: { name: '默认标准', content: '## 代码质量(50分)\n整洁\n## 架构设计(50分)\n设计' },
});
});
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
});
test('5.1 打开导入面板', async ({ page }) => {
await page.click('.btn-secondary:has-text("导入")');
await expect(page.locator('.import-panel')).toBeVisible();
});
test('5.2 批量导入 CSV 格式', async ({ page }) => {
await page.click('.btn-secondary:has-text("导入")');
const csv = `title,repo_url,participant,difficulty
小明作品,https://xiaoming-${UNIQUE}.git,小明,★★
小红作品,https://xiaohong-${UNIQUE}.git,小红,★★★`;
await page.fill('.import-panel textarea', csv);
await page.click('.import-panel .form-actions button:first-child');
await expect(page.locator('.import-result')).toContainText('成功 2 条');
});
test('5.3 导入结果显示错误行', async ({ page }) => {
await page.click('.btn-secondary:has-text("导入")');
const csv = `title,repo_url
,https://empty-title.git
有效,https://valid-${UNIQUE}.git`;
await page.fill('.import-panel textarea', csv);
await page.click('.import-panel .form-actions button:first-child');
await expect(page.locator('.import-result')).toContainText('成功 1 条');
await expect(page.locator('.import-result')).toContainText('失败 1 条');
await expect(page.locator('.error-row')).toContainText('标题为空');
});
test('5.4 表格显示导入的条目', async ({ page }) => {
await page.waitForTimeout(500);
await expect(page.locator('.section-header h3')).not.toContainText('(0)');
});
});
// ══════════════════════════════════════
// 6. 详情面板
// ══════════════════════════════════════
test.describe('详情面板', () => {
let pid = '';
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST', headers: auth, data: { name: `详情测试-${UNIQUE}`, track: '赛道一' },
});
pid = (await pr.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
method: 'POST', headers: auth,
data: { name: '默认标准', content: '## 代码质量(50分)\n整洁\n## 架构设计(50分)\n设计' },
});
// Create an entry and mark it as review_done via report
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
method: 'POST', headers: auth,
data: { title: `详情条目-${UNIQUE}`, repo_url: `https://detail-${UNIQUE}.git` },
});
const eid = (await er.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries/${eid}/force-review`, {
method: 'PUT', headers: auth,
data: { dimensions: [
{ name: '代码质量', score: 40, maxScore: 50, comment: '代码整洁' },
{ name: '架构设计', score: 35, maxScore: 50, comment: '合理' },
]},
});
});
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
});
test('6.1 点击标题打开详情', async ({ page }) => {
await page.click('.title-cell');
await expect(page.locator('.detail-panel')).toBeVisible();
await expect(page.locator('.detail-panel h3')).toContainText(`详情条目-${UNIQUE}`);
});
test('6.2 详情面板显示元信息', async ({ page }) => {
await page.click('.title-cell');
await expect(page.locator('.detail-meta')).toContainText(`https://detail-${UNIQUE}.git`);
});
test('6.3 详情面板显示维度', async ({ page }) => {
await page.click('.title-cell');
await expect(page.locator('.detail-dims')).toBeVisible();
const rows = await page.locator('.detail-dims tbody tr').count();
expect(rows).toBe(2);
});
test('6.4 评分可编辑', async ({ page }) => {
await page.click('.title-cell');
const input = page.locator('.score-input').first();
await input.fill('45');
await expect(input).toHaveValue('45');
});
test('6.5 评语可编辑', async ({ page }) => {
await page.click('.title-cell');
// 评语为点击后进入编辑态(textarea.comment-input
await page.locator('.suggestion-cell').first().click();
const comment = page.locator('.comment-input').first();
await comment.fill('修改后评语');
await expect(comment).toHaveValue('修改后评语');
});
test('6.6 保存修正按钮存在', async ({ page }) => {
await page.click('.title-cell');
await expect(page.locator('.btn-primary:has-text("保存修正")')).toBeVisible();
});
test('6.7 关闭详情面板', async ({ page }) => {
await page.click('.title-cell');
await expect(page.locator('.detail-panel')).toBeVisible();
await page.click('.btn-close');
await expect(page.locator('.detail-panel')).not.toBeVisible();
});
test('6.8 点击遮罩关闭详情', async ({ page }) => {
await page.click('.title-cell');
await page.waitForTimeout(300);
await page.click('.detail-overlay', { position: { x: 10, y: 10 } });
await expect(page.locator('.detail-panel')).not.toBeVisible();
});
});
// ══════════════════════════════════════
// 7. 汇总视图
// ══════════════════════════════════════
test.describe('汇总视图', () => {
let pid = '';
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST', headers: auth, data: { name: `汇总测试-${UNIQUE}`, track: '赛道一' },
});
pid = (await pr.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
method: 'POST', headers: auth,
data: { name: '标准1', category_tag: '赛道一',
content: '## 代码质量(50分)\n整洁\n## 架构设计(50分)\n设计' },
});
for (let i = 0; i < 3; i++) {
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
method: 'POST', headers: auth,
data: { title: `选手${i}-${UNIQUE}`, repo_url: `https://p${i}-${UNIQUE}.git`,
participant: `参赛者${i}`, category_tag: '赛道一', difficulty: i === 0 ? '★★' : '★★★' },
});
const eid = (await er.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries/${eid}/force-review`, {
method: 'PUT', headers: auth,
data: { dimensions: [
{ name: '代码质量', score: 40 + i * 5, maxScore: 50, comment: 'ok' },
{ name: '架构设计', score: 30 + i * 5, maxScore: 50, comment: 'ok' },
]},
});
}
});
test.beforeEach(async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("汇总")');
});
test('7.1 汇总页面显示标题', async ({ page }) => {
await expect(page.locator('h3')).toContainText('汇总排名');
});
test('7.2 按分类显示排名', async ({ page }) => {
await expect(page.locator('.summary-section')).toBeVisible();
await expect(page.locator('.summary-section h4')).toContainText('赛道一');
});
test('7.3 排名表格中显示选手', async ({ page }) => {
await expect(page.locator('.summary-section')).toContainText('参赛者0');
await expect(page.locator('.summary-section')).toContainText('参赛者1');
await expect(page.locator('.summary-section')).toContainText('参赛者2');
});
test('7.4 排名按分数降序', async ({ page }) => {
const scores = await page.locator('.summary-section .entry-table td:nth-child(4)').allTextContents();
const nums = scores.map(s => parseInt(s));
for (let i = 1; i < nums.length; i++) {
expect(nums[i]).toBeLessThanOrEqual(nums[i - 1]);
}
});
test('7.5 排名序号 #1 #2 #3', async ({ page }) => {
const ranks = await page.locator('.rank').allTextContents();
expect(ranks).toEqual(['#1', '#2', '#3']);
});
test('7.6 参赛者合格判定区域', async ({ page }) => {
await expect(page.locator('h4:has-text("参赛者合格判定")')).toBeVisible();
});
test('7.7 无参赛者时显示空状态', async ({ page, request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: `空汇总-${UNIQUE}`, track: '赛道一' },
});
const emptyPid = (await pr.json()).id;
await login(page);
await page.goto(`/project/${emptyPid}`);
await page.click('.tab:has-text("汇总")');
await expect(page.locator('h3')).toContainText('汇总排名');
await expect(page.locator('.summary-section')).toHaveCount(0);
});
});
// ══════════════════════════════════════
// 8. 异常与临界值测试
// ══════════════════════════════════════
test.describe('异常与临界值', () => {
let pid = '';
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST', headers: auth, data: { name: `异常测试-${UNIQUE}`, track: '赛道一' },
});
pid = (await pr.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/standards`, {
method: 'POST', headers: auth,
data: { name: '标准', content: '## 代码质量(100分)\n质量' },
});
});
test('8.1 导航到不存在的项目', async ({ page }) => {
await login(page);
// navigating to non-existent project should show error or fallback
await page.goto('/project/non-existent-id');
// Should show something that indicates error or at least not crash
const body = await page.locator('.project-view').textContent();
expect(body).toBeTruthy();
});
test('8.2 空标题创建条目被阻止(通过API验证)', async ({ page, request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { repo_url: 'https://no-title.git' },
});
expect(er.status()).toBe(400);
});
test('8.3 删除标准前确认弹窗', async ({ page, request }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("标准")');
// Create a standard to delete
await page.click('.section-header button:has-text("上传标准")');
await page.fill('.standard-form input:first-child', '待删除标准');
await page.fill('.standard-form textarea', '## 维度(100分)\n内容');
await page.click('.form-actions button:has-text("保存")');
await page.waitForTimeout(300);
let dialogSeen = false;
page.on('dialog', d => { dialogSeen = true; d.accept(); });
await page.locator('.btn-danger').first().click();
await page.waitForTimeout(300);
expect(dialogSeen).toBe(true);
});
test('8.4 导入空CSV', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
await page.click('.btn-secondary:has-text("导入")');
await page.fill('.import-panel textarea', '');
await page.click('.import-panel .form-actions button:first-child');
await expect(page.locator('.import-panel')).toBeVisible();
});
test('8.5 同一个条目无法两次启动', async ({ page, request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const auth = { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
const er = await request.fetch(`http://localhost:3002/api/projects/${pid}/entries`, {
method: 'POST', headers: auth,
data: { title: `双启动-${UNIQUE}`, repo_url: `https://dual-${UNIQUE}.git` },
});
const eid = (await er.json()).id;
await request.fetch(`http://localhost:3002/api/projects/${pid}/entries/${eid}/start`, {
method: 'POST', headers: auth,
});
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
await page.reload();
await expect(page.locator('.btn-action').first()).toBeVisible();
});
});
// ══════════════════════════════════════
// 9. UI 一致性
// ══════════════════════════════════════
test.describe('UI 一致性', () => {
let pid = '';
test.beforeAll(async ({ request }) => {
const r = await request.post('http://localhost:3002/api/auth/login', {
data: { password: PASSWORD },
});
const { token } = await r.json();
const pr = await request.fetch('http://localhost:3002/api/projects', {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: `UI测试-${UNIQUE}`, track: '赛道一' },
});
pid = (await pr.json()).id;
});
test('9.1 四个Tab都存在', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await expect(page.locator('.tab')).toHaveText(['标准', '条目', '成果物', '汇总']);
});
test('9.2 Tab切换工作', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("标准")');
await expect(page.locator('.tab.active')).toContainText('标准');
await page.click('.tab:has-text("条目")');
await expect(page.locator('.tab.active')).toContainText('条目');
await page.click('.tab:has-text("汇总")');
await expect(page.locator('.tab.active')).toContainText('汇总');
});
test('9.3 项目统计显示所有项', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
const meta = await page.locator('.project-meta').textContent();
expect(meta).toContain('总计');
expect(meta).toContain('✓');
expect(meta).toContain('▶');
expect(meta).toContain('✕');
});
});
// ══════════════════════════════════════
// 10. 完整用户流程
// ══════════════════════════════════════
test.describe('完整用户流程', () => {
test('完整流程:创建项目→标准→条目→查看汇总', async ({ page }) => {
await login(page);
// Step 1: Create project
await page.click('.btn-new-project');
await page.fill('.new-project-form input', `全流程-${UNIQUE}`);
await page.selectOption('.new-project-form select', '赛道一');
await page.click('.new-project-actions button:first-child');
await page.waitForURL(/\/project\//);
// 清掉赛道自动标准,保证下方断言 `.standard-card` 唯一(上传的「全流程标准」)
const pid10 = page.url().split('/project/')[1];
const loginR = await page.request.post('http://localhost:3002/api/auth/login', { data: { password: PASSWORD } });
const { token: tok10 } = await loginR.json();
const listS = await page.request.get(`http://localhost:3002/api/projects/${pid10}/standards`, { headers: { Authorization: `Bearer ${tok10}` } });
const stds10 = await listS.json();
for (const s of stds10) {
await page.request.delete(`http://localhost:3002/api/projects/${pid10}/standards/${s.id}`, { headers: { Authorization: `Bearer ${tok10}` } });
}
// Step 2: Switch to standards tab, create standard
await page.click('.tab:has-text("标准")');
await page.click('.section-header button:has-text("上传标准")');
await page.fill('.standard-form input:first-child', '全流程标准');
await page.fill('.standard-form textarea',
'## 功能完整性(40分)\n功能完整\n## 代码质量(30分)\n代码整洁\n## 文档(30分)\n文档齐全');
await page.click('.form-actions button:has-text("保存")');
await expect(page.locator('.standard-card')).toContainText('全流程标准');
// Step 3: Switch to entries tab, batch import
await page.click('.tab:has-text("条目")');
await page.click('.btn-secondary:has-text("导入")');
const csv = `title,repo_url,participant,difficulty
选手1,https://p1-${UNIQUE}.git,张三,★★
选手2,https://p2-${UNIQUE}.git,李四,★★★`;
await page.fill('.import-panel textarea', csv);
await page.click('.import-panel .form-actions button:first-child');
await expect(page.locator('.import-result')).toContainText('成功 2 条');
// Step 4: Verify entries visible
await page.waitForTimeout(500);
await expect(page.locator('.section-header h3')).toContainText('(2)');
// Step 5: Switch to summary
await page.click('.tab:has-text("汇总")');
// Since entries haven't been reviewed, summary should show categories
await page.waitForTimeout(500);
const body = await page.locator('.tab-content').textContent();
expect(body).toBeTruthy();
});
});
+73
View File
@@ -0,0 +1,73 @@
import { spawn } from 'child_process';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const ROOT = path.resolve(__dirname, '..', '..');
const SERVER_DIR = path.join(ROOT, 'server');
const WEB_DIR = path.join(ROOT, 'web');
const PID_FILE = path.join(__dirname, '.server-pids.json');
async function waitForServer(url: string, label: string, timeoutSec = 45) {
const start = Date.now();
while (Date.now() - start < timeoutSec * 1000) {
try {
const res = await fetch(url);
console.log(`[setup] ${label} probe: status ${res.status}`);
return;
} catch {}
await new Promise(r => setTimeout(r, 1000));
}
throw new Error(`[setup] ${label} at ${url} failed to start within ${timeoutSec}s`);
}
export default async function globalSetup() {
const env = {
...process.env,
PORT: '3002',
AUTH_PASSWORD: 'test123',
ADMIN_TEST_TOKEN: 'true',
// 测试模式下 writeEnvVar 只更新进程内 env,避免改密用例污染真实 server/.env
NODE_ENV: 'test',
} as Record<string, string>;
console.log('[setup] Starting backend server...');
const server = spawn('npx.cmd', ['tsx', 'src/index.ts'], {
cwd: SERVER_DIR,
stdio: ['ignore', 'pipe', 'pipe'],
env,
shell: true,
});
server.stdout.on('data', (d: Buffer) => process.stdout.write(`[server] ${d}`));
server.stderr.on('data', (d: Buffer) => process.stderr.write(`[server-err] ${d}`));
server.on('error', (e: Error) => console.error('[setup] server spawn error:', e.message));
console.log('[setup] Starting frontend...');
const frontend = spawn('npx.cmd', ['vite', '--port', '14001', '--strictPort'], {
cwd: WEB_DIR,
stdio: ['ignore', 'pipe', 'pipe'],
env: { ...process.env } as Record<string, string>,
shell: true,
});
frontend.stdout.on('data', (d: Buffer) => process.stdout.write(`[web] ${d}`));
frontend.stderr.on('data', (d: Buffer) => process.stderr.write(`[web-err] ${d}`));
frontend.on('error', (e: Error) => console.error('[setup] frontend spawn error:', e.message));
fs.writeFileSync(PID_FILE, JSON.stringify({
serverPid: server.pid,
frontendPid: frontend.pid,
}));
try {
await waitForServer('http://localhost:3002/api/health', 'backend');
await waitForServer('http://localhost:14001', 'frontend');
console.log('[setup] Both servers ready');
} catch (e) {
console.error('[setup] Failed:', (e as Error).message);
server.kill();
frontend.kill();
throw e;
}
}
+24
View File
@@ -0,0 +1,24 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const PID_FILE = path.join(__dirname, '.server-pids.json');
export default async function globalTeardown() {
try {
if (fs.existsSync(PID_FILE)) {
const { serverPid, frontendPid } = JSON.parse(fs.readFileSync(PID_FILE, 'utf-8'));
console.log(`[teardown] Killing server(pid=${serverPid}) frontend(pid=${frontendPid})`);
try { process.kill(serverPid, 'SIGTERM'); } catch {}
try { process.kill(frontendPid, 'SIGTERM'); } catch {}
fs.unlinkSync(PID_FILE);
console.log('[teardown] Done');
}
} catch (e) {
console.error('[teardown] Error:', (e as Error).message);
}
}
+453
View File
@@ -0,0 +1,453 @@
import { test, expect, type Page, type APIRequestContext } from '@playwright/test';
// ══════════════════════════════════════════════════════════════════
// 硬编码修正 E2E 测试套件
// 覆盖对象:
// 1. 模板维度可声明「文件关键词」→ DIM_FILE_FILTERS 硬编码仅兑底
// 2. 模板 content 成为评审指南第一来源(评审要点完整保留并透传)
// 3. 人才测评 L2「功能完整性」盲区修复(isBuildRelatedDim
// 4. 阈值收拢命名常量后 pass_line / 及格线行为回归
// 5. question_id 分组过滤(快照只含 common + 所选 Qn
// ══════════════════════════════════════════════════════════════════
const PASSWORD = 'test123';
const UNIQUE = Date.now().toString(36);
let entrySeq = 0; // 保证同 describe 内多条目的 repo_url 不撞车(中文标题消毒后 slug 可能为空)
const BASE = 'http://localhost:3002/api';
// ─── Helpers ───
async function login(page: Page) {
await page.goto('/login');
await page.fill('input[type="password"]', PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL('/');
}
async function apiLogin(request: APIRequestContext): Promise<string> {
const r = await request.post(`${BASE}/auth/login`, { data: { password: PASSWORD } });
const { token } = await r.json();
return token;
}
async function apiCreateProject(request: APIRequestContext, token: string, name: string, track = ''): Promise<string> {
const r = await request.fetch(`${BASE}/projects`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name, track },
});
expect(r.status()).toBe(200);
return (await r.json()).id;
}
async function apiCreateStandard(request: APIRequestContext, token: string, pid: string, content: string, category_tag = ''): Promise<string> {
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: `标准-${UNIQUE}`, content, category_tag, max_score: 150 },
});
expect(r.status()).toBe(200);
return (await r.json()).id;
}
// 赛道必选后项目创建自动带入赛道默认标准;若测试只用自定义标准,需先清空自动标准,
// 让条目创建时 resolveStandard 回退到用户上传的标准(category_tag=''
async function apiClearStandards(request: APIRequestContext, token: string, pid: string): Promise<void> {
const list = await request.fetch(`${BASE}/projects/${pid}/standards`, { headers: { Authorization: `Bearer ${token}` } });
const standards = await list.json();
for (const s of standards) {
await request.fetch(`${BASE}/projects/${pid}/standards/${s.id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${token}` },
});
}
}
async function apiCreateEntry(request: APIRequestContext, token: string, pid: string, title: string, extra: any = {}): Promise<any> {
const r = await request.fetch(`${BASE}/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title, repo_url: `https://e2e-${UNIQUE}-${entrySeq++}-${title.replace(/[^\w]/g, '')}.git`, ...extra },
});
expect(r.status()).toBe(200);
return r.json();
}
// ══════════════════════════════════════
// 1. 项目创建与赛道自动标准
// ══════════════════════════════════════
test.describe('项目创建与赛道自动标准', () => {
test('1.1 缺赛道被拒绝(赛道必选)', async ({ request }) => {
const token = await apiLogin(request);
const r = await request.fetch(`${BASE}/projects`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: `无赛道-${UNIQUE}` },
});
expect(r.status()).toBe(400);
expect((await r.json()).error).toContain('赛道');
});
test('1.2 赛道二自动生成 8 维度标准', async ({ request }) => {
const token = await apiLogin(request);
const pid = await apiCreateProject(request, token, `赛道二-${UNIQUE}`, '赛道二');
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` },
});
const standards = await r.json();
expect(standards).toHaveLength(1);
expect(standards[0].category_tag).toBe('赛道二');
expect(standards[0].dimensions).toHaveLength(8);
const names = standards[0].dimensions.map((d: any) => d.name);
expect(names).toContain('提效设计合理性');
expect(names).toContain('提效幅度');
expect(names).toContain('稳定性与易用性');
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('1.3 人才测评自动生成含「功能完整性」标准', async ({ request }) => {
const token = await apiLogin(request);
const pid = await apiCreateProject(request, token, `人才-${UNIQUE}`, '人才测评');
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` },
});
const standards = await r.json();
expect(standards).toHaveLength(1);
const dims = standards[0].dimensions;
expect(dims.some((d: any) => d.name === '功能完整性' && d.maxScore === 40)).toBe(true);
// 共通维度合计 100(L2 及格线 = 60)
const commonTotal = dims.filter((d: any) => (d.group || 'common') === 'common').reduce((s: number, d: any) => s + d.maxScore, 0);
expect(commonTotal).toBe(100);
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
});
// ══════════════════════════════════════
// 2. 标准上传:文件关键词解析
// ══════════════════════════════════════
test.describe('标准上传:文件关键词(fileKeywords', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiLogin(request);
pid = await apiCreateProject(request, token, `关键词-${UNIQUE}`, '赛道一');
});
test.afterAll(async ({ request }) => {
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('2.1 半角冒号 `文件关键词: a,b` 解析成功,content 剥离关键词行', async ({ request }) => {
const content = [
'## 提效幅度(10分)',
'检查以下4项',
'1. 对比数据(3分)',
'2. 可验证(2分)',
'文件关键词: data,report,benchmark',
].join('\n');
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: '半角标准', content, max_score: 150 },
});
expect(r.status()).toBe(200);
const std = await r.json();
expect(std.dimensions).toHaveLength(1);
expect(std.dimensions[0].fileKeywords).toBe('data,report,benchmark');
expect(std.dimensions[0].content).not.toContain('文件关键词');
expect(std.dimensions[0].content).toContain('对比数据');
});
test('2.2 全角冒号 `文件关键词:a,b` 同样解析', async ({ request }) => {
const content = '## 架构审查(20分)\n架构分层合理性\n文件关键词:design,arch,spec';
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: '全角标准', content, max_score: 150 },
});
expect(r.status()).toBe(200);
const std = await r.json();
expect(std.dimensions[0].fileKeywords).toBe('design,arch,spec');
expect(std.dimensions[0].content).not.toContain('文件关键词');
});
test('2.3 无关键词维度 fileKeywords 为 undefined', async ({ request }) => {
const content = '## 代码质量(50分)\n代码整洁、可读性\n## 测试覆盖(50分)\n覆盖率';
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: '无关键词标准', content, max_score: 150 },
});
expect(r.status()).toBe(200);
const std = await r.json();
expect(std.dimensions[0].fileKeywords).toBeUndefined();
expect(std.dimensions[1].fileKeywords).toBeUndefined();
});
test('2.4 文件关键词不改变标准总分校验', async ({ request }) => {
const content = '## A100分)\n说明\n文件关键词: a\n## B60分)\n说明';
const r = await request.fetch(`${BASE}/projects/${pid}/standards`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { name: '超分标准', content, max_score: 150 },
});
expect(r.status()).toBe(400);
});
test('2.5 UI 上传带关键词的标准 → 维度详情显示 content 不含关键词行', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("标准")');
await page.click('.section-header button:has-text("上传标准")');
await page.fill('.standard-form input:first-child', 'UI关键词标准');
await page.fill('.standard-form textarea',
'## 提效幅度(10分)\n检查以下4项\n1. 对比数据(3分)\n文件关键词: data,report');
await page.click('.form-actions button:has-text("保存")');
const card = page.locator('.standard-card', { hasText: 'UI关键词标准' });
await expect(card).toBeVisible();
await card.locator('summary').first().click();
const detailText = await card.textContent();
expect(detailText).toContain('对比数据');
expect(detailText).not.toContain('文件关键词');
});
test('2.6 textarea placeholder 提示文件关键词语法', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("标准")');
await page.click('.section-header button:has-text("上传标准")');
const placeholder = await page.locator('.standard-form textarea').getAttribute('placeholder');
expect(placeholder).toContain('文件关键词');
});
});
// ══════════════════════════════════════
// 3. 条目快照:fileKeywords 与 content 透传
// ══════════════════════════════════════
test.describe('条目快照透传', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiLogin(request);
pid = await apiCreateProject(request, token, `快照-${UNIQUE}`, '赛道一');
await apiClearStandards(request, token, pid);
await apiCreateStandard(request, token, pid,
'## 提效幅度(10分)\n检查以下4项\n1. 对比数据(3分)\n文件关键词: data,report,benchmark\n## 演示与文档(5分)\n文档齐全');
});
test.afterAll(async ({ request }) => {
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('3.1 条目 standard_snapshot 携带 fileKeywords 且 content 保留评审要点', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, '快照条目');
const dims = JSON.parse(entry.standard_snapshot);
expect(dims).toHaveLength(2);
const dim0 = dims.find((d: any) => d.name === '提效幅度');
expect(dim0.fileKeywords).toBe('data,report,benchmark');
expect(dim0.content).toContain('对比数据');
expect(dim0.content).not.toContain('文件关键词');
const dim1 = dims.find((d: any) => d.name === '演示与文档');
expect(dim1.fileKeywords).toBeUndefined();
expect(dim1.content).toContain('文档齐全');
});
test('3.2 该维度评审时文件将被关键词过滤(评审提示含评审要点)', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, '快照条目2');
const dims = JSON.parse(entry.standard_snapshot);
const dim0 = dims.find((d: any) => d.name === '提效幅度');
// content 是发送给子 Agent 的评审指南来源(模板优先于硬编码)
expect(dim0.content.length).toBeGreaterThan(5);
});
});
// ══════════════════════════════════════
// 4. 人才测评:question_id 分组过滤 + 功能完整性
// ══════════════════════════════════════
test.describe('人才测评 question_id 分组', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiLogin(request);
pid = await apiCreateProject(request, token, `人才Q-${UNIQUE}`, '人才测评');
});
test.afterAll(async ({ request }) => {
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('4.1 建条目未选题目被拒绝(400', async ({ request }) => {
const r = await request.fetch(`${BASE}/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: '无题目', repo_url: `https://noq-${UNIQUE}.git` },
});
expect(r.status()).toBe(400);
});
test('4.2 Q2 条目快照包含 common 与 [Q2] 维度(完整标准,评审时按题目过滤)', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, 'Q2选手', { question_id: 'Q2' });
const dims = JSON.parse(entry.standard_snapshot);
const groups = new Set(dims.map((d: any) => d.group || 'common'));
expect(groups.has('common')).toBe(true);
expect(groups.has('Q2')).toBe(true);
// 快照保留完整标准(含其他题目维度),评审时按 question_id 过滤
expect(groups.has('Q3')).toBe(true);
// 功能完整性在 common 组且是构建封顶维度
const fnDim = dims.find((d: any) => d.name === '功能完整性');
expect(fnDim).toBeDefined();
expect(fnDim.maxScore).toBe(40);
});
test('4.3 pass_line = 共通维度 60% = 60', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, 'Q1选手', { question_id: 'Q1' });
expect(entry.pass_line).toBe(60);
});
});
// ══════════════════════════════════════
// 5. 赛道二 pass_line 与评审展示
// ══════════════════════════════════════
test.describe('赛道二 及格线与评审展示', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiLogin(request);
pid = await apiCreateProject(request, token, `赛道二P-${UNIQUE}`, '赛道二');
});
test.afterAll(async ({ request }) => {
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('5.1 赛道二 pass_line = 总分 60% = 60', async ({ request }) => {
const entry = await apiCreateEntry(request, token, pid, '赛道二选手');
expect(entry.pass_line).toBe(60);
});
test('5.2 force-review 后详情面板显示 8 维度与总分', async ({ page, request }) => {
const entry = await apiCreateEntry(request, token, pid, '展示选手');
const dims = JSON.parse(entry.standard_snapshot);
const reviewDims = dims.map((d: any) => ({
name: d.name,
score: Math.round(d.maxScore * 0.5),
maxScore: d.maxScore,
comment: `评审 ${d.name}`,
}));
await request.fetch(`${BASE}/projects/${pid}/entries/${entry.id}/force-review`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { dimensions: reviewDims },
});
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
await page.click('.title-cell');
await expect(page.locator('.detail-panel')).toBeVisible();
await expect(page.locator('.detail-dims')).toBeVisible();
const rows = await page.locator('.detail-dims tbody tr').count();
expect(rows).toBe(8);
const detailText = await page.locator('.detail-panel').textContent();
expect(detailText).toContain('提效设计合理性');
expect(detailText).toContain('提效幅度');
});
test('5.3 汇总页显示总分与通过判定', async ({ page, request }) => {
const entry = await apiCreateEntry(request, token, pid, '汇总选手');
const dims = JSON.parse(entry.standard_snapshot);
const reviewDims = dims.map((d: any) => ({ name: d.name, score: d.maxScore, maxScore: d.maxScore, comment: '满分' }));
await request.fetch(`${BASE}/projects/${pid}/entries/${entry.id}/force-review`, {
method: 'PUT',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { dimensions: reviewDims },
});
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("汇总")');
await expect(page.locator('.summary-section')).toContainText('汇总选手');
await expect(page.locator('.summary-section')).toContainText('100');
});
});
// ══════════════════════════════════════
// 6. 异常与临界值
// ══════════════════════════════════════
test.describe('异常与临界值', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiLogin(request);
pid = await apiCreateProject(request, token, `异常-${UNIQUE}`, '赛道一');
await apiCreateStandard(request, token, pid, '## 代码质量(100分)\n质量');
});
test.afterAll(async ({ request }) => {
await request.fetch(`${BASE}/projects/${pid}?force=true`, { method: 'DELETE', headers: { Authorization: `Bearer ${token}` } });
});
test('6.1 缺 repo_url 创建条目被拒(400', async ({ request }) => {
const r = await request.fetch(`${BASE}/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: '无仓库' },
});
expect(r.status()).toBe(400);
});
test('6.2 非法服务地址被拒(localhost 内网)', async ({ request }) => {
const r = await request.fetch(`${BASE}/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: '非法地址', repo_url: `https://svc-${UNIQUE}.git`, service_url: 'http://localhost:3000' },
});
expect(r.status()).toBe(400);
});
test('6.3 相同 repo_url 重复创建被拒(唯一约束)', async ({ request }) => {
const dupUrl = `https://dup-${UNIQUE}.git`;
await apiCreateEntry(request, token, pid, '首建', { repo_url: dupUrl });
const r = await request.fetch(`${BASE}/projects/${pid}/entries`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: '重复', repo_url: dupUrl },
});
expect(r.status()).toBe(409);
});
test('6.4 未评审条目启动后被系统处理(不崩溃)', async ({ page, request }) => {
const entry = await apiCreateEntry(request, token, pid, '启动条目');
const r = await request.fetch(`${BASE}/projects/${pid}/entries/${entry.id}/start`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
});
expect(r.status()).toBe(200);
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.tab:has-text("条目")');
await expect(page.locator('.entry-table')).toContainText('启动条目');
});
});
+137
View File
@@ -0,0 +1,137 @@
import { test, expect, type Page } from '@playwright/test';
const PASSWORD = 'test123';
const UNIQUE = Date.now().toString(36);
let pid = '';
let lastNewPwd = '';
async function login(page: Page, password = PASSWORD) {
await page.goto('/login');
await page.fill('input[type="password"]', password);
await page.click('button[type="submit"]');
await page.waitForURL('/');
}
// ══════════════════════════════════════
// K7: 改密流程(httpOnly cookie + 密钥轮换 → 需重登)
// ══════════════════════════════════════
test.describe('K7 改密流程', () => {
test.afterAll(async ({ request }) => {
// 崩溃安全:若改密测试中途失败,确保服务器密码还原,避免级联影响其他 spec
const ok = await request.post('http://localhost:3002/api/auth/login', { data: { password: PASSWORD } });
if (ok.ok()) return;
if (!lastNewPwd) return;
const loginNew = await request.post('http://localhost:3002/api/auth/login', { data: { password: lastNewPwd } });
if (!loginNew.ok()) return;
const { token } = await loginNew.json();
await request.post('http://localhost:3002/api/auth/password', {
headers: { Authorization: `Bearer ${token}` },
data: { currentPassword: lastNewPwd, newPassword: PASSWORD },
});
});
test('改密:当前密码错误时显示错误且不跳转', async ({ page }) => {
await login(page);
await page.click('button[title="修改管理密码"]');
await page.fill('input[placeholder="当前密码"]', 'wrong-current');
await page.fill('input[placeholder="新密码(至少 6 位)"]', 'new-pass-123');
await page.click('.new-project-actions button:has-text("保存")');
await expect(page.locator('.new-project-form')).toContainText('当前密码错误');
await expect(page).toHaveURL('/');
});
test('改密成功后旧密码失效、新密码可登录,并还原原密码', async ({ page }) => {
const NEW_PWD = `new-${UNIQUE}-x9`;
lastNewPwd = NEW_PWD;
await login(page, PASSWORD);
await page.click('button[title="修改管理密码"]');
await page.fill('input[placeholder="当前密码"]', PASSWORD);
await page.fill('input[placeholder="新密码(至少 6 位)"]', NEW_PWD);
await page.click('.new-project-actions button:has-text("保存")');
await page.waitForURL('/login');
// 旧密码登录失败
await page.fill('input[type="password"]', PASSWORD);
await page.click('button[type="submit"]');
await expect(page.locator('.error')).toContainText('密码错误');
// 新密码登录成功
await login(page, NEW_PWD);
await expect(page.locator('.sidebar-header h2')).toHaveText('AI-Review');
// 还原原密码
await page.click('button[title="修改管理密码"]');
await page.fill('input[placeholder="当前密码"]', NEW_PWD);
await page.fill('input[placeholder="新密码(至少 6 位)"]', PASSWORD);
await page.click('.new-project-actions button:has-text("保存")');
await page.waitForURL('/login');
await login(page, PASSWORD);
await expect(page.locator('.sidebar-header h2')).toHaveText('AI-Review');
});
});
// ══════════════════════════════════════
// D2: CSV 导入模板下载
// ══════════════════════════════════════
test.describe('D2 CSV 模板下载', () => {
test.beforeAll(async ({ request }) => {
const loginRes = await request.post('http://localhost:3002/api/auth/login', { data: { password: PASSWORD } });
const { token } = await loginRes.json();
const proj = await request.post('http://localhost:3002/api/projects', {
headers: { Authorization: `Bearer ${token}` },
data: { name: `csv-${UNIQUE}`, track: '赛道一' },
});
pid = (await proj.json()).id;
});
test('批量导入面板可下载 CSV 模板', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('button:has-text("批量导入")');
const downloadPromise = page.waitForEvent('download');
await page.click('button:has-text("下载模板")');
const download = await downloadPromise;
expect(download.suggestedFilename()).toBe('entry-import-template.csv');
});
});
// ══════════════════════════════════════
// K4: 条目编辑保存补齐 sub_type/question_id
// ══════════════════════════════════════
test.describe('K4 编辑保存字段', () => {
let pid = '';
let eid = '';
test.beforeAll(async ({ request }) => {
const loginRes = await request.post('http://localhost:3002/api/auth/login', { data: { password: PASSWORD } });
const { token } = await loginRes.json();
const proj = await request.post('http://localhost:3002/api/projects', {
headers: { Authorization: `Bearer ${token}` }, data: { name: `k4-${UNIQUE}`, track: '赛道一' },
});
pid = (await proj.json()).id;
const entry = await request.post(`http://localhost:3002/api/projects/${pid}/entries`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: 'k4-entry', repo_url: `file://C:\\k4-${UNIQUE}` },
});
eid = (await entry.json()).id;
});
test('编辑保存 sub_type 持久化', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('button:has-text("编辑")');
await page.selectOption('.detail-overlay select', { label: '新規开发' });
await page.click('.detail-overlay button:has-text("保存")');
const loginRes = await page.request.post('http://localhost:3002/api/auth/login', { data: { password: PASSWORD } });
const { token } = await loginRes.json();
const detailRes = await page.request.get(`http://localhost:3002/api/projects/${pid}/entries/${eid}`, {
headers: { Authorization: `Bearer ${token}` },
});
const detail = await detailRes.json();
expect(detail.sub_type).toBe('新規');
});
});
+229
View File
@@ -0,0 +1,229 @@
import { test, expect, type Page } from '@playwright/test';
const PASSWORD = 'test123';
const UNIQUE = Date.now().toString(36);
async function login(page: Page) {
await page.goto('/login');
await page.fill('input[type="password"]', PASSWORD);
await page.click('button[type="submit"]');
await page.waitForURL('/');
}
async function apiToken(request: any): Promise<string> {
const res = await request.post('http://localhost:3002/api/auth/login', { data: { password: PASSWORD } });
const { token } = await res.json();
return token;
}
// ══════════════════════════════════════
// 1. 成果物 TabDeliverablesView
// ══════════════════════════════════════
test.describe('成果物 Tab', () => {
let pid = '';
let token = '';
test.beforeAll(async ({ request }) => {
token = await apiToken(request);
const proj = await request.post('http://localhost:3002/api/projects', {
headers: { Authorization: `Bearer ${token}` }, data: { name: `deliv-${UNIQUE}`, track: '赛道一' },
});
pid = (await proj.json()).id;
await request.post(`http://localhost:3002/api/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` }, data: { name: 'd-std', content: '## 场景价值(8分)' },
});
await request.post(`http://localhost:3002/api/projects/${pid}/entries`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: 'deliv-entry', repo_url: `file://C:\\deliv-${UNIQUE}` },
});
});
test.afterAll(async ({ request }) => {
await request.delete(`http://localhost:3002/api/projects/${pid}?force=true`, { headers: { Authorization: `Bearer ${token}` } });
});
test('初始化一覧 → 勾选 → 提交率更新 → CSV 下载', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.getByRole('button', { name: '成果物' }).click();
await expect(page.getByText('成果物确认')).toBeVisible();
// 初始化一覧
await page.getByRole('button', { name: '初始化一覧' }).click();
await expect(page.locator('.entry-table input[type="checkbox"]').first()).toBeVisible();
// 勾选第一个成果物 → 提交率 +1(经 API 验证)
const before = await page.request.get(`http://localhost:3002/api/projects/${pid}/entries/deliverables/summary`, {
headers: { Authorization: `Bearer ${token}` },
});
const beforeData = await before.json();
await page.locator('.entry-table input[type="checkbox"]').first().check();
await expect.poll(async () => {
const r = await page.request.get(`http://localhost:3002/api/projects/${pid}/entries/deliverables/summary`, {
headers: { Authorization: `Bearer ${token}` },
});
return (await r.json()).totalSubmitted;
}).toBe(beforeData.totalSubmitted + 1);
// CSV 下载
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '下载CSV' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toContain('deliverables');
});
});
// ══════════════════════════════════════
// 2. 评审进度时间线(DetailPanelD7
// ══════════════════════════════════════
test.describe('评审进度时间线', () => {
let pid = ''; let eid = ''; let token = '';
test.beforeAll(async ({ request }) => {
token = await apiToken(request);
const proj = await request.post('http://localhost:3002/api/projects', {
headers: { Authorization: `Bearer ${token}` }, data: { name: `prog-${UNIQUE}`, track: '赛道一' },
});
pid = (await proj.json()).id;
await request.post(`http://localhost:3002/api/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` }, data: { name: 'p-std', content: '## 场景价值(8分)' },
});
const entry = await request.post(`http://localhost:3002/api/projects/${pid}/entries`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: 'prog-entry', repo_url: `file://C:\\prog-${UNIQUE}` },
});
eid = (await entry.json()).id;
// 启动评审(外部路径 → 秒级 clone_fail),写入 progress_log
await request.post(`http://localhost:3002/api/projects/${pid}/entries/${eid}/start`, {
headers: { Authorization: `Bearer ${token}` },
});
await expect.poll(async () => {
const r = await request.get(`http://localhost:3002/api/projects/${pid}/entries/${eid}`, {
headers: { Authorization: `Bearer ${token}` },
});
return (await r.json()).status;
}).toBe('clone_fail');
});
test.afterAll(async ({ request }) => {
await request.delete(`http://localhost:3002/api/projects/${pid}?force=true`, { headers: { Authorization: `Bearer ${token}` } });
});
test('详情面板显示评审进度时间线(含克隆步骤)', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.title-cell');
const progress = page.locator('details', { has: page.getByText('评审进度') });
await expect(progress).toBeVisible();
await expect(progress).toContainText('不允许克隆外部路径');
});
});
// ══════════════════════════════════════
// 3. PDF 下载(单条目报告 / 汇总报告)
// ══════════════════════════════════════
test.describe('PDF 下载', () => {
let pid = ''; let eid = ''; let token = '';
test.beforeAll(async ({ request }) => {
token = await apiToken(request);
const proj = await request.post('http://localhost:3002/api/projects', {
headers: { Authorization: `Bearer ${token}` }, data: { name: `pdf-${UNIQUE}`, track: '赛道一' },
});
pid = (await proj.json()).id;
await request.post(`http://localhost:3002/api/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` }, data: { name: 'pdf-std', content: '## 架构设计(10分)' },
});
const entry = await request.post(`http://localhost:3002/api/projects/${pid}/entries`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: 'pdf-entry', repo_url: `file://C:\\pdf-${UNIQUE}` },
});
eid = (await entry.json()).id;
await request.put(`http://localhost:3002/api/projects/${pid}/entries/${eid}/force-review`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { dimensions: [{ name: '架构设计', score: 8, maxScore: 10 }] },
});
});
test.afterAll(async ({ request }) => {
await request.delete(`http://localhost:3002/api/projects/${pid}?force=true`, { headers: { Authorization: `Bearer ${token}` } });
});
test('单条目报告 PDF 下载', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.title-cell');
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '下载报告' }).click();
const download = await downloadPromise;
// 服务端 Content-Disposition 中文文件名在部分平台会被搅乱,这里只断言"触发了 .pdf 下载"
expect(download.suggestedFilename().toLowerCase()).toContain('.pdf');
});
test('汇总报告 PDF 下载', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.getByRole('button', { name: '汇总' }).click();
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: '下载汇总PDF' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename().toLowerCase()).toContain('.pdf');
});
});
// ══════════════════════════════════════
// 4. 人才测评 L2/L3 分表展示
// ══════════════════════════════════════
test.describe('人才测评 L2/L3 分表', () => {
let pid = ''; let eid = ''; let token = '';
test.beforeAll(async ({ request }) => {
token = await apiToken(request);
const proj = await request.post('http://localhost:3002/api/projects', {
headers: { Authorization: `Bearer ${token}` }, data: { name: `l2l3-${UNIQUE}`, track: '人才测评' },
});
pid = (await proj.json()).id;
// 删除自动标准,替换为自定义 Q2 标准
const list = await request.get(`http://localhost:3002/api/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` },
});
for (const s of await list.json()) {
await request.delete(`http://localhost:3002/api/projects/${pid}/standards/${s.id}`, { headers: { Authorization: `Bearer ${token}` } });
}
await request.post(`http://localhost:3002/api/projects/${pid}/standards`, {
headers: { Authorization: `Bearer ${token}` },
data: { name: 'l2-std', category_tag: '人才测评', content: '## 功能完整性(40分)\n## 设计文档(10分)\n## [Q2] LLM生成问卷(15分)' },
});
const entry = await request.post(`http://localhost:3002/api/projects/${pid}/entries`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: { title: 'l2l3-entry', repo_url: `file://C:\\l2-${UNIQUE}`, question_id: 'Q2' },
});
eid = (await entry.json()).id;
await request.put(`http://localhost:3002/api/projects/${pid}/entries/${eid}/force-review`, {
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
data: {
dimensions: [
{ name: '功能完整性', score: 40, maxScore: 40, group: 'common' },
{ name: '设计文档', score: 10, maxScore: 10, group: 'common' },
{ name: 'LLM生成问卷', score: 15, maxScore: 15, group: 'Q2' },
],
},
});
});
test.afterAll(async ({ request }) => {
await request.delete(`http://localhost:3002/api/projects/${pid}?force=true`, { headers: { Authorization: `Bearer ${token}` } });
});
test('详情面板分别展示 L2共通 与 L3追加 分表', async ({ page }) => {
await login(page);
await page.goto(`/project/${pid}`);
await page.click('.title-cell');
await expect(page.getByText('L2共通评分')).toBeVisible();
await expect(page.getByText('L3追加评分')).toBeVisible();
});
});
+18
View File
@@ -0,0 +1,18 @@
import { test, expect } from '@playwright/test';
// ⚠️ 本用例会锁定服务端 127.0.0.1 的登录 60 秒,必须作为 e2e 套件最后一个文件运行(zzz- 前缀)。
// 若与其它 spec 并行执行会级联影响登录;当前套件共享单一服务器、依赖串行(与既有改密用例一致)。
test.describe('登录限流 UI(§7.1', () => {
test('连续 5 次密码错误后第 6 次提示「登录尝试过多」', async ({ page }) => {
await page.goto('/login');
for (let i = 0; i < 5; i++) {
await page.fill('input[type="password"]', 'wrong-password');
await page.click('button[type="submit"]');
await expect(page.locator('.error')).toContainText('密码错误');
}
await page.fill('input[type="password"]', 'wrong-password');
await page.click('button[type="submit"]');
await expect(page.locator('.error')).toContainText('登录尝试过多');
});
});