668 lines
22 KiB
JavaScript
668 lines
22 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)), '..');
|
||
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} 个绑定 policy(PostgreSQL)`);
|
||
} 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;
|
||
}
|