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:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user