Files
memind/scripts/scenario-test-lib.mjs
T
john 441703de32
Memind CI / Test, build, and release guards (push) Successful in 1m58s
fix: stabilize page data release gate scenarios
2026-07-26 17:41:45 +08:00

668 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
const allowLoopbackReply = process.env.RELEASE_GATE_ALLOW_LOOPBACK_REPLY === '1';
function isIgnoredReplyPattern(pattern) {
return allowLoopbackReply && pattern === '127.0.0.1:';
}
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 (isIgnoredReplyPattern(pattern)) continue;
if (pattern && replyText.includes(pattern)) {
reporter.fail('回复禁用模式', `命中 ${pattern}`);
}
}
if (forbidReply.length && !forbidReply.some(
(pattern) => !isIgnoredReplyPattern(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|order|下单|submit|form|customer/i.test(name) && !/admin|后台|manage/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 {
// Current Page Data delivery persists dataset registration in the
// service PostgreSQL and materializes the bound policy files; older
// workspaces also carry the legacy SQLite registry. Accept either
// representation, but never treat an HTML-only page as registered.
try {
const policyEntries = await fs.readdir(policyDir);
const registeredPolicies = policyEntries.filter((name) => name.endsWith('.json'));
if (registeredPolicies.length > 0) {
reporter.pass('Page Data 数据集', `${registeredPolicies.length} 个绑定 policyPostgreSQL`);
} else {
reporter.fail('Page Data 数据集', '未找到 SQLite registry 或绑定 policy');
}
} catch {
reporter.fail('Page Data 数据集', '未找到 SQLite registry 或绑定 policy');
}
}
}
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;
}