/** * SSA Phase 2A 端到端自动化测试 * * 运行方式:node run_e2e_test.js * * 测试层次: * - Layer 1: R 服务 - 7 个统计工具 * - Layer 2: Python DataProfile API * - Layer 3: Node.js 后端 API */ const http = require('http'); const https = require('https'); const fs = require('fs'); const path = require('path'); // ==================== 配置 ==================== const CONFIG = { R_SERVICE_URL: 'http://localhost:8082', PYTHON_SERVICE_URL: 'http://localhost:8000', BACKEND_URL: 'http://localhost:3000', USERNAME: '13800000001', PASSWORD: '123456', TEST_CSV_PATH: path.join(__dirname, 'test.csv') }; // ==================== HTTP 请求工具 ==================== function httpRequest(urlStr, options = {}) { return new Promise((resolve, reject) => { const url = new URL(urlStr); const isHttps = url.protocol === 'https:'; const lib = isHttps ? https : http; const reqOptions = { hostname: url.hostname, port: url.port || (isHttps ? 443 : 80), path: url.pathname + url.search, method: options.method || 'GET', headers: { 'Content-Type': 'application/json', ...options.headers }, timeout: options.timeout || 60000 }; const req = lib.request(reqOptions, (res) => { let data = ''; res.on('data', chunk => data += chunk); res.on('end', () => { try { resolve({ status: res.statusCode, data: JSON.parse(data) }); } catch { resolve({ status: res.statusCode, data }); } }); }); req.on('error', reject); req.on('timeout', () => { req.destroy(); reject(new Error('Request timeout')); }); if (options.body) { req.write(JSON.stringify(options.body)); } req.end(); }); } // ==================== 日志工具 ==================== function log(emoji, message, details) { console.log(`${emoji} ${message}`); if (details) { const str = JSON.stringify(details, null, 2); const lines = str.split('\n').slice(0, 10); console.log(' ', lines.join('\n ')); } } const success = (msg, details) => log('✅', msg, details); const error = (msg, details) => log('❌', msg, details); const info = (msg) => log('ℹ️', msg); function section(title) { console.log('\n' + '='.repeat(60)); console.log(` ${title}`); console.log('='.repeat(60) + '\n'); } // ==================== 加载测试数据 ==================== function loadTestData() { const csvContent = fs.readFileSync(CONFIG.TEST_CSV_PATH, 'utf-8'); const lines = csvContent.trim().split('\n'); const headers = lines[0].split(','); const data = []; for (let i = 1; i < lines.length; i++) { const values = lines[i].split(','); const row = {}; for (let j = 0; j < headers.length; j++) { const val = values[j]; if (val === '' || val === undefined) { row[headers[j]] = null; } else if (!isNaN(Number(val))) { row[headers[j]] = Number(val); } else { row[headers[j]] = val; } } data.push(row); } return data; } // ==================== Layer 1: R 服务测试 ==================== async function testRService() { section('Layer 1: R 服务测试(7 个统计工具 + JIT 护栏)'); const data = loadTestData(); info(`测试数据: ${data.length} 行, ${Object.keys(data[0]).length} 列`); const results = []; const dataSource = { type: 'inline', data }; // 1. 健康检查 try { const res = await httpRequest(`${CONFIG.R_SERVICE_URL}/health`); success('R 服务健康检查', { version: res.data.version, tools_loaded: res.data.tools_loaded }); } catch (e) { error('R 服务连接失败', e.message); return { passed: false, results }; } // 测试配置 const tests = [ { name: 'ST_DESCRIPTIVE (描述性统计)', endpoint: '/api/v1/skills/ST_DESCRIPTIVE', body: { data_source: dataSource, params: { variables: ['age', 'bmi', 'time'], group_var: 'sex' } }, extract: (r) => ({ summary: r.results?.summary }) }, { name: 'ST_T_TEST_IND (独立样本T检验)', endpoint: '/api/v1/skills/ST_T_TEST_IND', body: { data_source: dataSource, params: { group_var: 'sex', value_var: 'age' }, guardrails: { check_normality: true } }, extract: (r) => ({ p: r.results?.p_value_fmt, t: r.results?.statistic }) }, { name: 'ST_MANN_WHITNEY (Mann-Whitney U)', endpoint: '/api/v1/skills/ST_MANN_WHITNEY', body: { data_source: dataSource, params: { group_var: 'sex', value_var: 'bmi' } }, extract: (r) => ({ p: r.results?.p_value_fmt, U: r.results?.statistic_U }) }, { name: 'ST_CHI_SQUARE (卡方检验)', endpoint: '/api/v1/skills/ST_CHI_SQUARE', body: { data_source: dataSource, params: { var1: 'sex', var2: 'smoke' } }, extract: (r) => ({ p: r.results?.p_value_fmt, chi2: r.results?.statistic }) }, { name: 'ST_CORRELATION (相关分析)', endpoint: '/api/v1/skills/ST_CORRELATION', body: { data_source: dataSource, params: { var_x: 'age', var_y: 'bmi', method: 'auto' } }, extract: (r) => ({ r: r.results?.statistic, p: r.results?.p_value_fmt, method: r.results?.method_code }) }, { name: 'ST_LOGISTIC_BINARY (Logistic回归)', endpoint: '/api/v1/skills/ST_LOGISTIC_BINARY', body: { data_source: dataSource, params: { outcome_var: 'Yqol', predictors: ['age', 'bmi', 'sex', 'smoke'] } }, extract: (r) => ({ n: r.results?.n_predictors, sig: r.results?.coefficients?.filter(c => c.significant)?.length, aic: r.results?.model_fit?.aic }) }, { name: 'ST_T_TEST_PAIRED (配对T检验)', endpoint: '/api/v1/skills/ST_T_TEST_PAIRED', body: { data_source: dataSource, params: { before_var: 'mouth_open', after_var: 'bucal_relax' }, guardrails: { check_normality: true } }, extract: (r) => ({ p: r.results?.p_value_fmt, diff: r.results?.descriptive?.difference?.mean }) }, { name: 'JIT 护栏检查', endpoint: '/api/v1/guardrails/jit', body: { data_source: dataSource, tool_code: 'ST_T_TEST_IND', params: { group_var: 'sex', value_var: 'age' } }, extract: (r) => ({ checks: r.checks?.length, all_passed: r.all_checks_passed }) } ]; for (const test of tests) { try { const start = Date.now(); const res = await httpRequest(`${CONFIG.R_SERVICE_URL}${test.endpoint}`, { method: 'POST', body: test.body }); const elapsed = Date.now() - start; if (res.data.status === 'success') { const extracted = test.extract(res.data); success(`${test.name} (${elapsed}ms)`, extracted); results.push({ name: test.name, status: 'success', time: elapsed }); } else { error(`${test.name} 失败`, res.data.error || res.data.message); results.push({ name: test.name, status: 'failed', time: elapsed }); } } catch (e) { error(`${test.name} 异常`, e.message); results.push({ name: test.name, status: 'error', error: e.message }); } } const passed = results.filter(r => r.status === 'success').length; info(`\nR 服务测试: ${passed}/${results.length} 通过`); return { passed: passed === results.length, results, passedCount: passed, total: results.length }; } // ==================== Layer 2: Python DataProfile 测试 ==================== async function testPythonDataProfile() { section('Layer 2: Python DataProfile 测试'); const data = loadTestData(); // 健康检查 try { const res = await httpRequest(`${CONFIG.PYTHON_SERVICE_URL}/api/health`); success('Python 服务健康检查', { status: res.data.status }); } catch (e) { error('Python 服务连接失败', e.message); return { passed: false }; } // DataProfile 测试 try { const start = Date.now(); const res = await httpRequest(`${CONFIG.PYTHON_SERVICE_URL}/api/ssa/data-profile`, { method: 'POST', body: { data, max_unique_values: 20, include_quality_score: true } }); const elapsed = Date.now() - start; if (res.data.success) { const profile = res.data.profile; const quality = res.data.quality; success(`DataProfile 生成成功 (${elapsed}ms)`, { rows: profile.summary.totalRows, columns: profile.summary.totalColumns, numeric: profile.summary.numericColumns, categorical: profile.summary.categoricalColumns, missing_rate: profile.summary.overallMissingRate + '%', quality_score: quality?.score, quality_grade: quality?.grade }); info('部分列画像:'); for (const col of profile.columns.slice(0, 5)) { console.log(` - ${col.name} [${col.type}]: 缺失${col.missingRate}%`); } return { passed: true, profile, quality }; } else { error('DataProfile 生成失败', res.data); return { passed: false }; } } catch (e) { error('DataProfile 请求异常', e.message); return { passed: false }; } } // ==================== Layer 3: Node.js 后端 API 测试 ==================== async function testBackendAPI() { section('Layer 3: Node.js 后端 API 测试'); let token = ''; // 1. 登录 try { const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/auth/login/password`, { method: 'POST', body: { phone: CONFIG.USERNAME, password: CONFIG.PASSWORD } }); if (res.data.success && res.data.data?.tokens?.accessToken) { token = res.data.data.tokens.accessToken; success('登录成功', { user: res.data.data.user?.name, token: token.substring(0, 20) + '...' }); } else if (res.data.data?.token) { token = res.data.data.token; success('登录成功', { token: token.substring(0, 20) + '...' }); } else { error('登录失败', res.data); return { passed: false }; } } catch (e) { error('登录请求异常', e.message); return { passed: false }; } const data = loadTestData(); let sessionId = ''; // 2. 创建 SSA 会话 try { const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/ssa/sessions`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: { title: 'Phase2A 自动化测试 - ' + new Date().toISOString().slice(0, 19), dataPayload: data, dataSchema: { columns: Object.keys(data[0]).map(name => ({ name, type: typeof data[0][name] === 'number' ? 'numeric' : 'categorical' })) } } }); if (res.data.sessionId || res.data.session?.id || res.data.data?.id || res.data.id) { sessionId = res.data.sessionId || res.data.session?.id || res.data.data?.id || res.data.id; success('SSA 会话创建成功', { sessionId }); } else { error('SSA 会话创建失败', res.data); return { passed: false, token }; } } catch (e) { error('创建会话异常', e.message); return { passed: false, token }; } // 3. 生成 DataProfile(通过会话) try { const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/ssa/workflow/profile`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: { sessionId } }); if (res.data.success) { success('会话 DataProfile 生成成功', { rows: res.data.profile?.summary?.totalRows }); } else { info('DataProfile: ' + (res.data.message || '跳过')); } } catch (e) { info('DataProfile 端点: ' + e.message); } // 4. 规划工作流 let workflowId = ''; try { const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/ssa/workflow/plan`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, body: { sessionId, userQuery: '比较不同性别的年龄和BMI差异,分析是否存在统计学显著差异' } }); if (res.data.success && res.data.plan) { const plan = res.data.plan; workflowId = res.data.workflowId || ''; success('工作流规划成功', { workflowId, goal: plan.goal, steps: plan.steps?.length, tools: plan.steps?.map(s => s.toolCode) }); } else { error('工作流规划失败', res.data); return { passed: false, token, sessionId }; } } catch (e) { error('工作流规划异常', e.message); return { passed: false, token, sessionId }; } // 5. 执行工作流(如果有 workflowId) if (workflowId) { try { info(`执行工作流 ${workflowId}...`); const res = await httpRequest(`${CONFIG.BACKEND_URL}/api/v1/ssa/workflow/${workflowId}/execute`, { method: 'POST', headers: { Authorization: `Bearer ${token}` }, timeout: 120000 // 2 分钟超时 }); if (res.data.success) { success('工作流执行成功', { status: res.data.result?.status, completedSteps: res.data.result?.completedSteps, totalSteps: res.data.result?.totalSteps, hasConclusion: !!res.data.result?.conclusion }); // 显示结论摘要 if (res.data.result?.conclusion) { info('结论摘要:'); console.log(' ' + (res.data.result.conclusion.summary || '').substring(0, 200) + '...'); } } else { error('工作流执行失败', res.data); } } catch (e) { error('工作流执行异常', e.message); } } return { passed: true, token, sessionId, workflowId }; } // ==================== 主函数 ==================== async function main() { console.log('\n'); console.log('╔══════════════════════════════════════════════════════════╗'); console.log('║ SSA Phase 2A 端到端自动化测试 ║'); console.log('║ ' + new Date().toISOString().slice(0, 19) + ' ║'); console.log('╚══════════════════════════════════════════════════════════╝'); const results = { layer1: { passed: false }, layer2: { passed: false }, layer3: { passed: false } }; // Layer 1: R 服务 try { results.layer1 = await testRService(); } catch (e) { error('Layer 1 测试异常', e.message); } // Layer 2: Python DataProfile try { results.layer2 = await testPythonDataProfile(); } catch (e) { error('Layer 2 测试异常', e.message); } // Layer 3: Node.js 后端 try { results.layer3 = await testBackendAPI(); } catch (e) { error('Layer 3 测试异常', e.message); } // 最终汇总 section('测试汇总报告'); const layers = [ { name: 'Layer 1: R 服务 (7工具+护栏)', result: results.layer1 }, { name: 'Layer 2: Python DataProfile', result: results.layer2 }, { name: 'Layer 3: Node.js 后端 API', result: results.layer3 } ]; let allPassed = true; for (const layer of layers) { const status = layer.result.passed ? '✅ 通过' : '❌ 失败'; let extra = ''; if (layer.result.passedCount !== undefined) { extra = ` (${layer.result.passedCount}/${layer.result.total})`; } console.log(`${status} ${layer.name}${extra}`); if (!layer.result.passed) allPassed = false; } console.log('\n' + '─'.repeat(60)); if (allPassed) { console.log('🎉 Phase 2A 端到端测试全部通过!系统已准备就绪!'); } else { console.log('⚠️ 部分测试未通过,请检查上述错误并修复'); } console.log('─'.repeat(60) + '\n'); process.exit(allPassed ? 0 : 1); } main().catch(err => { console.error('测试脚本执行失败:', err); process.exit(1); });