chore(test): 新增统一测试入口与端到端场景模拟脚本

- scripts/run-memind-tests.mjs:按改动文件自动匹配 test + verify 范围
  (quick/changed/guards/release/full 模式),配套 memind-test skill
- scripts/run-scenario-test.mjs + scenario-test-lib.mjs:真实登录 + 多轮
  聊天 + 页面生成 + 公网可访问性的端到端模拟,配套 memind-scenario-test
  skill 与首个场景 scenarios/suzhou-page.json
- package.json 新增 test:memind / test:scenario 脚本别名

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-07 10:55:31 +08:00
parent ec8d0464a3
commit b4171ca268
10 changed files with 1340 additions and 0 deletions
+356
View File
@@ -0,0 +1,356 @@
#!/usr/bin/env node
/**
* Memind scoped test runner.
* Usage: node scripts/run-memind-tests.mjs [--mode quick|changed|guards|release|full] [--base REF]
*/
import { spawnSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
const root = path.resolve(new URL('..', import.meta.url).pathname);
const MODES = new Set(['quick', 'changed', 'guards', 'release', 'full']);
const QUICK_TESTS = [
'capabilities.test.mjs',
'llm-providers.test.mjs',
'user-auth.test.mjs',
'wechat-mp.test.mjs',
];
const GUARD_SCRIPTS = [
'verify:mindspace-publish-guards',
'verify:mindspace-page-sync-guards',
];
const RELEASE_TESTS = [
...QUICK_TESTS,
'wechat-oauth.test.mjs',
'wechat-pay.test.mjs',
'mindspace-public-finish-sync.test.mjs',
'chat-finish-sync.test.mjs',
'conversation-display.test.mjs',
'mindspace-pages.test.mjs',
'mindspace-page-sync-service.test.mjs',
];
const RELEASE_CHECKS = [
'check:mindspace-public-links',
'verify:mindspace-publish-guards:full',
];
const SCOPE_RULES = [
{
id: 'publish-chat-finish',
patterns: [
/mindspace-public-finish/i,
/chat-finish-sync/i,
/conversation-display/i,
/useTKMindChat/i,
/message\.ts$/,
/mindspace-h5-html-finish/i,
],
tests: [
'mindspace-public-finish-sync.test.mjs',
'mindspace-h5-html-finish-guard.test.mjs',
'chat-finish-sync.test.mjs',
'conversation-display.test.mjs',
],
verify: ['verify:mindspace-publish-guards'],
},
{
id: 'page-sync-thumbnail',
patterns: [
/mindspace-page-sync/i,
/mindspace-pages/i,
/public-site-bases/i,
/publicUrl\.ts$/,
/mindspace-thumbnails/i,
/mindspace-workspace-thumbnails/i,
],
tests: [
'mindspace-pages.test.mjs',
'mindspace-page-sync-service.test.mjs',
'public-site-bases.test.mjs',
'mindspace-thumbnails.test.mjs',
'mindspace-workspace-thumbnails.test.mjs',
],
verify: ['verify:mindspace-page-sync-guards'],
},
{
id: 'wechat',
patterns: [/wechat/i],
tests: [
'wechat-mp.test.mjs',
'wechat-oauth.test.mjs',
'wechat-pay.test.mjs',
'user-auth.test.mjs',
],
verify: ['verify:wechat-channel-isolation'],
},
{
id: 'billing',
patterns: [/billing/i, /sse-billing/i],
tests: [
'billing.test.mjs',
'billing-recharge.test.mjs',
'billing-subscription.test.mjs',
'sse-billing.test.mjs',
],
},
{
id: 'agent-run',
patterns: [/agent-run/i, /tool-gateway/i, /goosed-proxy/i],
tests: [
'agent-run-gateway.test.mjs',
'agent-run-routes.test.mjs',
'agent-run-stream.test.mjs',
'tool-gateway.test.mjs',
'goosed-proxy-boundary.test.mjs',
'chat-agent-run-gate.test.mjs',
],
verify: ['verify:goosed-proxy-boundary'],
},
{
id: 'memory-v2',
patterns: [/memory-v2/i, /scripts\/check-memory-v2/i, /scripts\/smoke-memory-v2/i],
tests: [
'memory-v2.test.mjs',
'memory-v2-runtime.test.mjs',
'memory-v2-health.test.mjs',
'memory-v2-backend-contract.test.mjs',
],
},
{
id: 'plaza',
patterns: [/plaza/i],
tests: [
'plaza-posts.test.mjs',
'plaza-interactions.test.mjs',
'plaza-algorithm.test.mjs',
'plaza-seo.test.mjs',
'plaza-ops.test.mjs',
],
},
{
id: 'mindspace-core',
patterns: [/mindspace/i, /server\.mjs$/, /src\/hooks\/useTKMind/i],
tests: [
'mindspace.test.mjs',
'mindspace-public-route.test.mjs',
'mindspace-public-links.test.mjs',
'mindspace-runtime-config.test.mjs',
],
},
];
function usage() {
console.log(`Usage:
node scripts/run-memind-tests.mjs [--mode quick|changed|guards|release|full] [--base REF]
Modes:
changed Run tests matched to git diff (default)
quick Fast smoke for routine checks
guards Regression guard scripts only
release Pre-portal-release checklist
full Entire npm test suite`);
}
function parseArgs(argv) {
let mode = 'changed';
let base = 'HEAD';
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--mode') {
mode = argv[++i] ?? '';
} else if (arg === '--base') {
base = argv[++i] ?? 'HEAD';
} else if (arg === '-h' || arg === '--help') {
usage();
process.exit(0);
} else {
throw new Error(`Unknown argument: ${arg}`);
}
}
if (!MODES.has(mode)) {
throw new Error(`Invalid mode: ${mode}`);
}
return { mode, base };
}
function runStep(label, fn) {
console.log(`\n==> ${label}`);
const ok = fn();
if (!ok) {
throw new Error(`${label} failed`);
}
}
function runNodeTest(files) {
const existing = files.filter((file) => fs.existsSync(path.join(root, file)));
if (existing.length === 0) {
console.log('(skip: no matching test files on disk)');
return true;
}
const result = spawnSync(process.execPath, ['--test', ...existing], {
cwd: root,
stdio: 'inherit',
});
return result.status === 0;
}
function runNpmScript(script) {
const result = spawnSync('npm', ['run', script], {
cwd: root,
stdio: 'inherit',
shell: process.platform === 'win32',
});
return result.status === 0;
}
function gitChangedFiles(base) {
const result = spawnSync('git', ['diff', '--name-only', `${base}...HEAD`], {
cwd: root,
encoding: 'utf8',
});
if (result.status !== 0) {
const fallback = spawnSync('git', ['diff', '--name-only', base], {
cwd: root,
encoding: 'utf8',
});
if (fallback.status !== 0) {
return [];
}
return fallback.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean);
}
function gitStagedAndUnstaged() {
const result = spawnSync('git', ['status', '--porcelain'], {
cwd: root,
encoding: 'utf8',
});
if (result.status !== 0) {
return [];
}
return result.stdout
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => line.replace(/^. [^ ]+ -> /, '').replace(/^.. /, ''));
}
function directTestForSource(file) {
const base = path.basename(file).replace(/\.(mjs|ts|tsx|js)$/, '');
const candidates = [
`${base}.test.mjs`,
path.join('scripts', `${base}.test.mjs`),
path.join('mindspace-service', `${base}.test.mjs`),
];
return candidates.filter((candidate) => fs.existsSync(path.join(root, candidate)));
}
function resolveChangedScope(files) {
const tests = new Set(QUICK_TESTS);
const verify = new Set();
for (const file of files) {
for (const test of directTestForSource(file)) {
tests.add(test);
}
for (const rule of SCOPE_RULES) {
if (rule.patterns.some((pattern) => pattern.test(file))) {
for (const test of rule.tests) {
tests.add(test);
}
for (const script of rule.verify ?? []) {
verify.add(script);
}
}
}
}
return {
files,
tests: [...tests],
verify: [...verify],
};
}
function summarize(scope) {
console.log('\n--- planned scope ---');
if (scope.files?.length) {
console.log(`changed files (${scope.files.length}):`);
for (const file of scope.files) {
console.log(` - ${file}`);
}
}
console.log(`tests (${scope.tests.length}):`);
for (const test of scope.tests) {
console.log(` - ${test}`);
}
if (scope.verify.length) {
console.log(`verify scripts (${scope.verify.length}):`);
for (const script of scope.verify) {
console.log(` - npm run ${script}`);
}
}
}
function main() {
const { mode, base } = parseArgs(process.argv);
let scope;
switch (mode) {
case 'quick':
scope = { tests: QUICK_TESTS, verify: [] };
break;
case 'guards':
scope = { tests: [], verify: GUARD_SCRIPTS };
break;
case 'release':
scope = { tests: RELEASE_TESTS, verify: RELEASE_CHECKS };
break;
case 'full':
runStep('full test suite (npm test)', () => runNpmScript('test'));
console.log('\nmemind tests ok (full)');
return;
case 'changed':
default: {
const changed = [...new Set([...gitChangedFiles(base), ...gitStagedAndUnstaged()])];
if (changed.length === 0) {
console.log('No changed files detected; falling back to quick smoke.');
scope = { tests: QUICK_TESTS, verify: [], files: [] };
} else {
scope = resolveChangedScope(changed);
}
break;
}
}
summarize(scope);
for (const test of scope.tests) {
runStep(`node --test ${test}`, () => runNodeTest([test]));
}
for (const script of scope.verify) {
runStep(`npm run ${script}`, () => runNpmScript(script));
}
console.log(`\nmemind tests ok (${mode})`);
}
try {
main();
} catch (error) {
console.error(`\n${error.message}`);
process.exit(1);
}
+206
View File
@@ -0,0 +1,206 @@
#!/usr/bin/env node
/**
* Memind 场景模拟测试入口。
*
* Usage:
* node scripts/run-scenario-test.mjs --scenario suzhou-page
* node scripts/run-scenario-test.mjs --list
* JOHN_PASSWORD=888888 node scripts/run-scenario-test.mjs
*/
import { loadH5Environment } from './load-env.mjs';
import {
createAgentRun,
createReporter,
listScenarios,
loadScenario,
loginViaApi,
resolvePortalBase,
snapshotPublicHtml,
verifyPageAccess,
waitForAssistantGrowth,
waitForRunTerminal,
extractAssistantTexts,
getSession,
} from './scenario-test-lib.mjs';
loadH5Environment(import.meta.dirname);
function usage() {
console.log(`Usage:
node scripts/run-scenario-test.mjs [--scenario <id>] [--list] [--port <port>]
Examples:
node scripts/run-scenario-test.mjs --scenario suzhou-page
node scripts/run-scenario-test.mjs --list`);
}
function parseArgs(argv) {
let scenarioId = 'suzhou-page';
let listOnly = false;
let port = Number(process.env.H5_PORT ?? 8081);
for (let i = 2; i < argv.length; i += 1) {
const arg = argv[i];
if (arg === '--scenario' && argv[i + 1]) {
scenarioId = argv[++i];
} else if (arg === '--port' && argv[i + 1]) {
port = Number(argv[++i]);
} else if (arg === '--list') {
listOnly = true;
} else if (arg === '-h' || arg === '--help') {
usage();
process.exit(0);
} else {
throw new Error(`未知参数: ${arg}`);
}
}
return { scenarioId, listOnly, port };
}
async function ensurePortalReady(baseUrl) {
const response = await fetch(`${baseUrl}/auth/status`);
if (!response.ok) {
throw new Error(`Portal 未就绪: ${baseUrl}/auth/status -> ${response.status}`);
}
}
async function runScenario(scenario, port) {
const reporter = createReporter();
const baseUrl = resolvePortalBase(port);
const account = {
username: scenario.account?.username ?? 'john',
password:
process.env.JOHN_PASSWORD
?? process.env.H5_ACCESS_PASSWORD
?? scenario.account?.password
?? '888888',
};
console.log(`==> 场景: ${scenario.name ?? scenario.id}`);
console.log(` Portal: ${baseUrl}`);
console.log(` 账户: ${account.username}\n`);
await ensurePortalReady(baseUrl);
let auth = null;
let sessionId = null;
let assistantCount = 0;
let assistantCombinedLength = 0;
let publishKey = null;
let htmlBefore = [];
for (const step of scenario.steps ?? []) {
const label = step.label ?? step.action;
console.log(`\n--- ${label} ---`);
if (step.action === 'login') {
auth = await loginViaApi(baseUrl, account, reporter);
publishKey = auth.user?.id ?? auth.user?.publishSlug ?? account.username;
continue;
}
if (step.action === 'chat') {
if (!auth) {
throw new Error('chat 步骤前必须先 login');
}
if (step.expect?.page) {
htmlBefore = await snapshotPublicHtml(publishKey);
}
const run = await createAgentRun(baseUrl, auth.cookie, {
message: step.message,
sessionId,
});
reporter.pass('提交消息', `"${step.message}" → run ${run.runId}`);
const terminal = await waitForRunTerminal(
baseUrl,
auth.cookie,
run.runId,
step.expect?.timeoutMs ?? 300_000,
);
sessionId = terminal.sessionId ?? terminal.agent_session_id ?? run.sessionId ?? sessionId;
if (terminal.status === 'failed') {
reporter.fail('run 终态', terminal.error ?? 'failed');
continue;
}
reporter.pass('run 终态', terminal.status);
if (!sessionId) {
reporter.fail('session', 'run 未回填 sessionId');
continue;
}
const reply = await waitForAssistantGrowth(baseUrl, auth.cookie, sessionId, {
previousCount: assistantCount,
previousCombinedLength: assistantCombinedLength,
minChars: step.expect?.assistantMinChars ?? 1,
timeoutMs: step.expect?.timeoutMs ?? 120_000,
});
if (!reply) {
reporter.fail('assistant 回复', '超时未收到新回复');
continue;
}
assistantCount = reply.count;
assistantCombinedLength = reply.combined.length;
reporter.pass('assistant 回复', `${reply.combined.length} 字 / ${reply.elapsedMs}ms`);
console.log('\n--- 回复摘要 ---');
console.log(reply.combined.slice(0, 600));
const keywords = step.expect?.replyKeywords ?? [];
if (keywords.length) {
const hit = keywords.filter((word) => reply.combined.includes(word));
if (hit.length === 0) {
reporter.fail('回复关键词', `未命中: ${keywords.join(', ')}`);
} else {
reporter.pass('回复关键词', hit.join(', '));
}
}
if (step.expect?.page) {
await verifyPageAccess({
baseUrl,
cookie: auth.cookie,
publishKey,
replyText: reply.combined,
htmlBefore,
expect: step.expect.page,
reporter,
});
}
continue;
}
reporter.fail('未知步骤', step.action ?? 'missing action');
}
return reporter.summary();
}
async function main() {
const { scenarioId, listOnly, port } = parseArgs(process.argv);
if (listOnly) {
const scenarios = await listScenarios();
console.log('可用场景:');
for (const item of scenarios) {
console.log(`- ${item.id}: ${item.name}${item.description ? `${item.description}` : ''}`);
}
return;
}
const scenario = await loadScenario(scenarioId);
const code = await runScenario(scenario, port);
process.exit(code);
}
main().catch((error) => {
console.error(error instanceof Error ? error.stack ?? error.message : error);
process.exit(1);
});
+324
View File
@@ -0,0 +1,324 @@
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)), '..');
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,
};
}
function buildUserMessage(text) {
return {
id: crypto.randomUUID(),
role: 'user',
content: [{ type: 'text', text }],
metadata: { userVisible: true, displayText: text },
};
}
export async function createAgentRun(baseUrl, cookie, { message, sessionId = null }) {
const requestId = crypto.randomUUID();
const body = {
request_id: requestId,
user_message: buildUserMessage(message),
};
if (sessionId) body.session_id = sessionId;
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(repoRoot, 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(repoRoot, 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 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;
}