healthapp
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

617 lines
26 KiB

/**
* 商城前端 · 真实数据端到端测试
*
* 前置条件:
* 1. 后端 API 运行中:http://localhost:8080
* 2. 商城前端运行中:http://localhost:5174
*
* 运行:node tests/mall-real.test.js
*
* 测试流程:
* 真实登录 → 首页(真实分类+商品) → 分类浏览 → 商品详情 → SKU选择 →
* 加入购物车 → 购物车操作 → 地址管理(CRUD) → 订单查看 → 会员中心
*/
const { chromium } = require('playwright');
const http = require('http');
const API_URL = 'http://localhost:8080';
const MALL_URL = 'http://localhost:5174';
const HEALTH_AI_URL = 'http://localhost:5173';
const TEST_PHONE = '13800138000';
const TEST_PASSWORD = '123456';
const testResults = { passed: 0, failed: 0, tests: [] };
function logTest(name, passed, detail = '') {
const status = passed ? '✓' : '✗';
console.log(`${status} ${name}${detail ? ': ' + detail : ''}`);
testResults.tests.push({ name, passed, detail });
if (passed) testResults.passed++;
else testResults.failed++;
}
/** HTTP 请求工具 */
function apiRequest(method, path, data, token) {
return new Promise((resolve, reject) => {
const url = new URL(path, API_URL);
const body = data ? JSON.stringify(data) : null;
const headers = { 'Content-Type': 'application/json' };
if (token) headers['Authorization'] = token;
if (body) headers['Content-Length'] = Buffer.byteLength(body);
const options = {
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers
};
const req = http.request(options, (res) => {
let raw = '';
res.on('data', (c) => raw += c);
res.on('end', () => {
try { resolve(JSON.parse(raw)); } catch { resolve(raw); }
});
});
req.on('error', reject);
if (body) req.write(body);
req.end();
});
}
// ─── 步骤 0:通过 API 获取真实 Token ───────────────────
async function getRealToken() {
console.log('\n【步骤0】通过后端 API 登录获取真实 Token...');
const resp = await apiRequest('POST', '/api/auth/login', {
phone: TEST_PHONE,
password: TEST_PASSWORD
});
const token = resp?.data?.token || resp?.token;
const user = resp?.data?.user || resp?.user;
if (!token) {
console.error(' 登录响应:', JSON.stringify(resp).slice(0, 200));
logTest('API登录', false, '未获取到 token');
return null;
}
logTest('API登录', true, `用户: ${user?.nickname || user?.phone}, Token: ${token.slice(0, 20)}...`);
return { token, user };
}
// ─── 步骤 1:首页 — 真实分类 & 商品 ───────────────────
async function testHomePageReal(page) {
console.log('\n【步骤1】验证首页 — 真实分类 & 商品数据...');
await page.goto(MALL_URL, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
await page.screenshot({ path: 'screenshots/mall-real-home.png', fullPage: true }).catch(() => {});
// 标题
const headerTitle = await page.locator('.header-title').first().textContent().catch(() => '');
logTest('首页标题', headerTitle?.includes('健康商城'), `"${headerTitle}"`);
// 分类导航 — 应该从真实 API 加载
const categoryItems = page.locator('.category-nav .category-item:not(.placeholder)');
const catCount = await categoryItems.count();
logTest('真实分类加载', catCount > 0, `加载了 ${catCount} 个分类`);
// 获取分类名称
if (catCount > 0) {
const firstCatName = await categoryItems.first().locator('.category-name').textContent().catch(() => '');
logTest('分类名称', firstCatName?.length > 0, `首个分类: "${firstCatName}"`);
}
// 热销商品 — 应该从真实 API 加载
const productCards = page.locator('.product-grid .product-card');
const productCount = await productCards.count();
logTest('真实商品加载', productCount > 0, `加载了 ${productCount} 个商品`);
// 商品名称 & 价格
if (productCount > 0) {
const firstName = await productCards.first().locator('.product-name').textContent().catch(() => '');
const firstPrice = await productCards.first().locator('.product-price').textContent().catch(() => '');
logTest('商品名称', firstName?.length > 0, `"${firstName?.trim()}"`);
logTest('商品价格', firstPrice?.includes('¥'), `${firstPrice?.trim()}`);
// 健康标签
const tags = productCards.first().locator('.product-tags .el-tag');
const tagCount = await tags.count();
logTest('健康标签', tagCount >= 0, `${tagCount} 个标签`);
}
// 体质推荐卡片
const constitutionCard = page.locator('.constitution-card');
logTest('体质推荐卡片', await constitutionCard.isVisible().catch(() => false));
// Banner
logTest('Banner轮播', await page.locator('.banner-section').isVisible().catch(() => false));
}
// ─── 步骤 2:分类页 — 浏览真实分类 ────────────────────
async function testCategoryPage(page) {
console.log('\n【步骤2】分类页 — 浏览真实分类...');
// 点击首页的第一个分类
const firstCat = page.locator('.category-nav .category-item:not(.placeholder)').first();
if (await firstCat.isVisible().catch(() => false)) {
const catName = await firstCat.locator('.category-name').textContent().catch(() => '');
await firstCat.click();
await page.waitForTimeout(2000);
await page.screenshot({ path: 'screenshots/mall-real-category.png', fullPage: true }).catch(() => {});
const url = page.url();
logTest('分类跳转', url.includes('/category/'), `点击: "${catName}", URL: ${url}`);
}
// 通过底部导航去分类页
await page.goto(MALL_URL + '/category', { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
await page.screenshot({ path: 'screenshots/mall-real-category-main.png', fullPage: true }).catch(() => {});
const title = await page.locator('.header-title').first().textContent().catch(() => '');
logTest('分类页标题', title?.includes('分类'), `"${title}"`);
}
// ─── 步骤 3:商品详情 — 真实数据 ──────────────────────
async function testProductDetailReal(page) {
console.log('\n【步骤3】商品详情 — 真实数据...');
// 回首页点击第一个商品
await page.goto(MALL_URL, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
const firstProduct = page.locator('.product-grid .product-card').first();
if (await firstProduct.isVisible().catch(() => false)) {
await firstProduct.click();
await page.waitForTimeout(3000);
await page.screenshot({ path: 'screenshots/mall-real-product-detail.png', fullPage: true }).catch(() => {});
const url = page.url();
logTest('商品详情跳转', url.includes('/product/'), `URL: ${url}`);
// 检查商品名称
const prodName = await page.locator('.product-name').first().textContent().catch(() => '');
logTest('商品详情名称', prodName?.length > 0, `"${prodName?.trim()}"`);
// 价格
const price = await page.locator('.current-price').first().textContent().catch(() => '');
logTest('商品详情价格', price?.includes('¥'), `${price?.trim()}`);
// 库存/销量
const salesRow = await page.locator('.sales-row').first().textContent().catch(() => '');
logTest('库存销量信息', salesRow?.length > 0, `${salesRow?.trim()}`);
// 体质标签
const constitutionTags = page.locator('.constitution-tags .el-tag');
const ctCount = await constitutionTags.count();
logTest('适合体质标签', ctCount > 0, `${ctCount} 个体质标签`);
// SKU 选择
const skuItems = page.locator('.sku-list .sku-item');
const skuCount = await skuItems.count();
logTest('SKU规格选项', skuCount > 0, `${skuCount} 个规格`);
// 选择第一个 SKU
if (skuCount > 0) {
const skuName = await skuItems.first().textContent().catch(() => '');
await skuItems.first().click();
await page.waitForTimeout(500);
const isActive = await skuItems.first().evaluate(el => el.classList.contains('active'));
logTest('SKU选中状态', isActive, `选中: "${skuName?.trim()}"`);
await page.screenshot({ path: 'screenshots/mall-real-sku-selected.png', fullPage: true }).catch(() => {});
}
// 功效/成分信息
const efficacy = await page.locator('.info-block').first().textContent().catch(() => '');
logTest('功效说明', efficacy?.length > 0, `${efficacy?.trim().slice(0, 50)}...`);
// 底部操作栏
const bottomBar = page.locator('.bottom-bar');
logTest('底部操作栏', await bottomBar.isVisible().catch(() => false));
const cartBtn = page.locator('.cart-btn');
logTest('加入购物车按钮', await cartBtn.isVisible().catch(() => false));
const buyBtn = page.locator('.buy-btn');
logTest('立即购买按钮', await buyBtn.isVisible().catch(() => false));
return url; // 返回详情页 URL 供后续使用
}
return null;
}
// ─── 步骤 4:加入购物车 & 购物车操作 ──────────────────
async function testCartFlow(page, token) {
console.log('\n【步骤4】加入购物车 & 购物车操作...');
// 先通过 API 清理购物车(确保干净状态)
try {
await apiRequest('DELETE', '/api/mall/cart/clear', null, token);
} catch { /* 忽略 */ }
// 在商品详情页点击"加入购物车"
const cartBtn = page.locator('.cart-btn');
if (await cartBtn.isVisible().catch(() => false)) {
await cartBtn.click();
await page.waitForTimeout(2000);
// 检查是否出现成功提示
const successMsg = page.locator('.el-message--success');
const msgVisible = await successMsg.isVisible({ timeout: 5000 }).catch(() => false);
logTest('加入购物车提示', msgVisible, msgVisible ? '已加入购物车' : '未显示成功提示');
await page.screenshot({ path: 'screenshots/mall-real-add-cart.png', fullPage: true }).catch(() => {});
}
// 导航到购物车页
await page.goto(MALL_URL + '/cart', { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
await page.screenshot({ path: 'screenshots/mall-real-cart.png', fullPage: true }).catch(() => {});
// 检查购物车中有商品
const cartItems = page.locator('.cart-item');
const cartCount = await cartItems.count();
logTest('购物车商品', cartCount > 0, `${cartCount} 个商品`);
if (cartCount > 0) {
// 商品名称
const itemName = await cartItems.first().locator('.item-name').textContent().catch(() => '');
logTest('购物车商品名称', itemName?.length > 0, `"${itemName?.trim()}"`);
// 价格
const itemPrice = await cartItems.first().locator('.item-price').textContent().catch(() => '');
logTest('购物车商品价格', itemPrice?.includes('¥'), `${itemPrice?.trim()}`);
// 数量控制
const quantityInput = cartItems.first().locator('.el-input-number');
logTest('数量控制器', await quantityInput.isVisible().catch(() => false));
// 底部结算栏
const footer = page.locator('.cart-footer');
logTest('底部结算栏', await footer.isVisible().catch(() => false));
// 合计金额
const total = await page.locator('.total-price').first().textContent().catch(() => '');
logTest('合计金额', total?.includes('¥'), `${total?.trim()}`);
// 结算按钮
const checkoutBtn = page.locator('.checkout-btn');
logTest('去结算按钮', await checkoutBtn.isVisible().catch(() => false));
// 全选
const selectAll = page.locator('.footer-left .el-checkbox');
logTest('全选按钮', await selectAll.isVisible().catch(() => false));
}
// 通过 API 清理购物车
try {
await apiRequest('DELETE', '/api/mall/cart/clear', null, token);
} catch { /* 忽略 */ }
}
// ─── 步骤 5:地址管理 — 真实 CRUD ─────────────────────
async function testAddressReal(page, token) {
console.log('\n【步骤5】地址管理 — 真实 CRUD...');
await page.goto(MALL_URL + '/address', { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
await page.screenshot({ path: 'screenshots/mall-real-address.png', fullPage: true }).catch(() => {});
// 检查现有地址
const addrItems = page.locator('.address-item');
const addrCount = await addrItems.count();
logTest('现有地址数量', true, `${addrCount} 个地址`);
// 如果有地址,验证内容
if (addrCount > 0) {
const name = await addrItems.first().locator('.addr-name').textContent().catch(() => '');
const phone = await addrItems.first().locator('.addr-phone').textContent().catch(() => '');
const detail = await addrItems.first().locator('.addr-detail').textContent().catch(() => '');
logTest('地址收件人', name?.length > 0, `"${name}"`);
logTest('地址电话', phone?.length > 0, `"${phone}"`);
logTest('地址详情', detail?.length > 0, `"${detail?.trim().slice(0, 30)}..."`);
}
// 新增地址
const addBtn = page.locator('.add-btn');
await addBtn.click();
await page.waitForTimeout(1000);
const drawer = page.locator('.el-drawer');
logTest('新增地址抽屉', await drawer.isVisible({ timeout: 3000 }).catch(() => false));
// 填写表单
const formVisible = await page.locator('.address-form').isVisible().catch(() => false);
if (formVisible) {
// 填写收件人
const nameInput = page.locator('.address-form .el-input__inner').first();
await nameInput.fill('测试收件人');
await page.waitForTimeout(200);
// 填写手机号
const phoneInput = page.locator('.address-form .el-input__inner').nth(1);
await phoneInput.fill('13912345678');
await page.waitForTimeout(200);
// 填写省市区
const provinceInput = page.locator('.address-form .el-input__inner').nth(2);
await provinceInput.fill('浙江省');
const cityInput = page.locator('.address-form .el-input__inner').nth(3);
await cityInput.fill('杭州市');
const districtInput = page.locator('.address-form .el-input__inner').nth(4);
await districtInput.fill('西湖区');
await page.waitForTimeout(200);
// 填写详细地址
const detailInput = page.locator('.address-form .el-textarea__inner').first();
await detailInput.fill('文三路100号 E2E测试地址');
await page.waitForTimeout(200);
await page.screenshot({ path: 'screenshots/mall-real-address-fill.png', fullPage: true }).catch(() => {});
// 点击保存
const saveBtn = page.locator('.save-btn');
await saveBtn.click();
await page.waitForTimeout(3000);
// 检查是否保存成功(抽屉关闭 & 列表刷新)
const drawerClosed = !(await drawer.isVisible().catch(() => true));
logTest('地址保存', drawerClosed, drawerClosed ? '抽屉已关闭' : '抽屉仍打开');
// 等待列表刷新完成 — 等 address-item 出现(如果之前没有的话)
if (addrCount === 0) {
await page.locator('.address-item').first().waitFor({ timeout: 8000 }).catch(() => {});
} else {
await page.waitForTimeout(3000);
}
await page.screenshot({ path: 'screenshots/mall-real-address-saved.png', fullPage: true }).catch(() => {});
// 列表中是否有新地址
const newAddrCount = await page.locator('.address-item').count();
logTest('地址列表更新', newAddrCount > addrCount, `之前: ${addrCount}, 现在: ${newAddrCount}`);
// 查找并删除刚创建的测试地址(通过 API 清理)
try {
const resp = await apiRequest('GET', '/api/mall/addresses', null, token);
const addresses = resp?.addresses || resp || [];
const testAddr = addresses.find(a => a.detail_addr?.includes('E2E测试地址'));
if (testAddr) {
await apiRequest('DELETE', `/api/mall/addresses/${testAddr.id}`, null, token);
logTest('测试地址清理', true, `已删除 ID: ${testAddr.id}`);
}
} catch { /* 忽略清理错误 */ }
} else {
// 取消
const cancelBtn = page.locator('.cancel-btn');
await cancelBtn.click().catch(() => {});
await page.waitForTimeout(500);
}
}
// ─── 步骤 6:订单列表 — 真实数据 ──────────────────────
async function testOrdersReal(page) {
console.log('\n【步骤6】订单列表 — 真实数据...');
await page.goto(MALL_URL + '/orders', { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
await page.screenshot({ path: 'screenshots/mall-real-orders.png', fullPage: true }).catch(() => {});
const title = await page.locator('.header-title').first().textContent().catch(() => '');
logTest('订单页标题', title?.includes('订单'), `"${title}"`);
// 检查是否有订单(可能有,可能没有)
// 查找订单列表或空状态
const pageContent = await page.textContent('body').catch(() => '');
const hasOrders = pageContent.includes('订单') || pageContent.includes('order');
logTest('订单页渲染', hasOrders, '页面正常渲染');
}
// ─── 步骤 7:会员中心 — 真实数据 ──────────────────────
async function testMemberReal(page) {
console.log('\n【步骤7】会员中心 — 真实数据...');
await page.goto(MALL_URL + '/member', { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
await page.screenshot({ path: 'screenshots/mall-real-member.png', fullPage: true }).catch(() => {});
const title = await page.locator('.header-title').first().textContent().catch(() => '');
logTest('会员页标题', title?.includes('我的'), `"${title}"`);
// 检查会员信息是否加载
const pageContent = await page.textContent('body').catch(() => '');
// 检查积分等数据是否展示
const hasPoints = pageContent.includes('积分') || pageContent.includes('会员');
logTest('会员信息加载', hasPoints, hasPoints ? '积分/会员信息已显示' : '未找到会员信息');
// TabBar 应该显示
const tabbar = page.locator('.mall-tabbar');
logTest('会员页TabBar', await tabbar.isVisible().catch(() => false));
}
// ─── 步骤 8:搜索功能 — 真实搜索 ─────────────────────
async function testSearchReal(page) {
console.log('\n【步骤8】搜索功能 — 真实搜索...');
await page.goto(MALL_URL + '/search', { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(1500);
await page.screenshot({ path: 'screenshots/mall-real-search.png', fullPage: true }).catch(() => {});
const title = await page.locator('.header-title').first().textContent().catch(() => '');
logTest('搜索页标题', title?.includes('搜索'), `"${title}"`);
// 查找搜索输入框
const searchInput = page.locator('input[type="text"], input[placeholder*="搜索"], .search-input input').first();
const inputVisible = await searchInput.isVisible().catch(() => false);
if (inputVisible) {
await searchInput.fill('养生茶');
await page.waitForTimeout(500);
// 按 Enter 或点搜索按钮
await searchInput.press('Enter');
await page.waitForTimeout(3000);
await page.screenshot({ path: 'screenshots/mall-real-search-result.png', fullPage: true }).catch(() => {});
const resultContent = await page.textContent('body').catch(() => '');
const hasResults = resultContent.includes('养生') || resultContent.includes('茶');
logTest('搜索结果', hasResults, hasResults ? '找到相关商品' : '未找到搜索结果');
} else {
logTest('搜索输入框', false, '未找到搜索输入框');
}
}
// ─── 步骤 9:完整购物流程(加购 → 结算页) ──────────
async function testCheckoutFlow(page, token) {
console.log('\n【步骤9】完整购物流程 — 加购到结算...');
// 通过 API 添加商品到购物车
try {
await apiRequest('DELETE', '/api/mall/cart/clear', null, token);
await apiRequest('POST', '/api/mall/cart', { product_id: 1, quantity: 2 }, token);
logTest('API加入购物车', true, '商品ID: 1, 数量: 2');
} catch (e) {
logTest('API加入购物车', false, e.message);
return;
}
// 打开购物车页
await page.goto(MALL_URL + '/cart', { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {});
await page.waitForTimeout(2000);
await page.screenshot({ path: 'screenshots/mall-real-cart-with-items.png', fullPage: true }).catch(() => {});
// 验证购物车有商品
const cartItems = page.locator('.cart-item');
const cartCount = await cartItems.count();
logTest('购物车有商品', cartCount > 0, `${cartCount} 个商品`);
if (cartCount > 0) {
// 点击去结算
const checkoutBtn = page.locator('.checkout-btn');
if (await checkoutBtn.isVisible().catch(() => false)) {
// 先勾选商品(如果未勾选)
const checkbox = cartItems.first().locator('.el-checkbox');
if (await checkbox.isVisible().catch(() => false)) {
// 检查是否已选中
const checked = await checkbox.evaluate(el =>
el.querySelector('.el-checkbox__input')?.classList.contains('is-checked')
).catch(() => false);
if (!checked) {
await checkbox.click();
await page.waitForTimeout(1000);
}
}
await checkoutBtn.click();
await page.waitForTimeout(3000);
await page.screenshot({ path: 'screenshots/mall-real-checkout.png', fullPage: true }).catch(() => {});
const checkoutUrl = page.url();
logTest('跳转结算页', checkoutUrl.includes('/checkout'), `URL: ${checkoutUrl}`);
// 结算页面验证
if (checkoutUrl.includes('/checkout')) {
const checkoutTitle = await page.locator('.page-title, .header-title').first().textContent().catch(() => '');
logTest('结算页标题', checkoutTitle?.includes('确认') || checkoutTitle?.includes('订单'), `"${checkoutTitle}"`);
// 地址卡片
const addrCard = page.locator('.address-card');
logTest('收货地址卡片', await addrCard.isVisible().catch(() => false));
}
}
}
// 清理购物车
try {
await apiRequest('DELETE', '/api/mall/cart/clear', null, token);
} catch { /* 忽略 */ }
}
// ─── 主入口 ──────────────────────────────────────────
async function runTests() {
console.log('═══════════════════════════════════════════════════════════');
console.log(' 商城前端 · 真实数据端到端测试');
console.log('═══════════════════════════════════════════════════════════');
console.log(`后端 API: ${API_URL}`);
console.log(`商城前端: ${MALL_URL}`);
console.log(`时间: ${new Date().toLocaleString()}\n`);
let browser;
try {
// ── 获取真实 Token ──
const auth = await getRealToken();
if (!auth) {
console.error('❌ 无法获取 Token,请确认后端已启动');
process.exit(1);
}
browser = await chromium.launch({
headless: false,
args: ['--no-sandbox']
});
const context = await browser.newContext({
viewport: { width: 750, height: 1334 },
userAgent: 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1'
});
// 注入真实 Token
await context.addInitScript(({ token, user }) => {
try {
localStorage.setItem('token', token);
localStorage.setItem('user', JSON.stringify(user));
} catch { /* ignore */ }
}, { token: auth.token, user: auth.user });
const page = await context.newPage();
page.setDefaultTimeout(10000);
// 记录 API 请求/响应用于调试
const apiLogs = [];
page.on('response', async (resp) => {
const url = resp.url();
if (url.includes('/api/mall/')) {
const status = resp.status();
apiLogs.push({ url: url.replace(API_URL, ''), status });
if (status >= 400) {
console.log(` ⚠ API ${status}: ${url.replace(API_URL, '')}`);
}
}
});
// 执行测试
await testHomePageReal(page);
await testCategoryPage(page);
await testProductDetailReal(page);
await testCartFlow(page, auth.token);
await testAddressReal(page, auth.token);
await testOrdersReal(page);
await testMemberReal(page);
await testSearchReal(page);
await testCheckoutFlow(page, auth.token);
// 输出 API 请求汇总
console.log(`\n API 请求汇总: 共 ${apiLogs.length} 次, 失败: ${apiLogs.filter(l => l.status >= 400).length}`);
} catch (err) {
console.error('\n❌ 测试执行出错:', err.message);
logTest('测试执行', false, err.message);
} finally {
if (browser) await browser.close();
}
// ── 汇总 ──
console.log('\n═══════════════════════════════════════════════════════════');
console.log(' 测试结果摘要');
console.log('═══════════════════════════════════════════════════════════');
console.log(`通过: ${testResults.passed} 失败: ${testResults.failed}`);
console.log('───────────────────────────────────────────────────────────');
for (const t of testResults.tests) {
console.log(`${t.passed ? '✓' : '✗'} ${t.name}${t.detail ? ' - ' + t.detail : ''}`);
}
console.log('═══════════════════════════════════════════════════════════');
process.exit(testResults.failed > 0 ? 1 : 0);
}
runTests();