import crypto from 'node:crypto'; import fs from 'node:fs/promises'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { USER_COOKIE } from '../user-auth.mjs'; import { PUBLISH_ROOT_DIR } from '../user-publish.mjs'; const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); const scenarioH5Root = path.resolve(process.env.MEMIND_SCENARIO_H5_ROOT ?? repoRoot); export function sleep(ms) { return new Promise((resolve) => setTimeout(resolve, ms)); } export function createReporter() { const checks = []; const issues = []; return { checks, issues, pass(label, detail = '') { checks.push({ ok: true, label, detail }); console.log(`✔ ${label}${detail ? `: ${detail}` : ''}`); }, fail(label, detail = '') { issues.push({ label, detail }); checks.push({ ok: false, label, detail }); console.error(`✘ ${label}${detail ? `: ${detail}` : ''}`); }, summary() { console.log('\n=== 汇总 ==='); console.log(`通过: ${checks.filter((item) => item.ok).length}/${checks.length}`); if (issues.length) { console.log('失败项:'); for (const item of issues) { console.log(` - ${item.label}: ${item.detail}`); } return 1; } console.log('场景测试全部通过'); return 0; }, }; } export function resolvePortalBase(port) { return `http://127.0.0.1:${port}`; } export async function loginViaApi(baseUrl, { username, password }, reporter) { const response = await fetch(`${baseUrl}/auth/login`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ username, password }), }); const body = await response.json().catch(() => ({})); if (!response.ok || !body?.authenticated) { throw new Error(`登录失败 ${response.status}: ${JSON.stringify(body)}`); } const setCookie = response.headers.getSetCookie?.() ?? []; const cookieLine = setCookie.find((line) => line.startsWith(`${USER_COOKIE}=`)) ?? response.headers.get('set-cookie'); const match = String(cookieLine ?? '').match(new RegExp(`${USER_COOKIE}=([^;]+)`)); const token = match?.[1] ? decodeURIComponent(match[1]) : null; if (!token) { throw new Error('登录成功但未解析到会话 Cookie'); } reporter.pass('登录', `${username} (${body.user?.id ?? 'unknown-id'})`); return { token, cookie: `${USER_COOKIE}=${token}`, user: body.user ?? null, }; } export async function logoutViaApi(baseUrl, cookie, reporter) { const response = await fetch(`${baseUrl}/auth/logout`, { method: 'POST', headers: { Cookie: cookie }, }); const body = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(`登出失败 ${response.status}: ${JSON.stringify(body)}`); } const setCookie = response.headers.getSetCookie?.() ?? []; const cleared = setCookie.some((line) => /Max-Age=0/i.test(line) || /Expires=Thu, 01 Jan 1970/i.test(line)); if (!cleared) { reporter.fail('登出 Cookie', '响应未包含清 Cookie 的 Set-Cookie'); } else { reporter.pass('登出 Cookie', 'Set-Cookie 已清除会话'); } const statusResponse = await fetch(`${baseUrl}/auth/status`, { headers: { Cookie: cookie }, }); const statusBody = await statusResponse.json().catch(() => ({})); if (statusBody?.authenticated) { reporter.fail('登出状态', `/auth/status 仍为 authenticated`); } else { reporter.pass('登出状态', '未登录'); } const protectedResponse = await fetch(`${baseUrl}/api/me`, { headers: { Cookie: cookie }, }); if (protectedResponse.status === 401 || protectedResponse.status === 403) { reporter.pass('登出后 API', `${protectedResponse.status} 已拒绝旧 Cookie`); } else { reporter.fail('登出后 API', `/api/me 返回 ${protectedResponse.status},旧 Cookie 仍有效`); } return body; } function buildUserMessage(text, { selectedChatSkill = null } = {}) { const metadata = { userVisible: true, displayText: text }; if (selectedChatSkill) { metadata.memindRun = { selectedChatSkill }; } return { id: crypto.randomUUID(), role: 'user', content: [{ type: 'text', text }], metadata, }; } export async function createAgentRun(baseUrl, cookie, { message, sessionId = null, selectedChatSkill = null, selectedAssetIds = null, }) { const requestId = crypto.randomUUID(); const body = { request_id: requestId, user_message: buildUserMessage(message, { selectedChatSkill }), }; if (sessionId) body.session_id = sessionId; if (Array.isArray(selectedAssetIds) && selectedAssetIds.length > 0) { body.selected_asset_ids = selectedAssetIds; } const response = await fetch(`${baseUrl}/api/agent/runs`, { method: 'POST', headers: { 'Content-Type': 'application/json', Cookie: cookie, }, body: JSON.stringify(body), }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(`POST /api/agent/runs ${response.status}: ${JSON.stringify(payload)}`); } const run = payload.run ?? payload; return { runId: run.id, requestId, sessionId: run.sessionId ?? run.agent_session_id ?? sessionId ?? null, status: run.status, }; } export async function getAgentRun(baseUrl, cookie, runId) { const response = await fetch(`${baseUrl}/api/agent/runs/${runId}`, { headers: { Cookie: cookie }, }); const payload = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(`GET /api/agent/runs/${runId} ${response.status}: ${JSON.stringify(payload)}`); } return payload.run ?? payload; } export async function getSession(baseUrl, cookie, sessionId) { const response = await fetch(`${baseUrl}/api/sessions/${encodeURIComponent(sessionId)}`, { headers: { Cookie: cookie }, }); const payload = await response.json().catch(() => ({})); if (!response.ok) { return { ok: false, status: response.status, payload }; } return { ok: true, session: payload }; } export function extractAssistantTexts(sessionPayload) { const conversation = sessionPayload?.conversation ?? sessionPayload?.session?.conversation ?? sessionPayload?.messages ?? []; const texts = []; for (const message of conversation) { if (message?.role !== 'assistant') continue; const content = message?.content; if (typeof content === 'string') texts.push(content); else if (Array.isArray(content)) { texts.push( content .map((item) => (item?.type === 'text' ? item.text ?? '' : '')) .join(''), ); } } return texts.filter(Boolean); } export function extractPublicLinks(text, baseUrl) { const links = new Set(); const markdown = /\[[^\]]+\]\((https?:\/\/[^)]+|\/MindSpace\/[^)]+)\)/gi; const bare = /(https?:\/\/[^\s)]+?\/MindSpace\/[^\s)]+?\.html)/gi; const relative = /(\/MindSpace\/[^\s)]+?\.html)/gi; for (const pattern of [markdown, bare, relative]) { for (const match of text.matchAll(pattern)) { const raw = match[1] ?? match[0]; if (!raw) continue; links.add(raw.startsWith('http') ? raw : new URL(raw, baseUrl).href); } } return [...links]; } async function listPublicHtmlFiles(publishDir) { const publicDir = path.join(publishDir, 'public'); try { const entries = await fs.readdir(publicDir, { withFileTypes: true }); const files = []; for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.html')) continue; const fullPath = path.join(publicDir, entry.name); const stat = await fs.stat(fullPath); files.push({ name: entry.name, fullPath, mtimeMs: stat.mtimeMs, relativePublicPath: `public/${entry.name}`, }); } return files; } catch { return []; } } export async function snapshotPublicHtml(publishKey) { const publishDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey); return listPublicHtmlFiles(publishDir); } export async function waitForRunTerminal(baseUrl, cookie, runId, timeoutMs) { const started = Date.now(); let last = null; while (Date.now() - started < timeoutMs) { last = await getAgentRun(baseUrl, cookie, runId); if (['succeeded', 'failed'].includes(last.status)) { return last; } await sleep(2000); } throw new Error(`run ${runId} 超时未完成,最后状态 ${last?.status ?? 'unknown'}`); } export async function waitForAssistantGrowth(baseUrl, cookie, sessionId, { previousCount = 0, previousCombinedLength = 0, minChars = 1, timeoutMs = 120000, }) { const started = Date.now(); while (Date.now() - started < timeoutMs) { const result = await getSession(baseUrl, cookie, sessionId); if (result.ok) { const texts = extractAssistantTexts(result.session); const combined = texts.join('\n').trim(); const grew = texts.length > previousCount || combined.length > previousCombinedLength; if (grew && combined.length >= minChars) { return { texts, combined, elapsedMs: Date.now() - started, count: texts.length, }; } } await sleep(2000); } return null; } export async function verifyPageAccess({ baseUrl, cookie, publishKey, replyText, htmlBefore = [], expect = {}, reporter, }) { const keywords = expect.keywords ?? []; const links = extractPublicLinks(replyText, baseUrl); let pageUrl = links.find((url) => /\/MindSpace\/.+\.html/i.test(url)) ?? null; if (!pageUrl && publishKey) { const htmlAfter = await listPublicHtmlFiles(path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey)); const beforeSet = new Set(htmlBefore.map((item) => item.fullPath)); const fresh = htmlAfter .filter((item) => !beforeSet.has(item.fullPath)) .sort((a, b) => b.mtimeMs - a.mtimeMs); if (fresh[0]) { pageUrl = new URL( `/MindSpace/${publishKey}/${fresh[0].relativePublicPath}`, baseUrl, ).href; reporter.pass('页面落盘', fresh[0].relativePublicPath); } } if (expect.requirePublicLink !== false) { if (!pageUrl) { reporter.fail('公网页面链接', '回复中未找到 MindSpace 链接,public/ 也无新 HTML'); return false; } reporter.pass('公网页面链接', pageUrl); } if (pageUrl && expect.requireHttp200 !== false) { const response = await fetch(pageUrl, { headers: cookie ? { Cookie: cookie } : {} }); const html = await response.text(); if (response.status !== 200) { reporter.fail('页面 HTTP 状态', `${response.status} ${pageUrl}`); return false; } reporter.pass('页面 HTTP 200', pageUrl); if (keywords.length) { const hit = keywords.filter((word) => html.includes(word)); if (hit.length === 0) { reporter.fail('页面内容关键词', `未命中: ${keywords.join(', ')}`); return false; } reporter.pass('页面内容关键词', hit.join(', ')); } if (expect.requireMindspaceCover && !/mindspace-cover/i.test(html)) { reporter.fail('页面元数据', '缺少 mindspace-cover'); return false; } } return true; } export async function verifySurveyDelivery({ publishKey, replyText = '', expect = {}, reporter, }) { const publishDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey); const publicDir = path.join(publishDir, 'public'); const policyDir = path.join(publishDir, '.mindspace', 'page-data-policies'); const sqlitePath = path.join(publishDir, '.mindspace', 'private-data.sqlite'); const forbidReply = expect.forbidReplyPatterns ?? []; for (const pattern of forbidReply) { if (pattern && replyText.includes(pattern)) { reporter.fail('回复禁用模式', `命中 ${pattern}`); } } if (forbidReply.length && !forbidReply.some((pattern) => pattern && replyText.includes(pattern))) { reporter.pass('回复禁用模式', '未出现旁路 API / PLACEHOLDER'); } let htmlFiles = []; try { const entries = await fs.readdir(publicDir, { withFileTypes: true }); htmlFiles = entries .filter((entry) => entry.isFile() && entry.name.endsWith('.html')) .map((entry) => entry.name); } catch { reporter.fail('问卷 HTML', 'public/ 目录不存在'); return false; } const surveyLike = htmlFiles.filter((name) => /survey|问卷|feature/i.test(name)); const adminLike = htmlFiles.filter((name) => /admin|后台|manage/i.test(name)); if (surveyLike.length === 0) { reporter.fail('问卷 HTML', `public/ 中未找到问卷页,现有: ${htmlFiles.join(', ') || '(空)'}`); } else { reporter.pass('问卷 HTML', surveyLike.join(', ')); } if (adminLike.length === 0) { reporter.fail('后台 HTML', `public/ 中未找到后台页,现有: ${htmlFiles.join(', ') || '(空)'}`); } else { reporter.pass('后台 HTML', adminLike.join(', ')); } const forbidHtml = expect.forbidHtmlPatterns ?? []; for (const name of [...surveyLike, ...adminLike]) { const html = await fs.readFile(path.join(publicDir, name), 'utf8'); if (!html.includes('page-data-client.js')) { reporter.fail(`${name} 脚本`, '未引用 page-data-client.js'); } for (const pattern of forbidHtml) { if (pattern && html.includes(pattern)) { reporter.fail(`${name} 禁用模式`, `命中 ${pattern}`); } } } if (surveyLike.length && adminLike.length) { reporter.pass('Page Data 客户端', '问卷/后台 HTML 已引用 page-data-client.js'); } if (expect.requirePolicy) { try { const policies = await fs.readdir(policyDir); const jsonPolicies = policies.filter((name) => name.endsWith('.json')); if (jsonPolicies.length === 0) { reporter.fail('Page Data 策略', 'page-data-policies/ 为空'); } else { reporter.pass('Page Data 策略', `${jsonPolicies.length} 个 policy 文件`); } } catch { reporter.fail('Page Data 策略', '缺少 .mindspace/page-data-policies/'); } } if (expect.requireDataset) { try { await fs.stat(sqlitePath); reporter.pass('私有 SQLite', 'private-data.sqlite 存在'); } catch { reporter.fail('私有 SQLite', 'private-data.sqlite 不存在'); } } const links = extractPublicLinks(replyText, 'http://127.0.0.1:8081'); if (links.length >= 2) { reporter.pass('交付链接', `${links.length} 个链接`); } else if (links.length === 1) { reporter.fail('交付链接', '仅 1 个链接,期望问卷 + 后台'); } else { reporter.fail('交付链接', '回复中未找到 MindSpace 链接'); } return true; } export const CHILDREN_HOBBY_DIET_SURVEY = { userId: '32035858-9a20-425b-89da-c118ef0779aa', username: 'john4', surveyHtml: 'children-hobby-survey.html', adminHtml: 'children-hobby-admin.html', dataset: 'children_hobby_survey', dietField: 'q4_diet_meals', dietKeywords: ['饮食', '几顿', 'q4_diet_meals'], }; async function readJsonIfExists(filePath) { try { const raw = await fs.readFile(filePath, 'utf8'); return JSON.parse(raw); } catch { return null; } } async function findPublicSurveyPolicy(publishKey, datasetName) { const policyDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey, '.mindspace', 'page-data-policies'); let entries = []; try { entries = await fs.readdir(policyDir); } catch { return null; } for (const name of entries.filter((item) => item.endsWith('.json'))) { const policy = await readJsonIfExists(path.join(policyDir, name)); const dataset = policy?.datasets?.[datasetName]; if (policy?.accessMode === 'public' && dataset?.insert) { return { pageId: policy.pageId, policy, fileName: name }; } } return null; } export async function verifyChildrenHobbyDietSurvey({ publishKey = CHILDREN_HOBBY_DIET_SURVEY.userId, baseUrl = resolvePortalBase(Number(process.env.H5_PORT ?? 8081)), reporter, testInsert = true, spec = CHILDREN_HOBBY_DIET_SURVEY, } = {}) { const publishDir = path.join(scenarioH5Root, PUBLISH_ROOT_DIR, publishKey); const publicDir = path.join(publishDir, 'public'); const sqlitePath = path.join(publishDir, '.mindspace', 'private-data.sqlite'); let ok = true; const surveyPath = path.join(publicDir, spec.surveyHtml); const adminPath = path.join(publicDir, spec.adminHtml); try { const [surveyHtml, adminHtml] = await Promise.all([ fs.readFile(surveyPath, 'utf8'), fs.readFile(adminPath, 'utf8'), ]); reporter.pass('问卷 HTML 文件', spec.surveyHtml); reporter.pass('后台 HTML 文件', spec.adminHtml); const dietHits = spec.dietKeywords.filter((keyword) => surveyHtml.includes(keyword)); if (dietHits.length !== spec.dietKeywords.length) { reporter.fail( '问卷饮食字段', `未命中: ${spec.dietKeywords.filter((keyword) => !surveyHtml.includes(keyword)).join(', ')}`, ); ok = false; } else { reporter.pass('问卷饮食字段', dietHits.join(', ')); } if (!surveyHtml.includes(`insertRow('${spec.dataset}'`) || !surveyHtml.includes(spec.dietField)) { reporter.fail('问卷提交字段', `insertRow 未包含 ${spec.dietField}`); ok = false; } else { reporter.pass('问卷提交字段', `${spec.dataset}.${spec.dietField}`); } const adminHasDietLabel = spec.dietKeywords.some((keyword) => adminHtml.includes(keyword)); if (!adminHasDietLabel || !adminHtml.includes(spec.dietField)) { reporter.fail('后台饮食列', `未展示 ${spec.dietField}`); ok = false; } else { reporter.pass('后台饮食列', spec.dietField); } if (!surveyHtml.includes('page-data-client.js') || !adminHtml.includes('page-data-client.js')) { reporter.fail('Page Data 客户端', '问卷/后台未引用 page-data-client.js'); ok = false; } else { reporter.pass('Page Data 客户端', 'page-data-client.js'); } } catch (error) { reporter.fail('HTML 读取', error instanceof Error ? error.message : String(error)); ok = false; } const policyInfo = await findPublicSurveyPolicy(publishKey, spec.dataset); if (!policyInfo) { reporter.fail('公开问卷策略', '未找到 public insert policy'); ok = false; } else { reporter.pass('公开问卷策略', `${policyInfo.fileName} (${policyInfo.pageId})`); const insertCols = policyInfo.policy?.datasets?.[spec.dataset]?.columns?.insert ?? []; if (!insertCols.includes(spec.dietField)) { reporter.fail('策略 insert 列', `${spec.dietField} 不在白名单: ${insertCols.join(', ')}`); ok = false; } else { reporter.pass('策略 insert 列', insertCols.join(', ')); } } try { await fs.stat(sqlitePath); reporter.pass('私有 SQLite', '.mindspace/private-data.sqlite'); } catch { reporter.fail('私有 SQLite', 'private-data.sqlite 不存在'); ok = false; } const surveyUrl = new URL( `/MindSpace/${publishKey}/public/${spec.surveyHtml}`, baseUrl, ).href; const adminUrl = new URL( `/MindSpace/${publishKey}/public/${spec.adminHtml}`, baseUrl, ).href; for (const [label, url] of [['问卷页 HTTP', surveyUrl], ['后台页 HTTP', adminUrl]]) { const response = await fetch(url); if (response.status !== 200) { reporter.fail(label, `${response.status} ${url}`); ok = false; continue; } const html = await response.text(); if (!html.includes('__MINDSPACE_PAGE_DATA__')) { reporter.fail(`${label} Page Data 注入`, '响应缺少 __MINDSPACE_PAGE_DATA__'); ok = false; } else { reporter.pass(`${label} Page Data 注入`, url); } } if (testInsert && policyInfo?.pageId) { const payload = { q1_age_group: '7-9岁', q2_favorite_hobby: '阅读写作', q3_development_area: 'automated verify insert', [spec.dietField]: '4顿(3正餐+1加餐)', }; const response = await fetch( `${baseUrl}/api/public/pages/${policyInfo.pageId}/data/${spec.dataset}/rows`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(payload), }, ); const body = await response.json().catch(() => ({})); if (!response.ok) { reporter.fail('公开 insert API', `${response.status} ${JSON.stringify(body)}`); ok = false; } else if (!body?.data?.row?.[spec.dietField]) { reporter.fail('公开 insert API', `响应缺少 ${spec.dietField}`); ok = false; } else { reporter.pass('公开 insert API', `${spec.dietField}=${body.data.row[spec.dietField]}`); } } return ok; } export async function loadScenario(scenarioId) { const scenarioPath = path.join(repoRoot, 'scenarios', `${scenarioId}.json`); const raw = await fs.readFile(scenarioPath, 'utf8'); return JSON.parse(raw); } export async function listScenarios() { const dir = path.join(repoRoot, 'scenarios'); const entries = await fs.readdir(dir, { withFileTypes: true }); const scenarios = []; for (const entry of entries) { if (!entry.isFile() || !entry.name.endsWith('.json') || entry.name.startsWith('_')) continue; const scenario = JSON.parse(await fs.readFile(path.join(dir, entry.name), 'utf8')); scenarios.push({ id: scenario.id ?? entry.name.replace(/\.json$/, ''), name: scenario.name ?? scenario.id, description: scenario.description ?? '', }); } return scenarios; }