Files
msh-system/tests/e2e/bug-regression.spec.ts
scottpan 4be53dcd1b feat: 集成 KieAI 服务,移除 models-integration 子项目
- 添加 Gemini 2.5 Flash 对话接口(流式+非流式)
- 添加 NanoBanana 图像生成/编辑接口
- 添加 Sora2 视频生成接口(文生视频、图生视频、去水印)
- 移除 models-integration 子项目(功能已迁移至主后端)
- 新增测试文档和 Playwright E2E 配置
- 更新前端页面和 API 接口
- 更新后端配置和日志处理
2026-03-03 15:33:50 +08:00

518 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* E2E Bug Regression Tests
*
* Covers manual test issues:
* BUG-001 打卡积分显示与累加逻辑 (TC-B01a, TC-B01b)
* BUG-002 食谱计算器 Tab 选中样式 (TC-B02)
* BUG-003 食物百科列表缺图片与简介 (TC-B03)
* BUG-004 食物百科详情页数据加载失败 (TC-B04)
* BUG-005 AI营养师固定默认答复 (TC-B05)
* BUG-006 健康知识/营养知识名称不统一 (TC-B06)
* BUG-007 饮食指南/科普文章详情页无内容 (TC-B07)
* BUG-008 打卡社区帖子营养统计数据 (TC-B08)
* BUG-009 打卡社区帖子类型中文命名 (TC-B09)
*/
import { test, expect, Page, APIRequestContext } from '@playwright/test';
// @ts-expect-error - Node built-in, types from @types/node
import * as fs from 'fs';
// ─── 常量 ───────────────────────────────────────────────────────────────────
const BASE = 'http://localhost:8080';
const API_BASE = 'http://127.0.0.1:20822';
const TOOL_MAIN = 'pages/tool_main/index';
const PHONE = '18621813282';
const PASSWORD = 'A123456';
const SCREENSHOT_DIR = 'tests/e2e/screenshots';
const EXPIRE_SUFFIX = '_expire_2019_12_17_18_44';
const FAR_FUTURE = '4102444800';
let AUTH_TOKEN = '';
let AUTH_UID = 0;
// ─── 工具函数 ─────────────────────────────────────────────────────────────────
async function apiLogin(request: APIRequestContext): Promise<{ token: string; uid: number }> {
const res = await request.post(`${API_BASE}/api/front/login`, {
data: { account: PHONE, password: PASSWORD },
headers: { 'Content-Type': 'application/json' },
});
const body = await res.json();
if (body.code !== 200 || !body.data?.token) {
throw new Error(`API login failed: ${JSON.stringify(body)}`);
}
return { token: body.data.token, uid: body.data.uid };
}
async function injectAuth(page: Page, token: string, uid: number): Promise<void> {
await page.addInitScript(
({ t, u, suffix, farFuture }) => {
localStorage.setItem('LOGIN_STATUS_TOKEN', t);
localStorage.setItem(`LOGIN_STATUS_TOKEN${suffix}`, farFuture);
localStorage.setItem('UID', u.toString());
localStorage.setItem(`UID${suffix}`, farFuture);
},
{ t: token, u: uid, suffix: EXPIRE_SUFFIX, farFuture: FAR_FUTURE },
);
}
async function goto(page: Page, route: string, waitMs = 2500): Promise<void> {
await page.goto(`${BASE}/#/${route}`);
await page.waitForTimeout(waitMs);
}
function ensureScreenshotDir(): void {
try {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
} catch {
// ignore if exists or permission
}
}
async function screenshot(page: Page, name: string): Promise<void> {
await page.screenshot({
path: `${SCREENSHOT_DIR}/${name}.png`,
fullPage: false,
}).catch(() => {});
}
/** Parse points number from ".points-text" content like "522 积分" */
function parsePointsFromText(text: string | null): number {
const s = text ?? '';
const m = s.match(/(\d+)\s*积分/);
return m ? parseInt(m[1], 10) : 0;
}
// ─── 全局 beforeAll / beforeEach ─────────────────────────────────────────────
test.beforeAll(async ({ request }) => {
ensureScreenshotDir();
try {
const { token, uid } = await apiLogin(request);
AUTH_TOKEN = token;
AUTH_UID = uid;
} catch (e) {
console.error('[Auth] API login failed:', e);
}
});
test.beforeEach(async ({ page }) => {
page.on('pageerror', (err) => {
if (!err.message.includes('sockjs') && !err.message.includes('Unexpected end of stream')) {
console.warn(`[PageError] ${err.message}`);
}
});
});
// ═══════════════════════════════════════════════════════════════════════════════
// BUG-001 打卡:未登录跳转登录页,打卡页一天仅允许一次
// ═══════════════════════════════════════════════════════════════════════════════
test('TC-B01a 未登录点击打卡跳转登录页', async ({ page }) => {
await goto(page, TOOL_MAIN, 4000);
const urlAfterLoad = page.url();
if (urlAfterLoad.includes('users/login') || urlAfterLoad.includes('login/index')) {
await screenshot(page, 'tc-b01a-checkin-to-login');
return;
}
const userCard = page.locator('.user-card').first();
await userCard.waitFor({ state: 'visible', timeout: 10_000 }).catch(() => {});
const checkinBtn = page.locator('.checkin-btn').first();
if (await checkinBtn.isVisible().catch(() => false)) {
await checkinBtn.click();
await page.waitForTimeout(2500);
}
const url = page.url();
const onLoginPage = url.includes('users/login') || url.includes('login/index');
expect(onLoginPage, '未登录点击打卡应跳转到登录页').toBe(true);
await screenshot(page, 'tc-b01a-checkin-to-login');
});
test('TC-B01b 登录后第一次打卡成功且同一天第二次仅允许一次', async ({ page }) => {
await injectAuth(page, AUTH_TOKEN, AUTH_UID);
await goto(page, 'pages/tool/checkin', 3000);
const checkinPage = page.locator('.checkin-page').first();
await checkinPage.waitFor({ state: 'visible', timeout: 12_000 });
const pointsEl = page.locator('.points-text').first();
await expect(pointsEl).toBeVisible({ timeout: 10_000 });
const initialText = await pointsEl.textContent();
const initialPoints = parsePointsFromText(initialText || '');
const punchBtn = page.locator('.checkin-card .checkin-btn').first();
const btnText = await punchBtn.textContent().catch(() => '');
if (btnText && btnText.includes('立即打卡')) {
await punchBtn.click();
await page.waitForTimeout(5000);
await goto(page, 'pages/tool/checkin', 3500);
const afterEl = page.locator('.points-text').first();
await expect(afterEl).toBeVisible({ timeout: 10_000 });
const afterText = await afterEl.textContent();
const afterPoints = parsePointsFromText(afterText || '');
expect(afterPoints, '首次打卡成功后积分应增加').toBeGreaterThanOrEqual(initialPoints);
}
await goto(page, 'pages/tool/checkin', 4000);
const punchBtnSecond = page.locator('.checkin-card .checkin-btn').first();
await expect(punchBtnSecond).toBeVisible({ timeout: 10_000 });
await page.waitForTimeout(1500);
let secondText = (await punchBtnSecond.textContent().catch(() => '')) || '';
let hasDisabledClass = await punchBtnSecond.evaluate((el) => el.classList.contains('checkin-btn-disabled')).catch(() => false);
let hasAlreadySignedText = await page.locator('.checkin-card').getByText(/今日已打卡|已签到|今日已/).isVisible().catch(() => false);
let alreadySigned =
secondText.includes('今日已打卡') ||
secondText.includes('已签到') ||
secondText.includes('今日已') ||
hasDisabledClass ||
hasAlreadySignedText;
if (!alreadySigned && secondText.includes('立即打卡')) {
await punchBtnSecond.click();
await page.waitForTimeout(3500);
const btnAfter = await page.locator('.checkin-card .checkin-btn').first().textContent().catch(() => '');
const pageHasToast = await page.getByText(/今日已签到|今日已打卡|不可重复/).isVisible().catch(() => false);
alreadySigned = (btnAfter && (btnAfter.includes('今日已打卡') || btnAfter.includes('已签到'))) || pageHasToast;
}
expect(alreadySigned, '同一天第二次进入打卡页应显示今日已打卡或点击后提示今日已签到').toBe(true);
await screenshot(page, 'tc-b01b-checkin-once-per-day');
});
// ═══════════════════════════════════════════════════════════════════════════════
// BUG-002 食谱计算器 Tab 选中样式辨识度
// ═══════════════════════════════════════════════════════════════════════════════
test('TC-B02 计算结果页 Tab 切换选中状态清晰', async ({ page }) => {
await injectAuth(page, AUTH_TOKEN, AUTH_UID);
await goto(page, TOOL_MAIN, 3000);
await page.locator('.user-card').waitFor({ state: 'visible', timeout: 12_000 });
await page.locator('.calculator').click();
await page.waitForTimeout(3000);
const formContainer = page.locator('.calculator-page').first();
await formContainer.waitFor({ state: 'visible', timeout: 10_000 });
const maleOption = page.locator('.radio-item').filter({ hasText: '男' }).first();
await maleOption.click();
await page.waitForTimeout(500);
const numberInputs = page.locator('input[type="number"], input.uni-input-input');
const ageI = numberInputs.nth(0);
const htI = numberInputs.nth(1);
const wtI = numberInputs.nth(2);
if (await ageI.count() > 0) {
await ageI.fill('45');
await htI.fill('170');
await wtI.fill('65');
await page.waitForTimeout(500);
}
const calcBtn = page.locator('.submit-btn, .calc-btn, button').filter({ hasText: /计算|确定|提交/ }).first();
await calcBtn.click();
await page.waitForTimeout(3500);
const resultPage = page.locator('.result-page').first();
await resultPage.waitFor({ state: 'visible', timeout: 10_000 });
const tabItems = page.locator('.tab-item');
await expect(tabItems).toHaveCount(2);
const overviewTab = page.locator('.tab-item').filter({ hasText: '健康概览' }).first();
const mealTab = page.locator('.tab-item').filter({ hasText: '营养配餐' }).first();
await overviewTab.click();
await page.waitForTimeout(500);
await expect(overviewTab).toHaveClass(/active/);
await expect(mealTab).not.toHaveClass(/active/);
await mealTab.click();
await page.waitForTimeout(500);
await expect(mealTab).toHaveClass(/active/);
await expect(overviewTab).not.toHaveClass(/active/);
const activeColor = await page.locator('.tab-item.active .tab-text').first().evaluate((el) => {
const s = window.getComputedStyle(el);
return s.color + '|' + s.fontWeight;
});
const inactiveColor = await page.locator('.tab-item').filter({ hasText: '健康概览' }).first().evaluate((el) => {
const s = window.getComputedStyle(el);
return s.color + '|' + s.fontWeight;
});
expect(activeColor).toBeTruthy();
expect(inactiveColor).toBeTruthy();
await screenshot(page, 'tc-b02-calculator-tabs');
});
// ═══════════════════════════════════════════════════════════════════════════════
// BUG-003 食物百科列表缺图片与简介
// ═══════════════════════════════════════════════════════════════════════════════
test('TC-B03 食物列表每条目展示图片与营养信息', async ({ page }) => {
await injectAuth(page, AUTH_TOKEN, AUTH_UID);
await goto(page, 'pages/tool/food-encyclopedia', 3000);
const foodPage = page.locator('.food-page, .search-container, .food-list').first();
await foodPage.waitFor({ state: 'visible', timeout: 12_000 });
const foodItems = page.locator('.food-item');
await expect(foodItems.first()).toBeVisible({ timeout: 10_000 });
const count = await foodItems.count();
expect(count, '食物列表应有至少一条').toBeGreaterThan(0);
const toCheck = Math.min(3, count);
for (let i = 0; i < toCheck; i++) {
const item = foodItems.nth(i);
await expect(item.locator('.food-name').first()).toBeVisible();
const nameText = await item.locator('.food-name').first().textContent();
expect((nameText || '').trim().length).toBeGreaterThan(0);
const img = item.locator('.food-image').first();
if (await img.count() > 0) {
const src = await img.getAttribute('src');
expect(src, `食物条目 ${i + 1} 应有配图 src`).toBeTruthy();
expect((src || '').trim().length).toBeGreaterThan(0);
}
const nutritionItems = item.locator('.nutrition-item');
const nutritionCount = await nutritionItems.count();
expect(nutritionCount, `食物条目 ${i + 1} 应至少有一条营养信息`).toBeGreaterThanOrEqual(1);
}
await screenshot(page, 'tc-b03-food-list');
});
// ═══════════════════════════════════════════════════════════════════════════════
// BUG-004 食物百科详情页数据加载失败
// ═══════════════════════════════════════════════════════════════════════════════
test('TC-B04 食物详情页正常加载内容', async ({ page }) => {
await injectAuth(page, AUTH_TOKEN, AUTH_UID);
await goto(page, 'pages/tool/food-encyclopedia', 3000);
const firstFood = page.locator('.food-item').first();
await firstFood.waitFor({ state: 'visible', timeout: 10_000 });
await firstFood.click();
await page.waitForTimeout(3000);
const detailPage = page.locator('.food-detail-page').first();
await detailPage.waitFor({ state: 'visible', timeout: 8_000 }).catch(() => {});
await expect(page.getByText('数据加载失败')).toHaveCount(0);
const nameOverlay = page.locator('.food-name-overlay').first();
await expect(nameOverlay).toBeVisible({ timeout: 6_000 });
const nameText = await nameOverlay.textContent();
expect((nameText || '').trim().length).toBeGreaterThan(0);
const nutrientCards = page.locator('.nutrient-card');
const cardCount = await nutrientCards.count();
expect(cardCount, '详情页应展示营养数据').toBeGreaterThan(0);
const nutritionRows = page.locator('.nutrition-row');
const rowCount = await nutritionRows.count();
expect(rowCount, '详情页应有营养表格').toBeGreaterThan(0);
await screenshot(page, 'tc-b04-food-detail');
});
// ═══════════════════════════════════════════════════════════════════════════════
// BUG-005 AI营养师对话使用 KieAI Gemini chat针对问题返回差异化回复
// ═══════════════════════════════════════════════════════════════════════════════
test('TC-B05 AI针对不同问题返回差异化回复', async ({ page }) => {
test.setTimeout(60_000);
await injectAuth(page, AUTH_TOKEN, AUTH_UID);
await goto(page, TOOL_MAIN, 3000);
await page.locator('.user-card').waitFor({ state: 'visible', timeout: 12_000 });
await page.locator('.ai-nutritionist').click();
await page.waitForTimeout(3500);
const chatContainer = page.locator('.chat-container, .ai-chat-page').first();
await chatContainer.waitFor({ state: 'visible', timeout: 12_000 });
const textInput = page.locator('input[type="text"], textarea, .chat-input input').first();
await textInput.waitFor({ state: 'visible', timeout: 8_000 });
await textInput.fill('什么食物富含蛋白质?');
const sendBtn = page.locator('.send-btn').first();
await sendBtn.click();
await page.waitForTimeout(8000);
const aiMessages1 = page.locator('.message-item.ai-message .message-text');
await expect(aiMessages1.last()).toBeVisible({ timeout: 15_000 });
const response1 = await aiMessages1.last().textContent();
expect((response1 || '').length).toBeGreaterThan(20);
await textInput.fill('糖尿病患者饮食需要注意什么?');
await sendBtn.click();
await page.waitForTimeout(8000);
const aiMessages2 = page.locator('.message-item.ai-message .message-text');
await expect(aiMessages2.last()).toBeVisible({ timeout: 10_000 });
const response2 = await aiMessages2.last().textContent();
expect((response2 || '').length).toBeGreaterThan(20);
expect(response1, '两个不同问题应得到不同回复').not.toBe(response2);
await screenshot(page, 'tc-b05-ai-responses');
});
// ═══════════════════════════════════════════════════════════════════════════════
// BUG-006 健康知识模块名称不统一
// ═══════════════════════════════════════════════════════════════════════════════
test('TC-B06 健康知识与营养知识名称统一性', async ({ page }) => {
await injectAuth(page, AUTH_TOKEN, AUTH_UID);
await goto(page, TOOL_MAIN, 3000);
await page.locator('.user-card').waitFor({ state: 'visible', timeout: 12_000 });
const sectionHeader = page.locator('.section-header').filter({ has: page.locator('.section-title') }).filter({ hasText: /健康知识|营养知识/ }).first();
await expect(sectionHeader).toBeVisible({ timeout: 10_000 });
const mainTitle = await sectionHeader.locator('.section-title').textContent();
const mainName = (mainTitle || '').trim();
await page.locator('.nutrition-knowledge').click();
await page.waitForTimeout(3000);
const navTitle = page.locator('.uni-nav-bar__content__title, .nav-bar-title, .page-title').first();
const pageTitle = await navTitle.textContent().catch(() => '');
const detailName = (pageTitle || '').trim();
const mainIsHealth = mainName.includes('健康知识');
const mainIsNutrition = mainName.includes('营养知识');
const detailIsHealth = detailName.includes('健康知识');
const detailIsNutrition = detailName.includes('营养知识');
expect(mainIsHealth || mainIsNutrition).toBe(true);
expect(detailIsHealth || detailIsNutrition).toBe(true);
expect(mainName === detailName || (mainIsHealth && detailIsHealth) || (mainIsNutrition && detailIsNutrition),
'主页区块名称与营养知识页标题应统一(均为健康知识或均为营养知识)').toBe(true);
await screenshot(page, 'tc-b06-name-consistency');
});
// ═══════════════════════════════════════════════════════════════════════════════
// BUG-007 饮食指南/科普文章详情页无内容
// ═══════════════════════════════════════════════════════════════════════════════
test('TC-B07 饮食指南和科普文章详情页有正常内容', async ({ page }) => {
await injectAuth(page, AUTH_TOKEN, AUTH_UID);
await goto(page, 'pages/tool/nutrition-knowledge', 3000);
const tabContainer = page.locator('.tab-container').first();
await tabContainer.waitFor({ state: 'visible', timeout: 10_000 });
const guideTab = page.locator('.tab-item').filter({ hasText: '饮食指南' }).first();
await guideTab.click();
await page.waitForTimeout(2000);
const guideList = page.locator('.knowledge-item');
const guideCount = await guideList.count();
if (guideCount > 0) {
await guideList.first().click();
await page.waitForTimeout(3000);
const contentArea = page.locator('.conter, .article-content, .content-scroll').first();
await expect(contentArea).toBeVisible({ timeout: 8_000 });
const contentText = await contentArea.textContent();
expect((contentText || '').trim().length, '饮食指南详情应有正文').toBeGreaterThan(50);
await expect(page.locator('.empty-placeholder').filter({ hasText: '暂无' })).toHaveCount(0);
await page.goBack();
await page.waitForTimeout(2000);
}
const articlesTab = page.locator('.tab-item').filter({ hasText: '科普文章' }).first();
await articlesTab.click();
await page.waitForTimeout(2000);
const articleList = page.locator('.knowledge-item');
const articleCount = await articleList.count();
if (articleCount > 0) {
await articleList.first().click();
await page.waitForTimeout(3000);
const contentArea2 = page.locator('.conter, .article-content, .content-scroll, .newsDetail').first();
await expect(contentArea2).toBeVisible({ timeout: 8_000 });
const contentText2 = await contentArea2.textContent();
expect((contentText2 || '').trim().length, '科普文章详情应有正文').toBeGreaterThan(50);
}
await screenshot(page, 'tc-b07-article-detail');
});
// ═══════════════════════════════════════════════════════════════════════════════
// BUG-008 打卡社区帖子营养统计数据
// ═══════════════════════════════════════════════════════════════════════════════
test('TC-B08 帖子详情页展示营养统计数据', async ({ page }) => {
await injectAuth(page, AUTH_TOKEN, AUTH_UID);
await goto(page, 'pages/tool_main/community', 3000);
const communityPage = page.locator('.community-page').first();
await communityPage.waitFor({ state: 'visible', timeout: 12_000 });
const hasPosts = await page.locator('.post-grid .post-card').first().isVisible().catch(() => false);
if (!hasPosts) {
test.skip();
return;
}
await page.locator('.post-card').first().click();
await page.waitForTimeout(3000);
const statsCard = page.locator('.nutrition-stats-card').first();
await expect(statsCard).toBeVisible({ timeout: 8_000 });
const statItems = page.locator('.stat-item');
await expect(statItems.first()).toBeVisible();
const count = await statItems.count();
expect(count).toBeGreaterThan(0);
const firstValue = await page.locator('.stat-value').first().textContent();
expect((firstValue || '').trim().length).toBeGreaterThan(0);
await screenshot(page, 'tc-b08-post-nutrition-stats');
});
// ═══════════════════════════════════════════════════════════════════════════════
// BUG-009 打卡社区帖子类型中文命名
// ═══════════════════════════════════════════════════════════════════════════════
const CHINESE_ONLY = /^[\u4e00-\u9fa5]+$/;
test('TC-B09 社区 Tab 标签和帖子类型均使用中文', async ({ page }) => {
await injectAuth(page, AUTH_TOKEN, AUTH_UID);
await goto(page, 'pages/tool_main/community', 3000);
const tabNav = page.locator('.tab-nav').first();
await tabNav.waitFor({ state: 'visible', timeout: 12_000 });
const tabTexts = page.locator('.tab-item .tab-text, .tab-item');
const tabCount = await tabTexts.count();
for (let i = 0; i < tabCount; i++) {
const text = await tabTexts.nth(i).textContent();
const label = (text || '').trim();
if (label.length > 0) {
const isChinese = CHINESE_ONLY.test(label) || /[\u4e00-\u9fa5]/.test(label);
expect(isChinese, `Tab 标签 "${label}" 应为中文`).toBe(true);
}
}
const typeTags = page.locator('.type-tag, .meal-tag');
const tagCount = await typeTags.count();
for (let i = 0; i < Math.min(tagCount, 10); i++) {
const text = await typeTags.nth(i).textContent();
const label = (text || '').trim();
if (label.length > 0) {
const hasChinese = /[\u4e00-\u9fa5]/.test(label);
expect(hasChinese, `帖子类型 "${label}" 应含中文表述`).toBe(true);
}
}
await screenshot(page, 'tc-b09-community-chinese');
});