339 lines
13 KiB
JavaScript
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);
|
|
});
|