@ -0,0 +1,862 @@ |
|||||
|
/** |
||||
|
* 问答页对话管理与流式输出自动化测试脚本 |
||||
|
* 测试流程:登录 → 进入问答Tab → 创建对话 → 发送消息 → 验证流式输出 → 对话管理 |
||||
|
*/ |
||||
|
const { chromium } = require('playwright'); |
||||
|
|
||||
|
const APP_URL = 'http://localhost:8081'; |
||||
|
const TEST_PHONE = '13800138000'; |
||||
|
const TEST_CODE = '123456'; |
||||
|
|
||||
|
// 测试结果统计
|
||||
|
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++; |
||||
|
} |
||||
|
|
||||
|
async function login(page) { |
||||
|
console.log('\n【步骤1】检查登录状态...'); |
||||
|
|
||||
|
const loginBtn = page.locator('text=登录').first(); |
||||
|
if (!(await loginBtn.isVisible({ timeout: 2000 }).catch(() => false))) { |
||||
|
logTest('已登录状态', true, '跳过登录流程'); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
console.log(' 执行登录流程...'); |
||||
|
|
||||
|
// 输入手机号
|
||||
|
const phoneInput = page.locator('input').first(); |
||||
|
await phoneInput.fill(TEST_PHONE); |
||||
|
await page.waitForTimeout(300); |
||||
|
|
||||
|
// 获取验证码
|
||||
|
const getCodeBtn = page.locator('text=获取验证码').first(); |
||||
|
if (await getCodeBtn.isVisible()) { |
||||
|
await getCodeBtn.click(); |
||||
|
await page.waitForTimeout(1000); |
||||
|
} |
||||
|
|
||||
|
// 输入验证码
|
||||
|
const codeInput = page.locator('input').nth(1); |
||||
|
await codeInput.fill(TEST_CODE); |
||||
|
await page.waitForTimeout(300); |
||||
|
|
||||
|
// 点击登录
|
||||
|
await loginBtn.click(); |
||||
|
await page.waitForTimeout(2000); |
||||
|
|
||||
|
logTest('登录', true); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
async function navigateToChatTab(page) { |
||||
|
console.log('\n【步骤2】导航到问答Tab...'); |
||||
|
|
||||
|
// 点击问答Tab
|
||||
|
const chatTabPos = await page.evaluate(() => { |
||||
|
const allElements = document.querySelectorAll('*'); |
||||
|
for (const el of allElements) { |
||||
|
if (el.textContent?.trim() === '问答' && el.children.length === 0) { |
||||
|
const rect = el.getBoundingClientRect(); |
||||
|
if (rect.width > 0 && rect.height > 0) { |
||||
|
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return null; |
||||
|
}); |
||||
|
|
||||
|
if (chatTabPos) { |
||||
|
await page.mouse.click(chatTabPos.x, chatTabPos.y); |
||||
|
await page.waitForTimeout(2000); |
||||
|
logTest('导航到问答页面', true); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
logTest('导航到问答页面', false, '未找到问答Tab'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// 确保进入到具体对话页面(ChatDetailScreen)
|
||||
|
async function ensureInChatDetail(page) { |
||||
|
console.log('\n【步骤2.1】检查是否在对话详情页...'); |
||||
|
|
||||
|
// 检查是否已在 ChatDetailScreen(有"对话管理"按钮)
|
||||
|
const inDetail = await page.evaluate(() => { |
||||
|
return document.body.innerText.includes('对话管理') && document.body.innerText.includes('AI健康助手'); |
||||
|
}); |
||||
|
|
||||
|
if (inDetail) { |
||||
|
logTest('已在对话详情页', true); |
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
// 如果在 ChatListScreen,需要创建新对话
|
||||
|
console.log(' 当前在对话列表页,尝试创建新对话...'); |
||||
|
|
||||
|
// 优先点击"开始对话"按钮(空状态时显示)
|
||||
|
let created = await page.evaluate(() => { |
||||
|
const allElements = document.querySelectorAll('*'); |
||||
|
for (const el of allElements) { |
||||
|
const text = el.textContent?.trim(); |
||||
|
if (text === '开始对话' && el.children.length === 0) { |
||||
|
const rect = el.getBoundingClientRect(); |
||||
|
if (rect.width > 0 && rect.height > 0) { |
||||
|
el.click(); |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return false; |
||||
|
}); |
||||
|
|
||||
|
if (!created) { |
||||
|
// 备用:点击 FAB "新建对话"按钮
|
||||
|
const fabBtn = page.locator('text=新建对话').first(); |
||||
|
if (await fabBtn.isVisible({ timeout: 2000 }).catch(() => false)) { |
||||
|
await fabBtn.click(); |
||||
|
created = true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
if (created) { |
||||
|
await page.waitForTimeout(2000); |
||||
|
|
||||
|
// 再次检查是否进入了详情页
|
||||
|
const nowInDetail = await page.evaluate(() => { |
||||
|
return document.body.innerText.includes('对话管理') || document.body.innerText.includes('AI健康助手'); |
||||
|
}); |
||||
|
|
||||
|
logTest('创建新对话进入详情页', nowInDetail); |
||||
|
return nowInDetail; |
||||
|
} |
||||
|
|
||||
|
logTest('进入对话详情页', false, '无法创建对话'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
async function testConversationManagement(page) { |
||||
|
console.log('\n【步骤3】测试对话管理功能...'); |
||||
|
|
||||
|
// 截图当前状态
|
||||
|
await page.screenshot({ path: 'tests/screenshots/chat-page.png' }); |
||||
|
|
||||
|
// 检查页面类型
|
||||
|
const pageType = await page.evaluate(() => { |
||||
|
const text = document.body.innerText; |
||||
|
if (text.includes('对话管理')) return 'detail'; |
||||
|
if (text.includes('历史记录')) return 'list'; |
||||
|
return 'unknown'; |
||||
|
}); |
||||
|
|
||||
|
console.log(` 当前页面类型: ${pageType}`); |
||||
|
|
||||
|
// 根据页面类型处理
|
||||
|
if (pageType === 'detail') { |
||||
|
// 在详情页,点击"对话管理"按钮
|
||||
|
const manageBtnPos = await page.evaluate(() => { |
||||
|
const allElements = document.querySelectorAll('*'); |
||||
|
for (const el of allElements) { |
||||
|
if (el.textContent?.trim() === '对话管理' && el.children.length === 0) { |
||||
|
const rect = el.getBoundingClientRect(); |
||||
|
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; |
||||
|
} |
||||
|
} |
||||
|
return null; |
||||
|
}); |
||||
|
|
||||
|
if (!manageBtnPos) { |
||||
|
logTest('对话管理按钮显示', false, '未找到对话管理按钮'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
logTest('对话管理按钮显示', true); |
||||
|
|
||||
|
await page.mouse.click(manageBtnPos.x, manageBtnPos.y); |
||||
|
await page.waitForTimeout(1000); |
||||
|
|
||||
|
// 验证对话管理弹窗打开(详情页弹窗有"+ 新建对话")
|
||||
|
const modalOpened = await page.evaluate(() => { |
||||
|
return document.body.innerText.includes('+ 新建对话'); |
||||
|
}); |
||||
|
|
||||
|
logTest('对话管理弹窗打开', modalOpened); |
||||
|
|
||||
|
if (!modalOpened) return true; // 没有弹窗也继续
|
||||
|
|
||||
|
await page.screenshot({ path: 'tests/screenshots/chat-management-modal.png' }); |
||||
|
|
||||
|
// 关闭弹窗 - 点击关闭按钮(X)或背景
|
||||
|
const closed = await page.evaluate(() => { |
||||
|
// 尝试点击关闭按钮
|
||||
|
const closeBtn = document.querySelector('[aria-label="Close modal"]') || |
||||
|
document.querySelector('button[icon="close"]'); |
||||
|
if (closeBtn) { |
||||
|
closeBtn.click(); |
||||
|
return true; |
||||
|
} |
||||
|
return false; |
||||
|
}); |
||||
|
|
||||
|
if (!closed) { |
||||
|
// 备用:按 Escape
|
||||
|
await page.keyboard.press('Escape'); |
||||
|
} |
||||
|
await page.waitForTimeout(800); |
||||
|
|
||||
|
// 再次检查弹窗是否关闭
|
||||
|
const stillOpen = await page.evaluate(() => { |
||||
|
return document.body.innerText.includes('+ 新建对话'); |
||||
|
}); |
||||
|
|
||||
|
if (stillOpen) { |
||||
|
// 再试一次:点击弹窗外部
|
||||
|
await page.mouse.click(50, 50); |
||||
|
await page.waitForTimeout(500); |
||||
|
} |
||||
|
|
||||
|
return true; |
||||
|
|
||||
|
} else if (pageType === 'list') { |
||||
|
// 在列表页,先点击"开始对话"按钮创建新对话(最直接的方式)
|
||||
|
const startChatBtn = await page.evaluate(() => { |
||||
|
const allElements = document.querySelectorAll('*'); |
||||
|
for (const el of allElements) { |
||||
|
if (el.textContent?.trim() === '开始对话' && el.children.length === 0) { |
||||
|
const rect = el.getBoundingClientRect(); |
||||
|
if (rect.width > 0 && rect.height > 0 && rect.width < 200) { |
||||
|
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return null; |
||||
|
}); |
||||
|
|
||||
|
if (startChatBtn) { |
||||
|
logTest('开始对话按钮显示', true); |
||||
|
await page.mouse.click(startChatBtn.x, startChatBtn.y); |
||||
|
console.log(' 点击开始对话按钮,等待页面跳转...'); |
||||
|
await page.waitForTimeout(3000); |
||||
|
|
||||
|
// 验证是否已进入详情页
|
||||
|
const inDetailNow = await page.evaluate(() => { |
||||
|
const text = document.body.innerText; |
||||
|
return text.includes('AI健康助手') || text.includes('对话管理'); |
||||
|
}); |
||||
|
|
||||
|
if (inDetailNow) { |
||||
|
logTest('创建新对话', true); |
||||
|
logTest('对话管理按钮显示', true, '进入详情页'); |
||||
|
await page.screenshot({ path: 'tests/screenshots/chat-detail-entered.png' }); |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 备用方案:点击右下角 FAB 按钮
|
||||
|
console.log(' 尝试点击 FAB 新建对话按钮...'); |
||||
|
const fabBtn = await page.evaluate(() => { |
||||
|
const allElements = document.querySelectorAll('*'); |
||||
|
for (const el of allElements) { |
||||
|
const text = el.textContent?.trim(); |
||||
|
// FAB 按钮显示"+ 新建对话"或类似
|
||||
|
if ((text === '新建对话' || text === '+ 新建对话') && el.children.length <= 2) { |
||||
|
const rect = el.getBoundingClientRect(); |
||||
|
// FAB 通常在右下角
|
||||
|
if (rect.width > 0 && rect.height > 0 && rect.y > 400) { |
||||
|
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return null; |
||||
|
}); |
||||
|
|
||||
|
if (fabBtn) { |
||||
|
await page.mouse.click(fabBtn.x, fabBtn.y); |
||||
|
console.log(' 点击 FAB 按钮,等待页面跳转...'); |
||||
|
await page.waitForTimeout(3000); |
||||
|
|
||||
|
const inDetailNow = await page.evaluate(() => { |
||||
|
const text = document.body.innerText; |
||||
|
return text.includes('AI健康助手') || text.includes('对话管理'); |
||||
|
}); |
||||
|
|
||||
|
if (inDetailNow) { |
||||
|
logTest('开始对话按钮显示', false, 'FAB 方式'); |
||||
|
logTest('创建新对话', true); |
||||
|
logTest('对话管理按钮显示', true, '进入详情页'); |
||||
|
await page.screenshot({ path: 'tests/screenshots/chat-detail-entered.png' }); |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
logTest('创建新对话', false, '两种方式都失败'); |
||||
|
await page.screenshot({ path: 'tests/screenshots/chat-create-failed.png' }); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
logTest('对话管理按钮显示', false, '页面状态异常'); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
async function testSendMessage(page) { |
||||
|
console.log('\n【步骤4】测试发送消息...'); |
||||
|
|
||||
|
// 等待页面完全加载
|
||||
|
await page.waitForTimeout(2000); |
||||
|
|
||||
|
// 截图当前页面状态
|
||||
|
await page.screenshot({ path: 'tests/screenshots/chat-before-input.png' }); |
||||
|
|
||||
|
// 检查页面状态
|
||||
|
const pageState = await page.evaluate(() => { |
||||
|
const text = document.body.innerText; |
||||
|
const inputs = document.querySelectorAll('input, textarea'); |
||||
|
let inputInfo = []; |
||||
|
inputs.forEach(inp => { |
||||
|
const rect = inp.getBoundingClientRect(); |
||||
|
inputInfo.push({ |
||||
|
tag: inp.tagName, |
||||
|
placeholder: inp.placeholder, |
||||
|
visible: rect.width > 0 && rect.height > 0, |
||||
|
pos: { x: rect.x, y: rect.y, w: rect.width, h: rect.height } |
||||
|
}); |
||||
|
}); |
||||
|
return { |
||||
|
hasAIAssistant: text.includes('AI健康助手'), |
||||
|
hasDialogMgr: text.includes('对话管理'), |
||||
|
hasInputPlaceholder: text.includes('请输入您的健康问题'), |
||||
|
inputCount: inputs.length, |
||||
|
inputs: inputInfo, |
||||
|
bodySnippet: text.substring(0, 300) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
console.log(` 页面状态: AI健康助手=${pageState.hasAIAssistant}, 对话管理=${pageState.hasDialogMgr}`); |
||||
|
console.log(` 输入框数量: ${pageState.inputCount}`); |
||||
|
if (pageState.inputs.length > 0) { |
||||
|
console.log(` 输入框信息:`, JSON.stringify(pageState.inputs)); |
||||
|
} |
||||
|
|
||||
|
// 查找输入框
|
||||
|
const inputVisible = pageState.hasInputPlaceholder || pageState.inputs.some(i => |
||||
|
i.placeholder?.includes('健康') || i.placeholder?.includes('问题') || i.visible |
||||
|
); |
||||
|
|
||||
|
logTest('消息输入框显示', inputVisible); |
||||
|
|
||||
|
const testMessage = '你好,请问感冒了应该怎么办?'; |
||||
|
|
||||
|
// 使用 Playwright 的 type 方法,模拟真实键盘输入
|
||||
|
// 这样可以正确触发 React Native 的 onChangeText
|
||||
|
const input = page.locator('input[placeholder*="健康"], input[placeholder*="问题"], textarea').first(); |
||||
|
|
||||
|
if (await input.isVisible({ timeout: 3000 }).catch(() => false)) { |
||||
|
// 先清空,再逐字输入
|
||||
|
await input.click(); |
||||
|
await page.waitForTimeout(300); |
||||
|
await input.fill(''); // 清空
|
||||
|
await page.waitForTimeout(200); |
||||
|
|
||||
|
// 使用 type 模拟键盘输入,这样能触发 React Native 状态更新
|
||||
|
await input.type(testMessage, { delay: 30 }); |
||||
|
console.log(' 已输入消息内容'); |
||||
|
logTest('输入消息内容', true); |
||||
|
} else { |
||||
|
// 备用:直接操作 DOM
|
||||
|
const filled = await page.evaluate((msg) => { |
||||
|
const inputs = document.querySelectorAll('input, textarea'); |
||||
|
for (const input of inputs) { |
||||
|
if (input.offsetParent !== null) { |
||||
|
// 模拟 React Native 的输入
|
||||
|
const nativeInputValueSetter = Object.getOwnPropertyDescriptor( |
||||
|
window.HTMLInputElement.prototype, 'value' |
||||
|
)?.set; |
||||
|
if (nativeInputValueSetter) { |
||||
|
nativeInputValueSetter.call(input, msg); |
||||
|
} else { |
||||
|
input.value = msg; |
||||
|
} |
||||
|
input.dispatchEvent(new Event('input', { bubbles: true })); |
||||
|
input.dispatchEvent(new Event('change', { bubbles: true })); |
||||
|
return true; |
||||
|
} |
||||
|
} |
||||
|
return false; |
||||
|
}, testMessage); |
||||
|
logTest('输入消息内容', filled, filled ? '使用 DOM 操作' : '失败'); |
||||
|
} |
||||
|
|
||||
|
await page.waitForTimeout(800); |
||||
|
await page.screenshot({ path: 'tests/screenshots/chat-input-filled.png' }); |
||||
|
|
||||
|
// 等待发送按钮出现(只有输入内容后才显示)
|
||||
|
console.log(' 等待发送按钮出现...'); |
||||
|
await page.waitForTimeout(500); |
||||
|
|
||||
|
// 点击发送按钮 - 查找蓝色的"发送"文字按钮
|
||||
|
let sendSuccess = false; |
||||
|
for (let retry = 0; retry < 3; retry++) { |
||||
|
const sendBtnPos = await page.evaluate(() => { |
||||
|
const allElements = document.querySelectorAll('*'); |
||||
|
for (const el of allElements) { |
||||
|
const text = el.textContent?.trim(); |
||||
|
if (text === '发送' && el.children.length === 0) { |
||||
|
const rect = el.getBoundingClientRect(); |
||||
|
// 发送按钮通常在右侧底部
|
||||
|
if (rect.width > 0 && rect.height > 0 && rect.y > window.innerHeight * 0.7) { |
||||
|
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return null; |
||||
|
}); |
||||
|
|
||||
|
if (sendBtnPos) { |
||||
|
console.log(` 找到发送按钮 at (${sendBtnPos.x.toFixed(0)}, ${sendBtnPos.y.toFixed(0)})`); |
||||
|
await page.mouse.click(sendBtnPos.x, sendBtnPos.y); |
||||
|
sendSuccess = true; |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
console.log(` 未找到发送按钮,重试 ${retry + 1}/3...`); |
||||
|
await page.waitForTimeout(500); |
||||
|
} |
||||
|
|
||||
|
if (!sendSuccess) { |
||||
|
// 备用方案:使用 Playwright locator
|
||||
|
const sendBtn = page.locator('text=发送').last(); |
||||
|
if (await sendBtn.isVisible({ timeout: 1000 }).catch(() => false)) { |
||||
|
await sendBtn.click({ force: true }); |
||||
|
sendSuccess = true; |
||||
|
console.log(' 使用 locator 点击发送'); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
logTest('点击发送按钮', sendSuccess, sendSuccess ? '' : '未找到发送按钮'); |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
async function testStreamingOutput(page) { |
||||
|
console.log('\n【步骤5】测试流式输出与思考过程...'); |
||||
|
|
||||
|
// 等待 AI 开始响应
|
||||
|
console.log(' 等待 AI 响应...'); |
||||
|
await page.waitForTimeout(3000); |
||||
|
|
||||
|
// 截图发送后状态
|
||||
|
await page.screenshot({ path: 'tests/screenshots/chat-after-send.png' }); |
||||
|
|
||||
|
// 检测思考过程和流式输出
|
||||
|
let previousLength = 0; |
||||
|
let contentGrowing = false; |
||||
|
let attempts = 0; |
||||
|
const maxAttempts = 30; // 增加等待次数,思考过程需要更长时间
|
||||
|
let foundThinking = false; |
||||
|
let foundAIResponse = false; |
||||
|
let thinkingContent = ''; |
||||
|
|
||||
|
while (attempts < maxAttempts) { |
||||
|
attempts++; |
||||
|
|
||||
|
const pageInfo = await page.evaluate(() => { |
||||
|
const body = document.body.innerText; |
||||
|
|
||||
|
// 检查是否有思考过程显示
|
||||
|
const hasThinking = body.includes('思考中') || |
||||
|
body.includes('思考过程') || |
||||
|
body.includes('💭'); |
||||
|
|
||||
|
// 获取思考内容
|
||||
|
const thinkingElements = document.querySelectorAll('[class*="thinking"]'); |
||||
|
let thinkingText = ''; |
||||
|
thinkingElements.forEach(el => { |
||||
|
thinkingText += el.textContent || ''; |
||||
|
}); |
||||
|
|
||||
|
// 检查是否有 AI 回复的典型格式
|
||||
|
const hasAIFormat = body.includes('【情况分析】') || |
||||
|
body.includes('【建议】') || |
||||
|
body.includes('【提醒】') || |
||||
|
body.includes('【用药参考】'); |
||||
|
|
||||
|
// 检查用户消息是否已发送
|
||||
|
const inputValue = document.querySelector('input')?.value || ''; |
||||
|
const userMsgSent = !inputValue.includes('感冒'); |
||||
|
|
||||
|
return { |
||||
|
bodyLength: body.length, |
||||
|
hasThinking, |
||||
|
thinkingText, |
||||
|
hasAIFormat, |
||||
|
userMsgSent, |
||||
|
preview: body.substring(0, 500) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
// 检测内容增长(流式效果)
|
||||
|
if (pageInfo.bodyLength > previousLength && previousLength > 0) { |
||||
|
contentGrowing = true; |
||||
|
if (attempts % 5 === 0) { |
||||
|
console.log(` 内容增长: ${previousLength} → ${pageInfo.bodyLength} 字符`); |
||||
|
} |
||||
|
} |
||||
|
previousLength = pageInfo.bodyLength; |
||||
|
|
||||
|
// 检测思考过程
|
||||
|
if (pageInfo.hasThinking && !foundThinking) { |
||||
|
foundThinking = true; |
||||
|
console.log(' ✓ 检测到思考过程显示'); |
||||
|
// 截图思考过程
|
||||
|
await page.screenshot({ path: 'tests/screenshots/chat-thinking.png' }); |
||||
|
} |
||||
|
|
||||
|
// 更新思考内容
|
||||
|
if (pageInfo.thinkingText && pageInfo.thinkingText.length > thinkingContent.length) { |
||||
|
thinkingContent = pageInfo.thinkingText; |
||||
|
} |
||||
|
|
||||
|
// 检测最终回复
|
||||
|
if (pageInfo.hasAIFormat) { |
||||
|
foundAIResponse = true; |
||||
|
console.log(' ✓ 检测到 AI 正式回复'); |
||||
|
break; |
||||
|
} |
||||
|
|
||||
|
// 检查用户消息是否已发送
|
||||
|
if (attempts === 5 && !pageInfo.userMsgSent) { |
||||
|
console.log(' 用户消息可能未发送,尝试再次点击发送...'); |
||||
|
const sendBtn = page.locator('text=发送').last(); |
||||
|
if (await sendBtn.isVisible({ timeout: 500 }).catch(() => false)) { |
||||
|
await sendBtn.click({ force: true }); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
await page.waitForTimeout(1000); |
||||
|
} |
||||
|
|
||||
|
// 最终检查
|
||||
|
if (!foundAIResponse) { |
||||
|
const finalCheck = await page.evaluate(() => { |
||||
|
const text = document.body.innerText; |
||||
|
return { |
||||
|
hasResponse: text.includes('【') || text.includes('建议') || text.includes('分析'), |
||||
|
hasThinking: text.includes('思考') || text.includes('💭'), |
||||
|
text: text.substring(0, 800) |
||||
|
}; |
||||
|
}); |
||||
|
foundAIResponse = finalCheck.hasResponse; |
||||
|
if (!foundThinking) foundThinking = finalCheck.hasThinking; |
||||
|
} |
||||
|
|
||||
|
// 截图最终状态
|
||||
|
await page.screenshot({ path: 'tests/screenshots/chat-ai-response.png' }); |
||||
|
|
||||
|
// 记录测试结果
|
||||
|
logTest('思考过程显示', foundThinking, foundThinking ? '💭 思考中...' : '未检测到思考过程'); |
||||
|
|
||||
|
if (thinkingContent.length > 20) { |
||||
|
logTest('思考内容输出', true, `${thinkingContent.length} 字符`); |
||||
|
} else { |
||||
|
logTest('思考内容输出', foundThinking, foundThinking ? '有思考显示' : '无思考内容'); |
||||
|
} |
||||
|
|
||||
|
logTest('AI 正式回复', foundAIResponse, foundAIResponse ? '检测到有效回复' : '未检测到回复'); |
||||
|
|
||||
|
if (contentGrowing) { |
||||
|
logTest('流式输出效果', true, '内容逐步显示'); |
||||
|
} else { |
||||
|
logTest('流式输出效果', foundAIResponse, foundAIResponse ? '有响应' : '未检测到流式效果'); |
||||
|
} |
||||
|
|
||||
|
return foundAIResponse || foundThinking; |
||||
|
} |
||||
|
|
||||
|
async function testDeleteConversation(page) { |
||||
|
console.log('\n【步骤6】测试删除对话功能...'); |
||||
|
|
||||
|
// 打开对话管理弹窗
|
||||
|
const manageBtnPos = await page.evaluate(() => { |
||||
|
const allElements = document.querySelectorAll('*'); |
||||
|
for (const el of allElements) { |
||||
|
if (el.textContent?.trim() === '对话管理' && el.children.length === 0) { |
||||
|
const rect = el.getBoundingClientRect(); |
||||
|
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; |
||||
|
} |
||||
|
} |
||||
|
return null; |
||||
|
}); |
||||
|
|
||||
|
if (!manageBtnPos) { |
||||
|
logTest('打开对话管理', false); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
await page.mouse.click(manageBtnPos.x, manageBtnPos.y); |
||||
|
await page.waitForTimeout(1000); |
||||
|
|
||||
|
// 获取删除前的对话数量
|
||||
|
const beforeCount = await page.evaluate(() => { |
||||
|
const items = document.querySelectorAll('[data-testid="conversation-item"]'); |
||||
|
if (items.length > 0) return items.length; |
||||
|
// 如果没有 testid,查找对话列表项
|
||||
|
const allElements = document.querySelectorAll('*'); |
||||
|
let count = 0; |
||||
|
for (const el of allElements) { |
||||
|
const text = el.textContent?.trim(); |
||||
|
if (text === '🗑') { |
||||
|
count++; |
||||
|
} |
||||
|
} |
||||
|
return count; // 每个对话项有一个删除按钮
|
||||
|
}); |
||||
|
console.log(` 删除前对话数量: ${beforeCount}`); |
||||
|
|
||||
|
// 查找并点击第一个删除按钮(垃圾桶 emoji 🗑)
|
||||
|
const deleteBtnPos = await page.evaluate(() => { |
||||
|
const allElements = document.querySelectorAll('*'); |
||||
|
for (const el of allElements) { |
||||
|
const text = el.textContent?.trim(); |
||||
|
if (text === '🗑') { |
||||
|
const rect = el.getBoundingClientRect(); |
||||
|
if (rect.width > 0 && rect.height > 0) { |
||||
|
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return null; |
||||
|
}); |
||||
|
|
||||
|
logTest('删除按钮显示', !!deleteBtnPos); |
||||
|
|
||||
|
if (!deleteBtnPos) { |
||||
|
await page.keyboard.press('Escape'); |
||||
|
await page.waitForTimeout(500); |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
// 点击删除按钮
|
||||
|
console.log(' 点击删除按钮...'); |
||||
|
await page.mouse.click(deleteBtnPos.x, deleteBtnPos.y); |
||||
|
await page.waitForTimeout(1000); |
||||
|
|
||||
|
// 查找并点击确认删除按钮
|
||||
|
const confirmBtnPos = await page.evaluate(() => { |
||||
|
const allElements = document.querySelectorAll('*'); |
||||
|
for (const el of allElements) { |
||||
|
const text = el.textContent?.trim(); |
||||
|
// 查找确认弹窗中的"删除"按钮
|
||||
|
if (text === '删除' && el.tagName !== 'SPAN') { |
||||
|
const rect = el.getBoundingClientRect(); |
||||
|
if (rect.width > 0 && rect.height > 0 && rect.width < 200) { |
||||
|
return { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return null; |
||||
|
}); |
||||
|
|
||||
|
logTest('确认删除弹窗', !!confirmBtnPos); |
||||
|
|
||||
|
if (confirmBtnPos) { |
||||
|
console.log(' 确认删除...'); |
||||
|
await page.mouse.click(confirmBtnPos.x, confirmBtnPos.y); |
||||
|
await page.waitForTimeout(2000); |
||||
|
|
||||
|
// 验证删除成功(检查是否有成功提示或对话数量减少)
|
||||
|
const afterCount = await page.evaluate(() => { |
||||
|
const allElements = document.querySelectorAll('*'); |
||||
|
let count = 0; |
||||
|
for (const el of allElements) { |
||||
|
const text = el.textContent?.trim(); |
||||
|
if (text === '🗑') { |
||||
|
count++; |
||||
|
} |
||||
|
} |
||||
|
return count; |
||||
|
}); |
||||
|
console.log(` 删除后对话数量: ${afterCount}`); |
||||
|
|
||||
|
const deleteSuccess = afterCount < beforeCount || afterCount === 0; |
||||
|
logTest('删除对话成功', deleteSuccess, deleteSuccess ? `${beforeCount} → ${afterCount}` : '数量未变化'); |
||||
|
} |
||||
|
|
||||
|
// 关闭弹窗
|
||||
|
await page.keyboard.press('Escape'); |
||||
|
await page.waitForTimeout(500); |
||||
|
|
||||
|
return true; |
||||
|
} |
||||
|
|
||||
|
async function testHistoryPersistence(page) { |
||||
|
console.log('\n【步骤7】测试数据持久化(刷新后状态保持)...'); |
||||
|
|
||||
|
// 刷新页面
|
||||
|
await page.reload(); |
||||
|
await page.waitForTimeout(3000); |
||||
|
|
||||
|
// 刷新后需要重新登录(token 可能在 localStorage,但会过期或需要重新验证)
|
||||
|
await login(page); |
||||
|
await page.waitForTimeout(1000); |
||||
|
|
||||
|
// 重新导航到问答页
|
||||
|
await navigateToChatTab(page); |
||||
|
await page.waitForTimeout(2000); |
||||
|
|
||||
|
// 检查页面状态 - 根据之前测试是否删除了对话来判断
|
||||
|
const pageState = await page.evaluate(() => { |
||||
|
const text = document.body.innerText; |
||||
|
// 检查是否在详情页(有对话内容)
|
||||
|
const inDetail = text.includes('AI健康助手'); |
||||
|
// 检查是否在列表页
|
||||
|
const inList = text.includes('AI问答') && !text.includes('AI健康助手'); |
||||
|
// 检查是否有对话内容
|
||||
|
const hasContent = text.includes('感冒') || text.includes('你好') || text.includes('【'); |
||||
|
// 检查是否显示空状态
|
||||
|
const isEmpty = text.includes('暂无对话') || text.includes('开始对话'); |
||||
|
|
||||
|
return { |
||||
|
inDetail, |
||||
|
inList, |
||||
|
hasContent, |
||||
|
isEmpty, |
||||
|
snippet: text.substring(0, 300) |
||||
|
}; |
||||
|
}); |
||||
|
|
||||
|
console.log(` 页面状态: 在${pageState.inDetail ? '详情页' : (pageState.inList ? '列表页' : '未知')}`); |
||||
|
|
||||
|
// 判断持久化是否成功
|
||||
|
// 如果之前删除了所有对话,空状态应该正确显示(这也是正确的持久化)
|
||||
|
// 如果还有对话,应该能看到对话内容
|
||||
|
const persistenceCorrect = pageState.inDetail || pageState.inList || pageState.isEmpty; |
||||
|
const stateDesc = pageState.hasContent ? '对话内容已保留' : |
||||
|
pageState.isEmpty ? '空状态正确显示' : '页面状态正确'; |
||||
|
|
||||
|
logTest('数据持久化', persistenceCorrect, stateDesc); |
||||
|
|
||||
|
await page.screenshot({ path: 'tests/screenshots/chat-after-refresh.png' }); |
||||
|
|
||||
|
return persistenceCorrect; |
||||
|
} |
||||
|
|
||||
|
async function runTests() { |
||||
|
console.log('═══════════════════════════════════════════════════════════'); |
||||
|
console.log(' 问答页对话管理与流式输出自动化测试'); |
||||
|
console.log('═══════════════════════════════════════════════════════════'); |
||||
|
console.log('\n测试范围:'); |
||||
|
console.log(' - 对话管理:打开弹窗、新建对话、删除对话'); |
||||
|
console.log(' - 发送消息:输入内容、点击发送'); |
||||
|
console.log(' - 流式输出:AI 响应逐字显示'); |
||||
|
console.log(' - 历史持久化:刷新后对话保留'); |
||||
|
console.log(''); |
||||
|
|
||||
|
const browser = await chromium.launch({ headless: false }); |
||||
|
const context = await browser.newContext({ |
||||
|
viewport: { width: 1280, height: 800 } |
||||
|
}); |
||||
|
const page = await context.newPage(); |
||||
|
|
||||
|
// 监听控制台
|
||||
|
page.on('console', msg => { |
||||
|
const text = msg.text(); |
||||
|
// 捕获与消息发送和思考过程相关的所有日志
|
||||
|
if (text.includes('[SendMessage]') || text.includes('[Chat]') || |
||||
|
text.includes('SSE') || text.includes('stream') || |
||||
|
text.includes('thinking') || text.includes('思考')) { |
||||
|
console.log(` [Browser ${msg.type()}] ${text.substring(0, 250)}`); |
||||
|
} |
||||
|
// 仅输出真正的错误(排除 constitution/result 的 400 - 这是正常情况)
|
||||
|
if (msg.type() === 'error' && !text.includes('constitution')) { |
||||
|
console.log(` [Browser error] ${text.substring(0, 200)}`); |
||||
|
} |
||||
|
}); |
||||
|
|
||||
|
try { |
||||
|
console.log('\n打开应用...'); |
||||
|
await page.goto(APP_URL); |
||||
|
await page.waitForTimeout(2000); |
||||
|
|
||||
|
// 执行测试步骤
|
||||
|
const loginOk = await login(page); |
||||
|
if (!loginOk) throw new Error('登录失败'); |
||||
|
|
||||
|
const navOk = await navigateToChatTab(page); |
||||
|
if (!navOk) throw new Error('导航失败'); |
||||
|
|
||||
|
await testConversationManagement(page); |
||||
|
await testSendMessage(page); |
||||
|
await testStreamingOutput(page); |
||||
|
await testDeleteConversation(page); |
||||
|
await testHistoryPersistence(page); |
||||
|
|
||||
|
} catch (error) { |
||||
|
console.error('\n测试中断:', error.message); |
||||
|
await page.screenshot({ path: 'tests/screenshots/chat-error.png' }); |
||||
|
} finally { |
||||
|
// 打印测试摘要
|
||||
|
console.log('\n═══════════════════════════════════════════════════════════'); |
||||
|
console.log(' 测试结果摘要'); |
||||
|
console.log('═══════════════════════════════════════════════════════════'); |
||||
|
console.log(`通过: ${testResults.passed} 失败: ${testResults.failed}`); |
||||
|
console.log('───────────────────────────────────────────────────────────'); |
||||
|
|
||||
|
// 按类别分组显示
|
||||
|
const categories = { |
||||
|
'登录与导航': [], |
||||
|
'对话管理': [], |
||||
|
'消息发送': [], |
||||
|
'思考过程': [], |
||||
|
'流式输出': [], |
||||
|
'历史持久化': [] |
||||
|
}; |
||||
|
|
||||
|
for (const test of testResults.tests) { |
||||
|
const icon = test.passed ? '✓' : '✗'; |
||||
|
const line = `${icon} ${test.name}${test.detail ? ' - ' + test.detail : ''}`; |
||||
|
|
||||
|
if (test.name.includes('登录') || test.name.includes('导航')) { |
||||
|
categories['登录与导航'].push(line); |
||||
|
} else if (test.name.includes('对话管理') || test.name.includes('新建') || test.name.includes('删除')) { |
||||
|
categories['对话管理'].push(line); |
||||
|
} else if (test.name.includes('输入') || test.name.includes('发送')) { |
||||
|
categories['消息发送'].push(line); |
||||
|
} else if (test.name.includes('思考')) { |
||||
|
categories['思考过程'].push(line); |
||||
|
} else if (test.name.includes('流式') || test.name.includes('AI')) { |
||||
|
categories['流式输出'].push(line); |
||||
|
} else if (test.name.includes('持久化') || test.name.includes('历史') || test.name.includes('数据')) { |
||||
|
categories['历史持久化'].push(line); |
||||
|
} else { |
||||
|
categories['登录与导航'].push(line); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
for (const [category, tests] of Object.entries(categories)) { |
||||
|
if (tests.length > 0) { |
||||
|
console.log(`\n【${category}】`); |
||||
|
for (const test of tests) { |
||||
|
console.log(` ${test}`); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
console.log('\n═══════════════════════════════════════════════════════════\n'); |
||||
|
|
||||
|
await browser.close(); |
||||
|
process.exit(testResults.failed > 0 ? 1 : 0); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
runTests(); |
||||
@ -0,0 +1,617 @@ |
|||||
|
/** |
||||
|
* 商城前端 · 真实数据端到端测试 |
||||
|
* |
||||
|
* 前置条件: |
||||
|
* 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(); |
||||
@ -0,0 +1,668 @@ |
|||||
|
/** |
||||
|
* 商城前端(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(); |
||||
|
After Width: | Height: | Size: 34 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 51 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 48 KiB |
|
After Width: | Height: | Size: 50 KiB |
|
After Width: | Height: | Size: 45 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 49 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 54 KiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 51 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 46 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 42 KiB |
|
Before Width: | Height: | Size: 64 KiB After Width: | Height: | Size: 63 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 180 KiB |
|
After Width: | Height: | Size: 26 KiB |
|
After Width: | Height: | Size: 83 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 82 KiB |
|
After Width: | Height: | Size: 76 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 15 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 73 KiB |
|
After Width: | Height: | Size: 44 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 37 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 118 KiB |
|
After Width: | Height: | Size: 84 KiB |
|
After Width: | Height: | Size: 30 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 42 KiB After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 41 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 61 KiB |
|
Before Width: | Height: | Size: 51 KiB After Width: | Height: | Size: 52 KiB |
|
Before Width: | Height: | Size: 52 KiB After Width: | Height: | Size: 51 KiB |
@ -0,0 +1,16 @@ |
|||||
|
import request from './request' |
||||
|
|
||||
|
// 登录
|
||||
|
export function loginApi(data: { phone?: string; email?: string; password: string }) { |
||||
|
return request.post('/api/auth/login', data) |
||||
|
} |
||||
|
|
||||
|
// 注册
|
||||
|
export function registerApi(data: { phone?: string; email?: string; password: string; code?: string }) { |
||||
|
return request.post('/api/auth/register', data) |
||||
|
} |
||||
|
|
||||
|
// 获取用户信息
|
||||
|
export function getUserInfoApi() { |
||||
|
return request.get('/api/user/profile') |
||||
|
} |
||||
@ -0,0 +1,56 @@ |
|||||
|
import axios from 'axios' |
||||
|
import { ElMessage } from 'element-plus' |
||||
|
import router from '@/router' |
||||
|
|
||||
|
// 创建 axios 实例
|
||||
|
const request = axios.create({ |
||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080', |
||||
|
timeout: 15000, |
||||
|
headers: { |
||||
|
'Content-Type': 'application/json' |
||||
|
} |
||||
|
}) |
||||
|
|
||||
|
// 请求拦截器:自动携带 token
|
||||
|
request.interceptors.request.use( |
||||
|
(config) => { |
||||
|
const token = localStorage.getItem('token') |
||||
|
if (token) { |
||||
|
config.headers.Authorization = token |
||||
|
} |
||||
|
return config |
||||
|
}, |
||||
|
(error) => Promise.reject(error) |
||||
|
) |
||||
|
|
||||
|
// 响应拦截器:统一错误处理
|
||||
|
request.interceptors.response.use( |
||||
|
(response) => { |
||||
|
const data = response.data |
||||
|
// go-zero 直接返回数据(非 code/message/data 包装)
|
||||
|
return data |
||||
|
}, |
||||
|
(error) => { |
||||
|
const status = error.response?.status |
||||
|
const message = error.response?.data?.message || error.message |
||||
|
|
||||
|
if (status === 401) { |
||||
|
localStorage.removeItem('token') |
||||
|
localStorage.removeItem('user') |
||||
|
ElMessage.error('登录已过期,请重新登录') |
||||
|
router.push('/login') |
||||
|
} else if (status === 400) { |
||||
|
ElMessage.error(message || '请求参数错误') |
||||
|
} else if (status === 404) { |
||||
|
ElMessage.error('请求的资源不存在') |
||||
|
} else if (status === 500) { |
||||
|
ElMessage.error('服务器内部错误') |
||||
|
} else { |
||||
|
ElMessage.error(message || '网络异常,请稍后重试') |
||||
|
} |
||||
|
|
||||
|
return Promise.reject(error) |
||||
|
} |
||||
|
) |
||||
|
|
||||
|
export default request |
||||