test(miniprogram): 端到端链路验证脚本 — 11 UI链路 + 10 API闭环
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 连接微信开发者工具 automator (ws://localhost:9420)
- 通过加密 storage 注入 admin token 绕过微信登录
- 验证 11 条 UI 链路: 首页/健康数据/录入/日常监测/积分商城/
  预约/家庭成员/咨询/文章/趋势/报告
- 验证 10 个 API 数据闭环: 患者/预约/咨询/日常监测/积分/
  签到/商品/医生/文章/随访
- 正确处理 tabbar 页面 (switchTab vs navigateTo)
- 所有导航带 8s 超时保护
This commit is contained in:
iven
2026-04-27 08:20:26 +08:00
parent c314093c76
commit d460316d23

View File

@@ -0,0 +1,419 @@
/**
* HMS 小程序端到端链路验证 v4 (final)
* 前置条件: dist/ 已构建, 开发者工具已打开项目, 自动化端口 9420 已开放
*/
const automator = require('miniprogram-automator');
const http = require('http');
const CryptoJS = require('crypto-js');
const ENC_KEY = '0a17b71d46064b06f993c9c202b342425e311a79f5be026d830562e7ad51f522';
function encrypt(plaintext) { return CryptoJS.AES.encrypt(plaintext, ENC_KEY).toString(); }
const BASE = 'http://localhost:3000/api/v1';
const results = [];
function log(chain, step, status, detail) {
const icon = status === 'PASS' ? '✅' : status === 'FAIL' ? '❌' : '⚠️';
console.log(` ${icon} [${chain}] ${step}: ${detail}`);
results.push({ chain, step, status, detail });
}
function api(method, path, body, token) {
return new Promise((resolve, reject) => {
const url = new URL(BASE + path);
const opts = {
hostname: url.hostname, port: url.port,
path: url.pathname + url.search, method,
headers: { 'Content-Type': 'application/json' },
timeout: 10000,
};
if (token) opts.headers['Authorization'] = `Bearer ${token}`;
const req = http.request(opts, (res) => {
const chunks = [];
res.on('data', c => chunks.push(c));
res.on('end', () => {
try { resolve({ status: res.statusCode, data: JSON.parse(Buffer.concat(chunks).toString()) }); }
catch { resolve({ status: res.statusCode, raw: true }); }
});
});
req.on('error', reject);
if (body) req.write(JSON.stringify(body));
req.end();
});
}
const T = (ms) => new Promise((_, r) => setTimeout(() => r(new Error('timeout')), ms));
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
async function main() {
console.log('\n=== HMS 小程序端到端链路验证 ===\n');
// ====== 连接 ======
console.log('连接微信开发者工具...');
const mini = await automator.connect({ wsEndpoint: 'ws://localhost:9420' });
const info = await mini.systemInfo();
console.log(`已连接 (SDK ${info.SDKVersion}, ${info.model})\n`);
// ====== 辅助 ======
async function curPage() {
const page = await mini.currentPage();
return page;
}
async function curPath() {
const page = await curPage();
return page.path;
}
const tabPages = new Set(['pages/index/index', 'pages/health/index', 'pages/consultation/index', 'pages/mall/index', 'pages/profile/index']);
async function nav(url) {
const cleanUrl = url.startsWith('/') ? url.slice(1) : url;
try {
if (tabPages.has(cleanUrl)) {
await Promise.race([mini.switchTab('/' + cleanUrl), T(8000)]);
} else {
await Promise.race([mini.navigateTo('/' + cleanUrl), T(8000)]);
}
} catch (e) {
console.log(` ⚠ 导航超时: ${url} - ${e.message}`);
}
await sleep(2000);
}
async function back() {
try { await Promise.race([mini.navigateBack(), T(5000)]); } catch {}
await sleep(1000);
}
async function tap(selector) {
const page = await curPage();
const el = await page.$(selector);
if (el) { await el.tap(); await sleep(800); return true; }
return false;
}
// ========================================
// 后端准备
// ========================================
console.log('--- 后端准备 ---');
const lr = await api('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
const token = lr.data?.data?.access_token;
log('后端', '管理员登录', token ? 'PASS' : 'FAIL', `status=${lr.status}`);
const pr = await api('GET', '/health/patients?page=1&page_size=10', null, token);
const patients = pr.data?.data?.data || [];
log('后端', '患者列表', patients.length > 0 ? 'PASS' : 'WARN', `${patients.length}个患者`);
const patientId = patients[0]?.id;
console.log('');
// ========================================
// 链路1: 登录页 → 首页(通过加密 storage 绕过)
// ========================================
console.log('--- 链路1: 认证流程 ---');
let startPath = await curPath();
log('认证', '初始页面', 'PASS', `路径: ${startPath}`);
if (startPath.includes('login')) {
const loginBtn = await (await curPage()).$('.login-btn, .auth-btn, button, [class*="login"]');
log('认证', '登录按钮', loginBtn ? 'PASS' : 'WARN', loginBtn ? '找到' : '未找到');
// 用 API 获取 admin token加密后写入 storage
try {
const loginRes = await api('POST', '/auth/login', { username: 'admin', password: 'Admin@2026' });
const apiToken = loginRes.data?.data?.access_token;
if (apiToken) {
await mini.callWxMethod('setStorageSync', 'access_token', encrypt(apiToken));
await mini.callWxMethod('setStorageSync', 'refresh_token', encrypt('dummy'));
await mini.callWxMethod('setStorageSync', 'user_data', encrypt(JSON.stringify({ id: 'test', username: 'admin', display_name: '管理员', tenant_id: '019d0da7-a2c1-7820-b0a3-3d5266a3a324' })));
await mini.callWxMethod('setStorageSync', 'user_roles', encrypt(JSON.stringify(['admin'])));
await mini.callWxMethod('setStorageSync', 'tenant_id', encrypt('019d0da7-a2c1-7820-b0a3-3d5266a3a324'));
await mini.callWxMethod('setStorageSync', 'current_patient', { id: patients[0]?.id || 'x', name: patients[0]?.name || '测试', relation: 'self' });
await mini.callWxMethod('setStorageSync', 'current_patient_id', patients[0]?.id || 'x');
log('认证', '加密Token写入', 'PASS', '加密 storage 已设置');
}
await mini.reLaunch('/pages/index/index');
await sleep(4000);
const afterPath = await curPath();
log('认证', 'reLaunch首页', afterPath.includes('index') ? 'PASS' : 'FAIL', `路径: ${afterPath}`);
if (afterPath.includes('login')) {
log('认证', '状态', 'FAIL', '仍在登录页');
} else {
log('认证', '登录成功', 'PASS', `已进入: ${afterPath}`);
}
} catch (e) {
log('认证', '异常', 'FAIL', e.message);
}
}
// ========================================
// 链路2: 健康数据页
// ========================================
console.log('\n--- 链路2: 健康数据 ---');
try {
await nav('/pages/health/index');
const hPath = await curPath();
log('健康数据', '页面导航', hPath.includes('health') ? 'PASS' : 'FAIL', `路径: ${hPath}`);
// API 链路验证
if (token && patientId) {
const tr = await api('GET', `/health/vital-signs/today?patient_id=${patientId}`, null, token);
log('健康数据', '今日体征API(F1)', tr.status === 200 ? 'PASS' : 'FAIL',
`status=${tr.status}, data=${JSON.stringify(tr.data?.data || {}).substring(0, 100)}`);
const trendR = await api('GET', '/health/vital-signs/trend?indicator=weight&range=7d', null, token);
log('健康数据', '趋势API', trendR.status === 200 ? 'PASS' : 'FAIL', `status=${trendR.status}`);
}
// 健康数据是 tabbar 页面,切回首页
await mini.switchTab('/pages/index/index');
await sleep(2000);
} catch (e) {
log('健康数据', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路3: 健康录入
// ========================================
console.log('\n--- 链路3: 健康录入 ---');
try {
await nav('/pages/health/input/index');
const iPath = await curPath();
log('健康录入', '页面导航', iPath.includes('input') ? 'PASS' : 'FAIL', `路径: ${iPath}`);
const page = await curPage();
const inputs = await page.$$('input');
log('健康录入', '表单输入框', inputs.length > 0 ? 'PASS' : 'FAIL', `${inputs.length}`);
// 尝试在第一个数字输入框输入数据
if (inputs.length > 0) {
for (const inp of inputs) {
const type = await inp.attribute('type');
if (type === 'digit' || type === 'number') {
await inp.input('65.5');
log('健康录入', '填写数据', 'PASS', '输入 65.5');
break;
}
}
}
await back();
} catch (e) {
log('健康录入', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路4: 日常监测
// ========================================
console.log('\n--- 链路4: 日常监测 ---');
try {
await nav('/pages/health/daily-monitoring/index');
const dPath = await curPath();
log('日常监测', '页面导航', dPath.includes('daily') ? 'PASS' : 'FAIL', `路径: ${dPath}`);
const page = await curPage();
const dmInputs = await page.$$('.dm-input');
log('日常监测', '表单字段', dmInputs.length > 0 ? 'PASS' : 'FAIL', `${dmInputs.length}个.dm-input`);
// 验证 Zod: 输入超范围值
if (dmInputs.length > 0) {
await dmInputs[0].input('9999');
log('日常监测', 'Zod超范围值', 'PASS', '输入收缩压9999(应被Zod拦截)');
}
const submitBtn = await page.$('.dm-submit');
log('日常监测', '提交按钮', submitBtn ? 'PASS' : 'FAIL', submitBtn ? '找到' : '未找到');
const resetBtn = await page.$('.dm-reset');
log('日常监测', '重置按钮', resetBtn ? 'PASS' : 'WARN', resetBtn ? '找到' : '未找到');
await back();
} catch (e) {
log('日常监测', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路5: 积分商城
// ========================================
console.log('\n--- 链路5: 积分商城 ---');
try {
await nav('/pages/mall/index');
const mPath = await curPath();
log('积分商城', '页面导航', mPath.includes('mall') ? 'PASS' : 'FAIL', `路径: ${mPath}`);
const page = await curPage();
const pointsCard = await page.$('.points-card');
log('积分商城', '积分卡片', pointsCard ? 'PASS' : 'WARN', pointsCard ? '显示积分' : '未显示(可能无档案)');
const checkinBtn = await page.$('.checkin-btn');
log('积分商城', '签到按钮', checkinBtn ? 'PASS' : 'WARN', checkinBtn ? '找到' : '未找到');
const products = await page.$$('.product-card');
log('积分商城', '商品列表', 'PASS', `${products.length}个商品`);
// F2 修复: 无档案降级 UI
const emptyEl = await page.$('.empty-state');
if (emptyEl && !pointsCard) {
log('积分商城', '无档案降级(F2)', 'PASS', '显示降级引导UI');
}
// 积分商城是 tabbar 页面,切回首页
await mini.switchTab('/pages/index/index');
await sleep(2000);
} catch (e) {
log('积分商城', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路6: 预约挂号
// ========================================
console.log('\n--- 链路6: 预约挂号 ---');
try {
await nav('/pages/appointment/index');
const aPath = await curPath();
log('预约', '页面导航', aPath.includes('appointment') ? 'PASS' : 'FAIL', `路径: ${aPath}`);
await back();
} catch (e) {
log('预约', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路7: 家庭成员
// ========================================
console.log('\n--- 链路7: 家庭成员 ---');
try {
await nav('/pages/profile/family/index');
const fPath = await curPath();
log('家庭成员', '页面导航', fPath.includes('family') ? 'PASS' : 'FAIL', `路径: ${fPath}`);
const page = await curPage();
const memberEls = await page.$$('[class*="card"], [class*="member"], [class*="patient"]');
log('家庭成员', '列表渲染', memberEls.length > 0 ? 'PASS' : 'WARN', `${memberEls.length}个元素`);
await back();
} catch (e) {
log('家庭成员', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路8: 咨询
// ========================================
console.log('\n--- 链路8: 咨询 ---');
try {
await nav('/pages/consultation/index');
const cPath = await curPath();
log('咨询', '页面导航', cPath.includes('consultation') ? 'PASS' : 'FAIL', `路径: ${cPath}`);
// 咨询页是 tabbar 页面,不能用 navigateBack切回首页
await mini.switchTab('/pages/index/index');
await sleep(2000);
} catch (e) {
log('咨询', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路9: 文章
// ========================================
console.log('\n--- 链路9: 文章 ---');
try {
await nav('/pages/article/index');
const arPath = await curPath();
log('文章', '页面导航', arPath.includes('article') ? 'PASS' : 'FAIL', `路径: ${arPath}`);
await back();
} catch (e) {
log('文章', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路10: 趋势
// ========================================
console.log('\n--- 链路10: 趋势 ---');
try {
await nav('/pages/health/trend/index');
const trPath = await curPath();
log('趋势', '页面导航', trPath.includes('trend') ? 'PASS' : 'FAIL', `路径: ${trPath}`);
await back();
} catch (e) {
log('趋势', '操作异常', 'FAIL', e.message);
}
// ========================================
// 链路11: 报告
// ========================================
console.log('\n--- 链路11: 报告 ---');
try {
await nav('/pages/profile/reports/index');
const rpPath = await curPath();
log('报告', '页面导航', rpPath.includes('report') ? 'PASS' : 'FAIL', `路径: ${rpPath}`);
await back();
} catch (e) {
log('报告', '操作异常', 'FAIL', e.message);
}
// ========================================
// API 数据闭环验证
// ========================================
console.log('\n--- API 数据闭环 ---');
if (token && patientId) {
const checks = [
['患者详情', 'GET', `/health/patients/${patientId}`],
['预约列表', 'GET', '/health/appointments?page=1&page_size=5'],
['咨询列表', 'GET', '/health/consultation-sessions?page=1&page_size=5'],
['日常监测', 'GET', `/health/patients/${patientId}/daily-monitoring?page=1&page_size=5`],
['积分账户', 'GET', '/health/points/account', 404], // admin 无患者档案, 404 预期
['签到状态', 'GET', '/health/points/checkin/status', 404], // admin 无患者档案, 404 预期
['商品列表', 'GET', '/health/points/products?page=1&page_size=5'],
['医生列表', 'GET', '/health/doctors?page=1&page_size=20'],
['文章列表', 'GET', '/health/articles?page=1&page_size=5&status=published'],
['随访任务', 'GET', '/health/follow-up-tasks?page=1&page_size=5'],
];
for (const check of checks) {
const [label, method, path, expected404] = Array.isArray(check) ? check : [check];
try {
const r = await api(method, path, null, token);
let detail = `status=${r.status}`;
if (r.data?.data?.name) detail += `, name=${r.data.data.name}`;
if (r.data?.data?.total !== undefined) detail += `, total=${r.data.data.total}`;
if (r.data?.data?.data?.length !== undefined) detail += `, items=${r.data.data.data.length}`;
if (r.status === 200) {
log('API闭环', label, 'PASS', detail);
} else if (r.status === 404 && expected404) {
log('API闭环', label, 'WARN', `${detail} (预期:无档案/无路由)`);
} else {
log('API闭环', label, 'FAIL', detail);
}
} catch (e) {
log('API闭环', label, 'FAIL', e.message);
}
}
}
// ====== 断开 ======
await mini.disconnect();
// ====== 汇总 ======
console.log('\n\n========================================');
console.log(' HMS 小程序端到端链路验证报告');
console.log('========================================\n');
const chains = [...new Set(results.map(r => r.chain))];
for (const chain of chains) {
const items = results.filter(r => r.chain === chain);
const p = items.filter(r => r.status === 'PASS').length;
const f = items.filter(r => r.status === 'FAIL').length;
const w = items.filter(r => r.status === 'WARN').length;
const icon = f > 0 ? '❌' : w > 0 ? '⚠️' : '✅';
console.log(`${icon} ${chain}: ${p}通过/${f}失败/${w}警告`);
for (const item of items.filter(i => i.status !== 'PASS')) {
console.log(` ${item.status === 'FAIL' ? '❌' : '⚠️'} ${item.step}: ${item.detail}`);
}
}
const tp = results.filter(r => r.status === 'PASS').length;
const tf = results.filter(r => r.status === 'FAIL').length;
const tw = results.filter(r => r.status === 'WARN').length;
console.log(`\n总计: ${results.length}项 — ✅${tp}通过 / ❌${tf}失败 / ⚠️${tw}警告`);
console.log('========================================\n');
process.exit(tf > 0 ? 1 : 0);
}
main().catch(e => { console.error('致命错误:', e); process.exit(1); });