#!/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 = [ '创建一个单页“小问卷耐久测试系统”:包含标题、昵称输入、一个基础单选问题、提交按钮和页内结果区;答案只保存在当前页面内存中,不做网络提交。创建可见的
    验收记录。', '新增“年龄段”下拉选择题,并在结果区显示对应中文标签。', '新增“最喜欢的活动”多选题,至少包含阅读、运动、音乐和旅行。', '新增“每周参与频率”单选题,包含偶尔、每周、每天。', '新增“你最期待的改进”多行文本题,并限制为 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 末尾加入
  1. ${code}
  2. 。`, '禁止调用任何图片工具;禁止使用 localStorage、sessionStorage、IndexedDB 或 Cookie。', `最终回复必须包含验收码 ${code} 和页面链接。`, ].join('\n'); } return [ `这是持续对话耐久测试第 ${turnNumber}/21 轮。`, `继续修改同一个 public/${pageName},不要新建页面。`, '必须保留之前所有功能与所有 durability-turn-* 验收标记。', instruction, `在 #durability-log 末尾加入
  3. ${code}
  4. 。`, '禁止调用任何图片工具;禁止使用 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); });