Files
memind/scripts/verify-long-conversation-continuity-local.mjs
john 48c61a3279
Memind CI / Test, build, and release guards (push) Successful in 1m28s
fix: harden long conversation continuity
2026-07-27 21:39:34 +08:00

339 lines
13 KiB
JavaScript

#!/usr/bin/env node
import crypto from 'node:crypto';
import {
createAgentRun,
createReporter,
extractAssistantTexts,
getSession,
loginViaApi,
logoutViaApi,
waitForRunTerminal,
} from './scenario-test-lib.mjs';
const baseUrl = process.env.MEMIND_E2E_BASE_URL ?? 'http://127.0.0.1:8081';
const username = process.env.MEMIND_E2E_USERNAME ?? 'john4';
const password = process.env.MEMIND_E2E_PASSWORD ?? '888888';
const userId = process.env.MEMIND_E2E_USER_ID ?? '32035858-9a20-425b-89da-c118ef0779aa';
const pageName = process.env.MEMIND_E2E_PAGE_NAME ?? 'durability-survey-20260727.html';
const pageUrl = `${baseUrl}/MindSpace/${userId}/public/${pageName}`;
const timeoutMs = Number(process.env.MEMIND_E2E_RUN_TIMEOUT_MS ?? 180_000);
const startTurn = Math.max(
1,
Math.min(21, Number(process.env.MEMIND_E2E_START_TURN ?? 1) || 1),
);
const resumeSessionId = String(process.env.MEMIND_E2E_RESUME_SESSION_ID ?? '').trim() || null;
const editTurns = [
'创建一个单页“小问卷耐久测试系统”:包含标题、昵称输入、一个基础单选问题、提交按钮和页内结果区;答案只保存在当前页面内存中,不做网络提交。创建可见的 <details><ol id="durability-log"></ol></details> 验收记录。',
'新增“年龄段”下拉选择题,并在结果区显示对应中文标签。',
'新增“最喜欢的活动”多选题,至少包含阅读、运动、音乐和旅行。',
'新增“每周参与频率”单选题,包含偶尔、每周、每天。',
'新增“你最期待的改进”多行文本题,并限制为 200 字。',
'给必填题增加页内校验和 role="alert" 错误提示,不允许用浏览器弹窗替代。',
'新增实时答题进度条,按已回答题目数量更新百分比。',
'把问卷改成两步填写,加入上一步和下一步按钮;最后一步才能提交。',
'提交前增加答案复核区域,可返回修改后再确认。',
'把视觉主题调整为蓝绿色卡片风格,保持现有功能和数据结构。',
'增加 prefers-color-scheme 深色模式样式,但不要持久化主题状态。',
'优化手机窄屏布局,按钮在小屏幕下堆叠且可触摸区域不小于 44px。',
'完善可访问性:题组使用 fieldset/legend,输入与标签关联,并提供清晰的 :focus-visible。',
'新增“重新填写”按钮,重置表单、进度、复核区和结果区。',
'提交成功后显示温和的页内完成提示,不使用 alert。',
'在完成结果中显示本次提交的本地时间,刷新页面后不需要保留。',
'结果区按题目中文名称逐项展示答案,不能只输出原始字段名。',
'新增“打印结果”按钮,调用 window.print(),并添加简洁的 @media print 样式。',
'新增问卷版本徽标 v1.0 和“最后更新:耐久测试第 19 轮”文案。',
'新增键盘友好的跳过链接,可直接跳到问卷正文和结果区域。',
'做最终一致性整理:保留全部问题、两步导航、校验、复核、重置、打印、深色模式和移动端样式,并在页脚写“21 轮持续修改验收完成”。',
];
const phaseEnds = new Set([6, 12, 18]);
const statusAfter = new Map([
[6, 'STATUS-AFTER-06'],
[12, 'STATUS-AFTER-12'],
[18, 'STATUS-AFTER-18'],
]);
const completedEditCodes = [];
const runRecords = [];
const reporter = createReporter();
let auth = null;
let sessionId = resumeSessionId;
let loginCount = 0;
for (let turnNumber = 1; turnNumber < startTurn; turnNumber += 1) {
completedEditCodes.push({
code: turnCode(turnNumber),
marker: turnMarker(turnNumber),
});
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
function hashText(text) {
return crypto.createHash('sha256').update(text).digest('hex');
}
function turnCode(turnNumber) {
return `DURABILITY-${String(turnNumber).padStart(2, '0')}`;
}
function turnMarker(turnNumber) {
return `durability-turn-${String(turnNumber).padStart(2, '0')}`;
}
async function login(label) {
auth = await loginViaApi(baseUrl, { username, password }, reporter);
loginCount += 1;
console.log(`[auth] ${label}: login #${loginCount}`);
if (sessionId) {
const result = await getSession(baseUrl, auth.cookie, sessionId);
assert(result.ok, `[auth] ${label}: session ${sessionId} unavailable after login`);
const transcript = extractAssistantTexts(result.session).join('\n');
const expected = completedEditCodes.at(-1)?.code;
if (expected) {
assert(
transcript.includes(expected),
`[auth] ${label}: restored transcript is missing ${expected}`,
);
}
console.log(`[auth] ${label}: restored session ${sessionId} with ${expected ?? 'no prior code'}`);
}
}
async function logout(label) {
assert(auth?.cookie, `[auth] ${label}: no active login`);
await logoutViaApi(baseUrl, auth.cookie, reporter);
auth = null;
console.log(`[auth] ${label}: logout complete`);
}
async function fetchPage() {
const response = await fetch(pageUrl, {
headers: auth?.cookie ? { Cookie: auth.cookie } : {},
});
const text = await response.text();
assert(response.ok, `page ${pageUrl} returned ${response.status}`);
return text;
}
async function assertSessionReply(expectedCode) {
const result = await getSession(baseUrl, auth.cookie, sessionId);
assert(result.ok, `session ${sessionId} returned ${result.status}`);
const assistantTexts = extractAssistantTexts(result.session);
const transcript = assistantTexts.join('\n');
assert(
transcript.includes(expectedCode),
`session ${sessionId} assistant transcript is missing ${expectedCode}`,
);
return {
assistantCount: assistantTexts.length,
transcriptLength: transcript.length,
};
}
function buildEditPrompt(turnNumber, instruction) {
const code = turnCode(turnNumber);
const marker = turnMarker(turnNumber);
if (turnNumber === 1) {
return [
`这是持续对话耐久测试第 ${turnNumber}/21 轮。`,
`请创建 public/${pageName}${instruction}`,
`在 #durability-log 末尾加入 <li id="${marker}">${code}</li>。`,
'禁止调用任何图片工具;禁止使用 localStorage、sessionStorage、IndexedDB 或 Cookie。',
`最终回复必须包含验收码 ${code} 和页面链接。`,
].join('\n');
}
return [
`这是持续对话耐久测试第 ${turnNumber}/21 轮。`,
`继续修改同一个 public/${pageName},不要新建页面。`,
'必须保留之前所有功能与所有 durability-turn-* 验收标记。',
instruction,
`在 #durability-log 末尾加入 <li id="${marker}">${code}</li>。`,
'禁止调用任何图片工具;禁止使用 localStorage、sessionStorage、IndexedDB 或 Cookie。',
`最终回复必须包含验收码 ${code} 和页面链接。`,
].join('\n');
}
async function createRunWithOptionalConflict(turnNumber, message, { probeConflict = false } = {}) {
const created = await createAgentRun(baseUrl, auth.cookie, {
message,
sessionId,
});
if (probeConflict) {
let conflictError = null;
try {
await createAgentRun(baseUrl, auth.cookie, {
message: `并发探针 ${turnCode(turnNumber)}:这条请求必须被同会话并发保护拒绝。`,
sessionId: created.sessionId ?? sessionId,
});
} catch (error) {
conflictError = error;
}
const conflictText = String(conflictError?.message ?? '');
assert(
/409/.test(conflictText) && /SESSION_RUN_CONFLICT/.test(conflictText),
`turn ${turnNumber}: expected SESSION_RUN_CONFLICT, got ${conflictText || 'no error'}`,
);
console.log(`[conflict] turn ${turnNumber}: expected 409 SESSION_RUN_CONFLICT`);
}
return created;
}
async function runEditTurn(turnNumber, instruction, options = {}) {
const code = turnCode(turnNumber);
const marker = turnMarker(turnNumber);
const startedAt = Date.now();
const created = await createRunWithOptionalConflict(
turnNumber,
buildEditPrompt(turnNumber, instruction),
{ probeConflict: options.probeConflict },
);
if (options.logoutDuringRun) {
await logout(`turn ${turnNumber} active`);
await login(`turn ${turnNumber} resume`);
}
const terminal = await waitForRunTerminal(baseUrl, auth.cookie, created.runId, timeoutMs);
assert(
terminal.status === 'succeeded',
`turn ${turnNumber}: run ${created.runId} ended ${terminal.status}: ${terminal.error_code ?? terminal.error_message ?? ''}`,
);
sessionId =
terminal.agent_session_id
?? terminal.sessionId
?? created.sessionId
?? sessionId;
assert(sessionId, `turn ${turnNumber}: terminal run has no session id`);
const page = await fetchPage();
const expectedMarkers = [...completedEditCodes.map((entry) => entry.marker), marker];
for (const expectedMarker of expectedMarkers) {
assert(
page.includes(`id="${expectedMarker}"`) || page.includes(`id='${expectedMarker}'`),
`turn ${turnNumber}: page lost marker ${expectedMarker}`,
);
}
assert(
!/\b(?:localStorage|sessionStorage|indexedDB)\b/.test(page),
`turn ${turnNumber}: page introduced forbidden browser storage`,
);
const sessionStats = await assertSessionReply(code);
completedEditCodes.push({ code, marker });
const record = {
turnNumber,
code,
runId: created.runId,
sessionId,
elapsedMs: Date.now() - startedAt,
pageBytes: Buffer.byteLength(page),
pageHash: hashText(page),
...sessionStats,
};
runRecords.push(record);
console.log(
`[turn ${String(turnNumber).padStart(2, '0')}/21] ${code} ok`
+ ` run=${created.runId} session=${sessionId}`
+ ` elapsed=${Math.round(record.elapsedMs / 1000)}s page=${record.pageBytes}B`,
);
}
async function runStatusTurn(afterTurn, expectedCode) {
const before = await fetchPage();
const created = await createAgentRun(baseUrl, auth.cookie, {
sessionId,
message: [
`这是第 ${afterTurn} 轮修改后的状态追问。`,
`请只汇报 public/${pageName} 当前已完成到 ${turnCode(afterTurn)},不要修改任何文件。`,
`最终回复必须包含状态验收码 ${expectedCode} 和页面链接。`,
].join('\n'),
});
const terminal = await waitForRunTerminal(baseUrl, auth.cookie, created.runId, timeoutMs);
assert(terminal.status === 'succeeded', `${expectedCode}: status run ended ${terminal.status}`);
sessionId = terminal.agent_session_id ?? terminal.sessionId ?? created.sessionId ?? sessionId;
const sessionStats = await assertSessionReply(expectedCode);
const after = await fetchPage();
assert(
hashText(after) === hashText(before),
`${expectedCode}: status-only follow-up unexpectedly changed the page`,
);
runRecords.push({
statusCode: expectedCode,
runId: created.runId,
sessionId,
pageHash: hashText(after),
...sessionStats,
});
console.log(`[status] ${expectedCode} ok; page hash unchanged`);
}
async function main() {
console.log(`=== Long conversation continuity: ${pageName} ===`);
await login('initial');
for (let index = startTurn - 1; index < editTurns.length; index += 1) {
const turnNumber = index + 1;
await runEditTurn(turnNumber, editTurns[index], {
logoutDuringRun: turnNumber === 13,
probeConflict: turnNumber === 17,
});
const statusCode = statusAfter.get(turnNumber);
if (statusCode) await runStatusTurn(turnNumber, statusCode);
if (phaseEnds.has(turnNumber)) {
await logout(`phase after turn ${turnNumber}`);
await login(`phase after turn ${turnNumber}`);
}
}
await logout('final persistence check');
await login('final persistence check');
const finalPage = await fetchPage();
const finalTranscript = await assertSessionReply(turnCode(editTurns.length));
for (const { marker } of completedEditCodes) {
assert(
finalPage.includes(`id="${marker}"`) || finalPage.includes(`id='${marker}'`),
`final page lost ${marker}`,
);
}
assert(
finalPage.includes('21 轮持续修改验收完成'),
'final page is missing the 21-turn completion footer',
);
await logout('complete');
console.log('\n=== RESULT ===');
console.log(JSON.stringify({
ok: true,
startTurn,
editTurns: completedEditCodes.length,
statusTurns: runRecords.filter((record) => record.statusCode).length,
totalConversationTurns: runRecords.length,
loginCount,
sessionId,
pageUrl,
finalPageBytes: Buffer.byteLength(finalPage),
finalPageHash: hashText(finalPage),
finalTranscript,
runs: runRecords,
}, null, 2));
}
main().catch(async (error) => {
console.error(`\nLONG_CONVERSATION_CONTINUITY_FAILED: ${error?.stack ?? error}`);
if (auth?.cookie) {
try {
await logout('failure cleanup');
} catch (logoutError) {
console.error(`failure cleanup logout failed: ${logoutError?.message ?? logoutError}`);
}
}
process.exit(1);
});