/** * 商城前端(mall)自动化测试脚本 * 认证策略:公开浏览(首页/分类/商品/搜索)→ 需登录操作(购物车/订单/支付) * 测试流程: * Phase A: 未登录公开浏览(首页、分类、商品详情、搜索) * Phase B: 未登录访问受保护页面(重定向到登录页) * Phase C: 登录后完整功能(购物车、订单、地址、会员) * * 运行方式: * cd tests * node mall.test.js * * 前置条件: * 1. mall 项目运行中:cd mall && npm run dev (http://localhost:5174) * 2. 后端 API 运行中(可选,部分测试不依赖后端数据) */ const { chromium } = require('playwright'); const MALL_URL = 'http://localhost:5174'; const HEALTH_AI_URL = 'http://localhost:5173'; // 模拟 token 和用户信息(与健康助手共享的 localStorage 数据) const MOCK_TOKEN = 'test-token-for-mall-e2e'; const MOCK_USER = JSON.stringify({ id: 1, phone: '13800138000', nickname: '测试用户', avatar: '', surveyCompleted: true }); // 测试结果统计 const testResults = { passed: 0, failed: 0, tests: [] }; function logTest(name, passed, detail = '') { const status = passed ? '✓' : '✗'; const msg = `${status} ${name}${detail ? ': ' + detail : ''}`; console.log(msg); testResults.tests.push({ name, passed, detail }); if (passed) testResults.passed++; else testResults.failed++; } /** * 设置登录态(通过 addInitScript 在页面加载前注入 localStorage) */ async function setupAuth(context, page) { console.log('\n【步骤0】设置登录态 (localStorage)...'); // 使用 addInitScript 在页面脚本执行前注入 token,避免路由守卫重定向 await context.addInitScript(({ token, user }) => { try { localStorage.setItem('token', token); localStorage.setItem('user', user); } catch (e) { // about:blank 等页面无法访问 localStorage } }, { token: MOCK_TOKEN, user: MOCK_USER }); logTest('设置登录态', true, '已通过 addInitScript 注入 token 和 user'); } /** * Mock 后端 API 响应(避免 401 重定向和网络错误) */ async function setupApiMocks(page) { console.log(' 设置 API Mock...'); // 拦截所有 /api/mall/* 请求,返回合理的 mock 数据 await page.route('**/api/mall/cart', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [], total_count: 0, selected_count: 0, total_amount: 0 }) }); }); await page.route('**/api/mall/categories', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ categories: [ { id: 1, name: '保健滋补', parent_id: 0, icon: '🫚', description: '', sort: 1 }, { id: 2, name: '养生茶饮', parent_id: 0, icon: '🍵', description: '', sort: 2 }, { id: 3, name: '草本膳食', parent_id: 0, icon: '🌿', description: '', sort: 3 }, { id: 4, name: '运动营养', parent_id: 0, icon: '💪', description: '', sort: 4 }, { id: 5, name: '母婴健康', parent_id: 0, icon: '👶', description: '', sort: 5 }, { id: 6, name: '个护清洁', parent_id: 0, icon: '🧴', description: '', sort: 6 } ] }) }); }); await page.route('**/api/mall/products/featured*', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ products: [ { id: 1, name: '枸杞红枣养生茶', description: '天然滋补好茶', main_image: '', price: 49.90, original_price: 68.00, sales_count: 235, stock: 100, is_featured: true, constitution_types: ['qixu'], health_tags: ['补气养血'] }, { id: 2, name: '黄芪片', description: '精选优质黄芪', main_image: '', price: 39.90, original_price: 52.00, sales_count: 128, stock: 50, is_featured: true, constitution_types: ['yangxu'], health_tags: ['补气固表'] } ] }) }); }); await page.route('**/api/mall/products/search*', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ products: [], total: 0, page: 1, page_size: 20 }) }); }); await page.route('**/api/mall/products/constitution-recommend*', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ products: [] }) }); }); await page.route('**/api/mall/products/*', (route) => { if (route.request().method() === 'GET') { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ id: 1, category_id: 1, name: '枸杞红枣养生茶', description: '精选优质枸杞红枣', main_image: '', images: [], price: 49.90, original_price: 68.00, stock: 100, sales_count: 235, is_featured: true, constitution_types: ['qixu'], health_tags: ['补气养血', '滋阴润燥'], efficacy: '补气养血', ingredients: '枸杞、红枣', usage: '每日一杯', contraindications: '孕妇慎用', skus: [] }) }); } else { route.continue(); } }); await page.route('**/api/mall/products', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ products: [], total: 0, page: 1, page_size: 20 }) }); }); await page.route('**/api/mall/addresses*', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) }); }); await page.route('**/api/mall/orders/preview', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ items: [{ id: 1, product_id: 1, sku_id: 0, product_name: '枸杞红枣养生茶', sku_name: '', image: '', price: 49.90, quantity: 1, selected: true, stock: 100 }], total_amount: 49.90, discount_amount: 0, shipping_fee: 0, pay_amount: 49.90, max_points_use: 0, points_discount: 0 }) }); }); await page.route('**/api/mall/orders*', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ orders: [], total: 0, page: 1, page_size: 20 }) }); }); await page.route('**/api/mall/member/info', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ level: 'normal', level_name: '普通会员', total_spent: 0, points: 100, member_since: '2026-01-01', next_level: 'silver', next_level_spent: 500, discount: 1.0, points_multiplier: 1, free_shipping_min: 99 }) }); }); await page.route('**/api/mall/member/points/records*', (route) => { route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ records: [], total: 0, page: 1, page_size: 20 }) }); }); logTest('API Mock 设置', true); } /** * 测试 1:商城首页 */ async function testHomePage(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-home.png', fullPage: true }).catch(() => {}); // 检查页面标题/header const headerTitle = await page.locator('.header-title').first().textContent().catch(() => ''); logTest('首页标题', headerTitle?.includes('健康商城'), `标题: "${headerTitle}"`); // 检查搜索栏 const searchBar = page.locator('.search-bar'); const searchVisible = await searchBar.isVisible().catch(() => false); logTest('搜索栏', searchVisible); // 检查 Banner 轮播 const bannerSection = page.locator('.banner-section'); const bannerVisible = await bannerSection.isVisible().catch(() => false); logTest('Banner轮播', bannerVisible); // 检查体质推荐卡片 const constitutionCard = page.locator('.constitution-card'); const constitutionVisible = await constitutionCard.isVisible().catch(() => false); logTest('体质推荐卡片', constitutionVisible); // 检查底部TabBar const tabbar = page.locator('.mall-tabbar'); const tabbarVisible = await tabbar.isVisible().catch(() => false); logTest('底部TabBar', tabbarVisible); // 检查TabBar包含4个Tab const tabItems = page.locator('.mall-tabbar .tab-item'); const tabCount = await tabItems.count(); logTest('TabBar项数量', tabCount === 4, `实际: ${tabCount}`); // 检查AI咨询悬浮按钮 const aiBtn = page.locator('.ai-float-btn'); const aiBtnVisible = await aiBtn.isVisible().catch(() => false); logTest('AI咨询悬浮按钮', aiBtnVisible); // 检查热销推荐标题 const sectionTitle = await page.locator('.section-title').first().textContent().catch(() => ''); logTest('热销推荐区域', sectionTitle?.includes('热销'), `标题: "${sectionTitle}"`); } /** * 测试 2:底部Tab导航 */ async function testTabNavigation(page) { console.log('\n【步骤2】测试底部Tab导航...'); // 点击 "分类" Tab const categoryTab = page.locator('.mall-tabbar .tab-item').nth(1); await categoryTab.click(); await page.waitForTimeout(1500); const currentUrl1 = page.url(); logTest('导航到分类页', currentUrl1.includes('/category'), `URL: ${currentUrl1}`); await page.screenshot({ path: 'screenshots/mall-category.png', fullPage: true }).catch(() => {}); // 检查分类页标题更新 const categoryTitle = await page.locator('.header-title').first().textContent().catch(() => ''); logTest('分类页标题', categoryTitle?.includes('分类'), `标题: "${categoryTitle}"`); // 点击 "购物车" Tab const cartTab = page.locator('.mall-tabbar .tab-item').nth(2); await cartTab.click(); await page.waitForTimeout(2000); const currentUrl2 = page.url(); logTest('导航到购物车页', currentUrl2.includes('/cart'), `URL: ${currentUrl2}`); await page.screenshot({ path: 'screenshots/mall-cart.png', fullPage: true }).catch(() => {}); // 检查购物车页面标题 const cartTitle = await page.locator('.header-title').first().textContent({ timeout: 3000 }).catch(() => ''); logTest('购物车页标题', cartTitle?.includes('购物车'), `标题: "${cartTitle}"`); // 购物车为空时应显示空状态(mock 返回空购物车) const emptyState = page.locator('.cart-empty, .el-empty'); const emptyVisible = await emptyState.first().isVisible({ timeout: 5000 }).catch(() => false); logTest('购物车空状态', emptyVisible, emptyVisible ? '显示空购物车' : '有商品或未显示'); // 点击 "我的" Tab const memberTab = page.locator('.mall-tabbar .tab-item').nth(3); await memberTab.click(); await page.waitForTimeout(1500); const currentUrl3 = page.url(); logTest('导航到会员页', currentUrl3.includes('/member'), `URL: ${currentUrl3}`); await page.screenshot({ path: 'screenshots/mall-member.png', fullPage: true }).catch(() => {}); // 回到首页 const homeTab = page.locator('.mall-tabbar .tab-item').nth(0); await homeTab.click(); await page.waitForTimeout(1500); const currentUrl4 = page.url(); logTest('回到首页', currentUrl4.endsWith('/') || currentUrl4 === MALL_URL || currentUrl4 === MALL_URL + '/', `URL: ${currentUrl4}`); } /** * 测试 3:搜索页导航 */ async function testSearchNavigation(page) { console.log('\n【步骤3】测试搜索页导航...'); // 点击搜索栏 const searchBar = page.locator('.search-bar'); await searchBar.click(); await page.waitForTimeout(1500); const currentUrl = page.url(); logTest('搜索栏跳转', currentUrl.includes('/search'), `URL: ${currentUrl}`); await page.screenshot({ path: 'screenshots/mall-search.png', fullPage: true }).catch(() => {}); // 检查搜索页标题 const searchTitle = await page.locator('.header-title').first().textContent().catch(() => ''); logTest('搜索页标题', searchTitle?.includes('搜索'), `标题: "${searchTitle}"`); // 检查有返回箭头(非 tabBar 页面) const backBtn = page.locator('.back-btn'); const backVisible = await backBtn.isVisible().catch(() => false); logTest('搜索页返回按钮', backVisible); // 返回首页 await backBtn.click().catch(() => {}); await page.waitForTimeout(1000); } /** * 测试 4:Header 组件 */ async function testHeaderComponents(page) { console.log('\n【步骤4】测试Header组件...'); await page.goto(MALL_URL, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {}); await page.waitForTimeout(1000); // 检查 header 右侧搜索图标 const headerSearchIcon = page.locator('.header-right .header-icon').first(); const searchIconVisible = await headerSearchIcon.isVisible().catch(() => false); logTest('Header搜索图标', searchIconVisible); // 点击 header 搜索图标 await headerSearchIcon.click(); await page.waitForTimeout(1000); const urlAfterSearch = page.url(); logTest('Header搜索图标跳转', urlAfterSearch.includes('/search'), `URL: ${urlAfterSearch}`); // 回到首页 await page.goto(MALL_URL, { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {}); await page.waitForTimeout(1000); // 检查 header 购物车图标 const headerCartBadge = page.locator('.cart-badge'); const cartBadgeVisible = await headerCartBadge.isVisible().catch(() => false); logTest('Header购物车图标', cartBadgeVisible); // 点击购物车图标应跳转到购物车 await headerCartBadge.click(); await page.waitForTimeout(1000); const urlAfterCart = page.url(); logTest('Header购物车跳转', urlAfterCart.includes('/cart'), `URL: ${urlAfterCart}`); } /** * 测试 5:收货地址页 */ async function testAddressPage(page) { console.log('\n【步骤5】测试收货地址页...'); await page.goto(MALL_URL + '/address', { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {}); await page.waitForTimeout(2000); await page.screenshot({ path: 'screenshots/mall-address.png', fullPage: true }).catch(() => {}); // 检查页面标题 const title = await page.locator('.header-title').first().textContent().catch(() => ''); logTest('地址页标题', title?.includes('地址'), `标题: "${title}"`); // 检查返回按钮(非tabBar页面) const backBtn = page.locator('.back-btn'); const backVisible = await backBtn.isVisible().catch(() => false); logTest('地址页返回按钮', backVisible); // 检查底部添加按钮 const addBtn = page.locator('.add-btn'); const addBtnVisible = await addBtn.isVisible().catch(() => false); logTest('新增地址按钮', addBtnVisible); // 点击新增地址按钮,弹出抽屉 if (addBtnVisible) { await addBtn.click(); await page.waitForTimeout(1000); // 检查抽屉弹出 const drawer = page.locator('.el-drawer'); const drawerVisible = await drawer.isVisible({ timeout: 3000 }).catch(() => false); logTest('新增地址抽屉弹出', drawerVisible); await page.screenshot({ path: 'screenshots/mall-address-drawer.png', fullPage: true }).catch(() => {}); // 检查表单字段 const formItems = page.locator('.address-form .el-form-item'); const formCount = await formItems.count(); logTest('地址表单字段', formCount >= 6, `字段数: ${formCount}`); // 关闭抽屉 const cancelBtn = page.locator('.cancel-btn'); if (await cancelBtn.isVisible().catch(() => false)) { await cancelBtn.click(); await page.waitForTimeout(500); } } } /** * 测试 6:订单页 */ async function testOrdersPage(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-orders.png', fullPage: true }).catch(() => {}); // 检查页面标题 const title = await page.locator('.header-title').first().textContent().catch(() => ''); logTest('订单页标题', title?.includes('订单'), `标题: "${title}"`); // 检查返回按钮 const backBtn = page.locator('.back-btn'); const backVisible = await backBtn.isVisible().catch(() => false); logTest('订单页返回按钮', backVisible); } /** * 测试 7A:未登录公开浏览(应能正常访问) */ async function testPublicBrowsing(browser) { console.log('\n【步骤7A】测试未登录公开浏览...'); const cleanContext = 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' }); const cleanPage = await cleanContext.newPage(); // Mock API(公开页面也需要获取数据) await setupApiMocks(cleanPage); try { // 首页 —— 应能正常访问 await cleanPage.goto(MALL_URL, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {}); await cleanPage.waitForTimeout(2000); let currentUrl = cleanPage.url(); let notRedirected = !currentUrl.includes('/login'); logTest('未登录访问首页', notRedirected, `URL: ${currentUrl}`); await cleanPage.screenshot({ path: 'screenshots/mall-public-home.png', fullPage: true }).catch(() => {}); // 分类页 —— 应能正常访问 await cleanPage.goto(MALL_URL + '/category', { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {}); await cleanPage.waitForTimeout(1000); currentUrl = cleanPage.url(); notRedirected = currentUrl.includes('/category'); logTest('未登录访问分类页', notRedirected, `URL: ${currentUrl}`); // 商品详情页 —— 应能正常访问 await cleanPage.goto(MALL_URL + '/product/1', { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {}); await cleanPage.waitForTimeout(1000); currentUrl = cleanPage.url(); notRedirected = currentUrl.includes('/product/1'); logTest('未登录访问商品详情', notRedirected, `URL: ${currentUrl}`); await cleanPage.screenshot({ path: 'screenshots/mall-public-product.png', fullPage: true }).catch(() => {}); // 搜索页 —— 应能正常访问 await cleanPage.goto(MALL_URL + '/search', { waitUntil: 'networkidle', timeout: 10000 }).catch(() => {}); await cleanPage.waitForTimeout(1000); currentUrl = cleanPage.url(); notRedirected = currentUrl.includes('/search'); logTest('未登录访问搜索页', notRedirected, `URL: ${currentUrl}`); } finally { await cleanContext.close(); } } /** * 测试 7B:未登录访问受保护页面(应重定向到登录页) */ async function testAuthGuard(browser) { console.log('\n【步骤7B】测试路由守卫(受保护页面重定向)...'); const cleanContext = 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' }); const cleanPage = await cleanContext.newPage(); try { const protectedRoutes = [ { path: '/cart', name: '购物车' }, { path: '/orders', name: '订单列表' }, { path: '/member', name: '会员页' }, { path: '/address', name: '收货地址' }, { path: '/checkout?cart_item_ids=1', name: '确认订单' } ]; for (const route of protectedRoutes) { await cleanPage.goto(MALL_URL + route.path, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {}); await cleanPage.waitForTimeout(2000); const currentUrl = cleanPage.url(); const redirectedToLogin = currentUrl.includes('/login'); logTest(`未登录→${route.name}重定向`, redirectedToLogin, `URL: ${currentUrl}`); } await cleanPage.screenshot({ path: 'screenshots/mall-auth-redirect.png', fullPage: true }).catch(() => {}); } finally { await cleanContext.close(); } } /** * 测试 8:页面响应式布局 */ async function testResponsiveLayout(page) { console.log('\n【步骤8】测试移动端响应式布局...'); // 设置移动端视口 await page.setViewportSize({ width: 375, height: 812 }); await page.goto(MALL_URL, { waitUntil: 'networkidle', timeout: 15000 }).catch(() => {}); await page.waitForTimeout(1500); await page.screenshot({ path: 'screenshots/mall-mobile-375.png', fullPage: true }).catch(() => {}); // 检查元素在小屏幕上仍然可见 const searchBarVisible = await page.locator('.search-bar').isVisible().catch(() => false); logTest('375px宽搜索栏', searchBarVisible); const tabbarVisible = await page.locator('.mall-tabbar').isVisible().catch(() => false); logTest('375px宽TabBar', tabbarVisible); // 设置更宽的视口 await page.setViewportSize({ width: 750, height: 1024 }); await page.waitForTimeout(500); await page.screenshot({ path: 'screenshots/mall-mobile-750.png', fullPage: true }).catch(() => {}); const searchBarVisible2 = await page.locator('.search-bar').isVisible().catch(() => false); logTest('750px宽搜索栏', searchBarVisible2); // 恢复默认 await page.setViewportSize({ width: 750, height: 1334 }); } /** * 测试 9:直接 URL 访问各路由 */ async function testDirectRouteAccess(page) { console.log('\n【步骤9】测试直接URL访问各路由...'); const routes = [ { path: '/', name: '首页', titleCheck: '健康商城' }, { path: '/category', name: '分类页', titleCheck: '分类' }, { path: '/cart', name: '购物车', titleCheck: '购物车' }, { path: '/member', name: '会员页', titleCheck: '我的' }, { path: '/search', name: '搜索页', titleCheck: '搜索' }, { path: '/address', name: '地址管理', titleCheck: '地址' }, { path: '/orders', name: '订单列表', titleCheck: '订单' }, { path: '/product/1', name: '商品详情', titleCheck: '商品详情' }, { path: '/checkout?cart_item_ids=1', name: '确认订单', titleCheck: '确认订单' }, ]; for (const route of routes) { await page.goto(MALL_URL + route.path, { waitUntil: 'domcontentloaded', timeout: 10000 }).catch(() => {}); await page.waitForTimeout(800); // 检查是否在 mall 域内(未被重定向到其他站点) const currentUrl = page.url(); const stayedOnMall = currentUrl.startsWith(MALL_URL); // 检查标题 const title = await page.locator('.header-title').first().textContent().catch(() => ''); const titleMatches = title?.includes(route.titleCheck); logTest(`路由[${route.name}]`, stayedOnMall && titleMatches, `URL: ${currentUrl}, 标题: "${title}"`); } } /** * 主测试入口 */ async function runTests() { console.log('═══════════════════════════════════════════════════════════'); console.log(' 商城前端 (mall) 自动化测试'); console.log('═══════════════════════════════════════════════════════════'); console.log(`目标地址: ${MALL_URL}`); console.log(`时间: ${new Date().toLocaleString()}\n`); let browser; try { 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' }); const page = await context.newPage(); page.setDefaultTimeout(10000); // 阶段 0:设置登录态 & API Mock await setupAuth(context, page); await setupApiMocks(page); // 阶段 1:商城首页 await testHomePage(page); // 阶段 2:底部Tab导航 await testTabNavigation(page); // 阶段 3:搜索页导航 await testSearchNavigation(page); // 阶段 4:Header组件 await testHeaderComponents(page); // 阶段 5:收货地址页 await testAddressPage(page); // 阶段 6:订单页 await testOrdersPage(page); // 阶段 7A:未登录公开浏览 await testPublicBrowsing(browser); // 阶段 7B:路由守卫(受保护页面重定向) await testAuthGuard(browser); // 阶段 8:响应式布局 await testResponsiveLayout(page); // 阶段 9:直接URL访问 await testDirectRouteAccess(page); } 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();