import assert from 'node:assert/strict'; import crypto from 'node:crypto'; import { spawn } from 'node:child_process'; import fs from 'node:fs/promises'; import net from 'node:net'; import path from 'node:path'; import { pathToFileURL } from 'node:url'; import pg from 'pg'; const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; const cliOptions = parseCliArgs(process.argv.slice(2)); const databaseUrl = process.env.DATABASE_URL || DEFAULT_DATABASE_URL; const outputDir = cliOptions.outputDir || resolvePathOption(process.env.LAUNCH_SMOKE_OUTPUT_DIR || 'docs/refactor/launch-artifacts'); const stableJsonPath = cliOptions.writePath || resolvePathOption(process.env.LAUNCH_SMOKE_WRITE_PATH || ''); const stableMdPath = cliOptions.writeMdPath || resolvePathOption(process.env.LAUNCH_SMOKE_WRITE_MD_PATH || ''); const startServer = boolEnv('LAUNCH_SMOKE_START_SERVER', !process.env.LAUNCH_SMOKE_API_BASE); const fixedPort = Number(process.env.LAUNCH_SMOKE_API_PORT || 0) || 0; const authMode = normalizeAuthMode(process.env.LAUNCH_SMOKE_AUTH_MODE || 'app_session'); const authSessionSecret = process.env.AUTH_SESSION_SECRET || 'development-session-secret-change-me'; const ids = { tenant: '00000000-0000-0000-0000-000000000001', partnerTenant: '00000000-0000-0000-0000-000000000901', student: '00000000-0000-0000-0000-000000000101', tenantAdmin: '00000000-0000-0000-0000-000000000102', platformAdmin: '00000000-0000-0000-0000-000000000999', region: '00000000-0000-0000-0000-000000000301', plan: '00000000-0000-0000-0000-000000000201', }; let apiBase = process.env.LAUNCH_SMOKE_API_BASE || process.env.API_BASE || ''; let serverProcess = null; let serverLogs = ''; let authTokens = {}; function resolvePathOption(value) { if (!value) return ''; return path.isAbsolute(value) ? value : path.resolve(process.cwd(), value); } function readValueArg(argv, index, flag) { const value = argv[index + 1]; if (!value || value.startsWith('--')) throw new Error(`${flag} requires a file path value.`); return value; } function parseCliArgs(argv = []) { const options = { outputDir: '', writePath: '', writeMdPath: '', }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--json') { continue; } if (arg === '--output-dir') { options.outputDir = resolvePathOption(readValueArg(argv, index, arg)); index += 1; continue; } if (arg.startsWith('--output-dir=')) { options.outputDir = resolvePathOption(arg.slice('--output-dir='.length)); continue; } if (arg === '--write') { options.writePath = resolvePathOption(readValueArg(argv, index, arg)); index += 1; continue; } if (arg.startsWith('--write=')) { options.writePath = resolvePathOption(arg.slice('--write='.length)); continue; } if (arg === '--write-md') { options.writeMdPath = resolvePathOption(readValueArg(argv, index, arg)); index += 1; continue; } if (arg.startsWith('--write-md=')) { options.writeMdPath = resolvePathOption(arg.slice('--write-md='.length)); continue; } throw new Error(`Unknown launch persona smoke option: ${arg}`); } return options; } function boolEnv(key, fallback) { const value = process.env[key]; if (value === undefined || value === '') return fallback; return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); } function normalizeAuthMode(value) { const normalized = String(value || '').toLowerCase().replace(/[-_]/g, ''); if (normalized === 'legacy' || normalized === 'headers') return 'legacy'; if (normalized === 'appsession' || normalized === 'session' || normalized === 'tk') return 'app_session'; return 'app_session'; } function nowIso() { return new Date().toISOString(); } function shanghaiStamp(date = new Date()) { const parts = Object.fromEntries( new Intl.DateTimeFormat('en-CA', { timeZone: 'Asia/Shanghai', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false, }).formatToParts(date).map(part => [part.type, part.value]), ); return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`; } function getFreePort() { return new Promise((resolve, reject) => { const server = net.createServer(); server.on('error', reject); server.listen(fixedPort, '127.0.0.1', () => { const address = server.address(); server.close(() => resolve(address.port)); }); }); } function sleep(ms) { return new Promise(resolve => setTimeout(resolve, ms)); } async function waitForHealth(timeoutMs = 20_000) { const started = Date.now(); let lastError = null; while (Date.now() - started < timeoutMs) { try { const response = await fetch(new URL('/health', apiBase)); const payload = await response.json().catch(() => ({})); if (response.ok && payload.ok) return; } catch (error) { lastError = error; } await sleep(250); } throw new Error(`API server did not become healthy. ${lastError?.message || ''}\n${serverLogs}`); } async function startServerIfNeeded() { if (!startServer) return; const port = await getFreePort(); apiBase = `http://127.0.0.1:${port}`; serverProcess = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], { cwd: process.cwd(), env: { ...process.env, PORT: String(port), DATABASE_URL: databaseUrl, AUTH_SESSION_SECRET: authSessionSecret, ALLOW_LEGACY_AUTH_HEADERS: authMode === 'legacy' ? 'true' : 'false', ALLOW_PLATFORM_ADMIN_KEY: authMode === 'legacy' ? 'true' : 'false', MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '1048576', MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '10485760', }, stdio: ['ignore', 'pipe', 'pipe'], windowsHide: true, }); serverProcess.stdout.on('data', chunk => { serverLogs += chunk.toString(); }); serverProcess.stderr.on('data', chunk => { serverLogs += chunk.toString(); }); await waitForHealth(); } function stopServer() { if (serverProcess && !serverProcess.killed) serverProcess.kill(); } function buildUrl(pathname, query = {}) { const target = new URL(pathname, apiBase); for (const [key, value] of Object.entries(query)) { if (value !== undefined && value !== null && value !== '') target.searchParams.set(key, String(value)); } return target; } async function request(pathname, options = {}) { const token = authTokenFor(options.persona || 'student'); const useSession = authMode === 'app_session' && token; const response = await fetch(buildUrl(pathname, options.query), { method: options.method || 'GET', headers: { 'content-type': 'application/json', ...(options.tenantId === false ? {} : { 'x-tenant-id': options.tenantId || ids.tenant }), ...(useSession ? { authorization: `Bearer ${token}` } : {}), ...(authMode === 'legacy' && options.userId !== false ? { 'x-user-id': options.userId || ids.student } : {}), ...(options.headers || {}), }, body: options.body ? JSON.stringify(options.body) : undefined, }); const payload = await response.json().catch(() => ({})); const expectedStatus = options.expectStatus || 200; assert.equal(response.status, expectedStatus, `${options.method || 'GET'} ${pathname} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`); return payload; } function createSessionToken() { return `tk_${crypto.randomBytes(32).toString('base64url')}`; } function hashSessionToken(token) { return crypto.createHmac('sha256', authSessionSecret).update(token).digest('hex'); } function authTokenFor(persona) { if (authMode !== 'app_session') return ''; return authTokens[persona] || authTokens.student || ''; } async function createSmokeAuthSession(pool, persona, userId, tenantId = ids.tenant) { if (authMode !== 'app_session') return ''; const token = createSessionToken(); await pool.query( ` insert into app_private.auth_sessions ( tenant_id, user_id, token_hash, provider, expires_at, metadata ) values ( $1, $2, $3, 'launch_persona_smoke', now() + interval '2 hours', $4::jsonb ) `, [ tenantId, userId, hashSessionToken(token), JSON.stringify({ createdBy: 'launch-persona-smoke', persona, authMode }), ], ); return token; } async function createSmokeAuthSessions(pool) { if (authMode !== 'app_session') { authTokens = {}; return; } authTokens = { student: await createSmokeAuthSession(pool, 'student', ids.student, ids.tenant), tenantAdmin: await createSmokeAuthSession(pool, 'tenantAdmin', ids.tenantAdmin, ids.tenant), platformAdmin: await createSmokeAuthSession(pool, 'platformAdmin', ids.platformAdmin, ids.tenant), }; } async function one(pool, sql, params = []) { const result = await pool.query(sql, params); return result.rows[0] || null; } function pickAnswer(question) { const correctIndices = Array.isArray(question.correctOptionIndices) ? question.correctOptionIndices : []; if (correctIndices.length) return { selectedOptions: correctIndices.map(item => String(item)) }; if (question.correctOptionIndex !== undefined && question.correctOptionIndex !== null) return { selectedOptions: [String(question.correctOptionIndex)] }; if (Array.isArray(question.options) && question.options.length) return { selectedOptions: ['0'] }; return { answerText: 'launch smoke answer', selfJudgedCorrect: true }; } async function ensureSvipEntitlement(pool) { await pool.query( ` insert into public.entitlements ( tenant_id, user_id, entitlement_type, scope_type, scope_id, source_type, legacy_source_id, starts_at, expires_at, status, metadata ) values ( $1, $2, 'svip', 'tenant', null, 'launch_persona_smoke', 'launch-persona-smoke', now() - interval '1 minute', now() + interval '1 day', 'active', '{"source":"launch_persona_smoke","temporary":true}'::jsonb ) on conflict do nothing `, [ids.tenant, ids.student], ); } async function discoverStudentPath(pool) { const entry = await one( pool, ` select id, name from public.content_entries where tenant_id = $1 and is_active = true and entry_type = 'question_practice' order by sort_order asc, created_at asc limit 1 `, [ids.tenant], ); assert.ok(entry, 'launch smoke needs an active question_practice content entry'); const collection = await one( pool, ` select qc.id, qc.name, qc.node_id as content_node_id from public.question_collections qc where qc.tenant_id = $1 and qc.status = 'active' and exists ( select 1 from public.question_collection_items qci join public.questions q on q.tenant_id = qci.tenant_id and q.id = qci.question_id where qci.tenant_id = qc.tenant_id and qci.collection_id = qc.id and q.status = 'published' ) order by qc.created_at asc limit 1 `, [ids.tenant], ); assert.ok(collection, 'launch smoke needs an active question collection with questions'); const blueprint = await one( pool, ` select id, name, mode from public.practice_blueprints where tenant_id = $1 and collection_id = $2 and status = 'active' order by case mode when 'sequential' then 0 when 'random' then 1 else 2 end, created_at asc limit 1 `, [ids.tenant, collection.id], ); assert.ok(blueprint, 'launch smoke needs an active practice blueprint'); return { entry, collection, blueprint }; } async function studentJourney(pool) { await ensureSvipEntitlement(pool); const pathInfo = await discoverStudentPath(pool); const profile = await request('/api/profile/me'); assert.equal(profile.item?.userId, ids.student, 'student profile should load current user'); const entitlements = await request('/api/commerce/entitlements'); assert.equal(entitlements.summary?.isSvip, true, 'student should be SVIP before practice journey'); const entries = await request('/api/catalog/content-entries', { query: { entryType: 'question_practice' } }); assert.ok(entries.items?.some(item => item.id === pathInfo.entry.id), 'student should see question practice entry'); const collections = await request('/api/catalog/question-collections', { query: { nodeId: pathInfo.collection.content_node_id } }); assert.ok(collections.items?.some(item => item.id === pathInfo.collection.id), 'student should see question collection'); const session = await request('/api/learning/practice-sessions', { method: 'POST', body: { blueprintId: pathInfo.blueprint.id, collectionId: pathInfo.collection.id, questionLimit: 3, metadata: { source: 'launch_persona_smoke' }, }, }); assert.ok(session.item?.id, 'student should create practice session'); assert.notEqual(session.item?.accessMode, 'free', 'SVIP student practice should not be constrained by free quota'); const detail = await request('/api/learning/practice-sessions/detail', { query: { practiceSessionId: session.item.id }, }); const questions = detail.item?.questions || []; assert.ok(questions.length > 0, 'practice detail should include questions'); const firstQuestion = questions[0]; await request('/api/learning/favorites/questions', { method: 'POST', body: { questionId: firstQuestion.id, favorite: true }, }); await request('/api/learning/answers', { method: 'POST', body: { practiceSessionId: session.item.id, questionId: firstQuestion.id, ...pickAnswer(firstQuestion), }, }); const report = await request('/api/learning/practice-sessions/submit', { method: 'POST', body: { practiceSessionId: session.item.id }, }); assert.equal(report.item?.practiceSessionId, session.item.id, 'student should submit practice and receive report'); const favorites = await request('/api/learning/favorites/questions', { query: { limit: 20 } }); assert.ok(favorites.items?.some(item => item.id === firstQuestion.id || item.questionId === firstQuestion.id), 'favorite list should include toggled question'); const wrong = await request('/api/learning/wrong-questions', { query: { limit: 20 } }); const wrongPlan = await request('/api/learning/wrong-questions/review-plan'); const favoriteReview = await request('/api/learning/practice-sessions', { method: 'POST', body: { mode: 'favorite_review', questionLimit: 3, metadata: { source: 'launch_persona_smoke' } }, }); assert.equal(favoriteReview.item?.mode, 'favorite_review', 'student should create favorite review session'); const wrongReview = wrong.items?.length > 0 ? await request('/api/learning/practice-sessions', { method: 'POST', body: { mode: 'wrong_review', questionLimit: 3, metadata: { source: 'launch_persona_smoke' } }, }) : null; return { profile: { profileId: profile.item?.id, userId: profile.item?.userId, avatarPreset: profile.item?.avatarPreset || null }, entitlement: { isSvip: entitlements.summary?.isSvip, count: entitlements.items?.length || 0 }, practice: { sessionId: session.item.id, accessMode: session.item.accessMode, questionCount: questions.length, reportId: report.item?.id, correctCount: report.item?.correctCount, wrongCount: report.item?.wrongCount, }, favorite: { questionId: firstQuestion.id, favoriteCount: favorites.items?.length || 0, reviewSessionId: favoriteReview.item?.id }, wrongReview: { wrongCount: wrong.items?.length || 0, reviewPlanCount: wrongPlan.items?.length || 0, reviewSessionId: wrongReview?.item?.id || null, skippedReason: wrong.items?.length > 0 ? null : 'No wrong questions were present for this student after the sampled answer.', }, }; } async function tenantAdminJourney() { const overview = await request('/api/tenant-admin/overview', { persona: 'tenantAdmin', userId: ids.tenantAdmin }); const dashboard = await request('/api/tenant-admin/dashboard', { persona: 'tenantAdmin', userId: ids.tenantAdmin, query: { timeRange: '7d', regionId: ids.region }, }); const theme = await request('/api/tenant-admin/theme', { persona: 'tenantAdmin', userId: ids.tenantAdmin }); const students = await request('/api/tenant-admin/students', { persona: 'tenantAdmin', userId: ids.tenantAdmin, query: { limit: 10 }, }); const conversion = await request('/api/referral/conversion-report', { persona: 'tenantAdmin', userId: ids.tenantAdmin, query: { limit: 10 }, }); const studentDenied = await request('/api/tenant-admin/dashboard', { expectStatus: 403 }); const crossTenantDenied = await request('/api/tenant-admin/dashboard', { persona: 'tenantAdmin', tenantId: ids.partnerTenant, userId: ids.tenantAdmin, expectStatus: 403, }); return { overview: { tenantId: overview.item?.id, name: overview.item?.name }, dashboard: { tenantId: dashboard.item?.scope?.tenantId, trendBuckets: dashboard.item?.trends?.length || 0, activeHourBuckets: dashboard.item?.activeHours?.length || 0, questionCount: dashboard.item?.cards?.content?.questions || 0, }, theme: { status: theme.item?.status || null, activeTemplateCode: theme.item?.activeTemplateCode || null }, students: { count: students.items?.length || 0 }, referralConversion: { mode: conversion.item?.aggregation?.mode || 'realtime_or_legacy', leadCount: conversion.item?.summary?.leadCount || 0, paidSourceCount: conversion.item?.summary?.paidSourceCount || 0, }, guards: { studentDashboardDenied: studentDenied.code, crossTenantDenied: crossTenantDenied.code, }, }; } async function platformAdminJourney() { const headers = authMode === 'legacy' ? { 'x-platform-admin-key': process.env.PLATFORM_ADMIN_API_KEY || 'local-platform-admin-key' } : {}; const overview = await request('/api/platform-admin/overview', { persona: 'platformAdmin', tenantId: false, userId: false, headers, }); const tenants = await request('/api/platform-admin/tenants', { persona: 'platformAdmin', tenantId: false, userId: false, headers, query: { limit: 10 }, }); const plans = await request('/api/platform-admin/plans', { persona: 'platformAdmin', tenantId: false, userId: false, headers, }); const auditAlerts = await request('/api/platform-admin/audit-alerts', { persona: 'platformAdmin', tenantId: false, userId: false, headers, query: { status: 'open', limit: 10 }, }); const studentDenied = await request('/api/platform-admin/overview', { tenantId: false, expectStatus: 403, }); return { overview: { tenantTotal: overview.item?.tenants?.total || 0, activeTenantCount: overview.item?.tenants?.active || 0, }, tenants: { count: tenants.items?.length || 0 }, plans: { count: plans.items?.length || 0 }, auditAlerts: { openCount: auditAlerts.items?.length || 0 }, guards: { studentPlatformDenied: studentDenied.code }, }; } function stepCell(step, formatPassed) { if (step.status === 'pass') return formatPassed(step.result); return `失败:${step.error?.message || 'unknown error'}`; } async function writeReport(report) { await fs.mkdir(outputDir, { recursive: true }); const jsonPath = path.join(outputDir, `launch-persona-smoke-${shanghaiStamp()}.json`); const mdPath = jsonPath.replace(/\.json$/, '.md'); const jsonPayload = `${JSON.stringify(report, null, 2)}\n`; await fs.writeFile(jsonPath, jsonPayload, 'utf8'); const lines = [ '# 上线前角色旅程烟测报告', '', `生成时间:${new Date(report.finishedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`, '', '| 角色 | 结果 | 关键验证 |', '| --- | --- | --- |', `| 普通学生 | ${report.student.status} | ${stepCell(report.student, result => `SVIP=${result.entitlement.isSvip}; session=${result.practice.sessionId}; favoriteReview=${result.favorite.reviewSessionId}; wrongReview=${result.wrongReview.reviewSessionId || result.wrongReview.skippedReason}`)} |`, `| 租户管理员 | ${report.tenantAdmin.status} | ${stepCell(report.tenantAdmin, result => `dashboardBuckets=${result.dashboard.trendBuckets}; studentDenied=${result.guards.studentDashboardDenied}; crossTenant=${result.guards.crossTenantDenied}`)} |`, `| 平台管理员 | ${report.platformAdmin.status} | ${stepCell(report.platformAdmin, result => `tenants=${result.overview.tenantTotal}; studentDenied=${result.guards.studentPlatformDenied}`)} |`, '', '说明:该脚本会写入少量 `launch_persona_smoke` 测试权益、练习 session、答题和收藏记录。生产环境仅建议在灰度/演练租户运行。', '', ]; const mdPayload = `${lines.join('\n')}\n`; await fs.writeFile(mdPath, mdPayload, 'utf8'); if (stableJsonPath) { await fs.mkdir(path.dirname(stableJsonPath), { recursive: true }); await fs.writeFile(stableJsonPath, jsonPayload, 'utf8'); } if (stableMdPath) { await fs.mkdir(path.dirname(stableMdPath), { recursive: true }); await fs.writeFile(stableMdPath, mdPayload, 'utf8'); } return { jsonPath, mdPath, stableJsonPath, stableMdPath }; } async function runStep(name, fn) { const startedAt = nowIso(); try { const result = await fn(); return { name, status: 'pass', startedAt, finishedAt: nowIso(), result }; } catch (error) { return { name, status: 'fail', startedAt, finishedAt: nowIso(), error: { message: error?.message || String(error), stack: error?.stack || '', }, }; } } async function main() { const pool = new pg.Pool({ connectionString: databaseUrl, max: 4 }); try { await createSmokeAuthSessions(pool); await startServerIfNeeded(); if (!apiBase) throw new Error('LAUNCH_SMOKE_API_BASE is required when LAUNCH_SMOKE_START_SERVER=false.'); const report = { startedAt: nowIso(), finishedAt: '', apiBase, authMode, databaseUrl: databaseUrl.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@'), student: await runStep('student', () => studentJourney(pool)), tenantAdmin: await runStep('tenant-admin', tenantAdminJourney), platformAdmin: await runStep('platform-admin', platformAdminJourney), }; report.finishedAt = nowIso(); report.status = [report.student, report.tenantAdmin, report.platformAdmin].every(item => item.status === 'pass') ? 'pass' : 'fail'; const files = await writeReport(report); console.log(`[launch-smoke] status=${report.status}`); console.log(`[launch-smoke] wrote ${files.jsonPath}`); console.log(`[launch-smoke] wrote ${files.mdPath}`); if (files.stableJsonPath) console.log(`[launch-smoke] wrote ${files.stableJsonPath}`); if (files.stableMdPath) console.log(`[launch-smoke] wrote ${files.stableMdPath}`); if (report.status !== 'pass') process.exitCode = 1; } finally { await pool.end(); stopServer(); } } if (process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href) { main().catch(error => { console.error(error); if (serverLogs) console.error(serverLogs); stopServer(); process.exit(1); }); } export { parseCliArgs };