7f8d692d16
Fix DEV logout cookie clearing, materialize selected MindSpace OA assets before agent runs, and recover zombie runs from synced workspace pages. Add client run wait timeout, harness retry limits, page-edit asset forwarding, and logout/john2 scenario tests. Co-authored-by: Cursor <cursoragent@cursor.com>
473 lines
15 KiB
JavaScript
473 lines
15 KiB
JavaScript
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,
|
|
};
|
|
}
|
|
|
|
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(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 verifySurveyDelivery({
|
|
publishKey,
|
|
replyText = '',
|
|
expect = {},
|
|
reporter,
|
|
}) {
|
|
const publishDir = path.join(repoRoot, 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 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;
|
|
}
|