'use strict'; const assert = require('node:assert'); const fs = require('node:fs'); const path = require('node:path'); const vm = require('node:vm'); const REPO_DIR = path.resolve(__dirname, '..'); const APP_PATH = path.join(REPO_DIR, 'public', 'app.js'); const STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css'); function extractFunctionSource(source, name) { const marker = `function ${name}`; const start = source.indexOf(marker); assert(start >= 0, `缺少函数 ${name}`); const paramsOpen = source.indexOf('(', start); assert(paramsOpen >= 0, `函数 ${name} 缺少参数列表`); let paramDepth = 0; let paramsClose = -1; for (let index = paramsOpen; index < source.length; index += 1) { const ch = source[index]; if (ch === '(') paramDepth += 1; if (ch === ')') { paramDepth -= 1; if (paramDepth === 0) { paramsClose = index; break; } } } assert(paramsClose >= 0, `函数 ${name} 参数列表未闭合`); const open = source.indexOf('{', paramsClose); assert(open >= 0, `函数 ${name} 缺少函数体`); let depth = 0; for (let index = open; index < source.length; index += 1) { const ch = source[index]; if (ch === '{') depth += 1; if (ch === '}') { depth -= 1; if (depth === 0) return source.slice(start, index + 1); } } throw new Error(`函数 ${name} 未闭合`); } class FakeClassList { constructor(element) { this.element = element; } values() { return String(this.element.className || '').split(/\s+/).filter(Boolean); } contains(name) { return this.values().includes(name); } add(...names) { const next = new Set(this.values()); names.forEach((name) => next.add(String(name))); this.element.className = Array.from(next).join(' '); } remove(...names) { const removed = new Set(names.map(String)); this.element.className = this.values().filter((name) => !removed.has(name)).join(' '); } toggle(name, force) { const enabled = force === undefined ? !this.contains(name) : !!force; if (enabled) this.add(name); else this.remove(name); return enabled; } } function dataKey(name) { return name.slice(5).split('-').map((part, index) => ( index === 0 ? part : `${part.slice(0, 1).toUpperCase()}${part.slice(1)}` )).join(''); } function attributeValue(element, name) { if (name === 'class') return element.className; if (name === 'id') return element.id || null; if (name === 'disabled') return element.disabled ? '' : null; if (name.startsWith('data-')) { const key = dataKey(name); return Object.prototype.hasOwnProperty.call(element.dataset, key) ? String(element.dataset[key]) : null; } return element.attributes.get(name) ?? null; } function matchesSelector(element, selector) { const raw = String(selector || '').trim().replace(/^:scope\s*/, ''); if (!raw) return false; const tag = raw.match(/^[A-Za-z][A-Za-z0-9-]*/)?.[0]; if (tag && element.tagName !== tag.toUpperCase()) return false; const classes = Array.from(raw.matchAll(/\.([A-Za-z0-9_-]+)/g), (match) => match[1]); if (classes.some((name) => !element.classList.contains(name))) return false; const attrs = Array.from(raw.matchAll(/\[([^\]=]+)(?:="([^"]*)")?\]/g)); for (const [, name, expected] of attrs) { const actual = attributeValue(element, name); if (actual === null) return false; if (expected !== undefined && actual !== expected) return false; } return true; } class FakeElement { constructor(tagName) { this.tagName = String(tagName).toUpperCase(); this.children = []; this.parentNode = null; this.dataset = {}; this.attributes = new Map(); this.listeners = new Map(); this.className = ''; this.id = ''; this.type = ''; this.disabled = false; this._textContent = ''; this.classList = new FakeClassList(this); } get textContent() { return `${this._textContent}${this.children.map((child) => child.textContent).join('')}`; } set textContent(value) { this._textContent = value == null ? '' : String(value); this.children.forEach((child) => { child.parentNode = null; }); this.children = []; } appendChild(child) { if (child.parentNode) child.remove(); child.parentNode = this; this.children.push(child); return child; } remove() { if (!this.parentNode) return; this.parentNode.children = this.parentNode.children.filter((child) => child !== this); this.parentNode = null; } setAttribute(name, value) { this.attributes.set(String(name), String(value)); } getAttribute(name) { return this.attributes.get(String(name)) ?? null; } removeAttribute(name) { this.attributes.delete(String(name)); } addEventListener(type, handler) { const list = this.listeners.get(type) || []; list.push(handler); this.listeners.set(type, list); } click() { const event = { preventDefault() {}, stopPropagation() {}, }; (this.listeners.get('click') || []).forEach((handler) => handler(event)); } querySelector(selector) { return this.querySelectorAll(selector)[0] || null; } querySelectorAll(selector) { const selectors = String(selector || '').split(',').map((item) => item.trim()).filter(Boolean); const results = []; const visit = (node) => { node.children.forEach((child) => { if (selectors.some((item) => matchesSelector(child, item))) results.push(child); visit(child); }); }; visit(this); return results; } } class FakeDocument { constructor() { this.root = new FakeElement('div'); } createElement(tagName) { return new FakeElement(tagName); } querySelector(selector) { return this.root.querySelector(selector); } } function createHarness() { const source = fs.readFileSync(APP_PATH, 'utf8'); const document = new FakeDocument(); const messagesDiv = document.root; const sent = []; const errors = []; let outlineUpdates = 0; let scrollbarUpdates = 0; const functionNames = [ 'codexAppSteerStatusLabel', 'cloneCodexAppSteerAttachments', 'registerCodexAppSteerRecord', 'clearCodexAppSteerRecord', 'commitCodexAppSteerMessage', 'registerCodexAppSteerSessionMessage', 'retryCodexAppSteerMessage', 'deleteCodexAppSteerMessage', 'setCodexAppSteerStatusElement', 'updateCodexAppSteerMessage', ]; const functions = functionNames.map((name) => extractFunctionSource(source, name)).join('\n\n'); const script = ` const userMessageIndex = new Map(); const codexAppSteerRecords = new Map(); let currentSessionId = 'session-a'; let currentMode = 'yolo'; let currentAgent = 'codexapp'; let isGenerating = true; let currentSessionRunning = true; let currentSessionMessageCount = 4; let ws = { readyState: 1, send(payload) { sent.push(JSON.parse(payload)); } }; function cssEscape(value) { return String(value || '').replace(/"/g, '\\\\"'); } function isCodexAppAgent(agent) { return agent === 'codexapp'; } function send(data) { if (ws && ws.readyState === 1) ws.send(JSON.stringify(data)); } function appendError(message) { errors.push(String(message || '')); } function updateUserOutlinePanel() { outlineUpdates += 1; } function updateScrollbar() { scrollbarUpdates += 1; } function scrollToBottom() {} function markSessionMessageElement(element, index) { element.dataset.sessionMessage = 'true'; element.dataset.messageIndex = String(index); } ${functions} function makeMessage(id) { const element = document.createElement('div'); element.className = 'msg user'; element.dataset.messageId = id; const bubble = document.createElement('div'); bubble.className = 'msg-bubble'; element.appendChild(bubble); messagesDiv.appendChild(element); userMessageIndex.set(id, { id, element, content: '原文本', timestamp: '2026-08-11T00:00:00.000Z' }); return element; } ({ makeMessage, registerCodexAppSteerRecord, registerCodexAppSteerSessionMessage, updateCodexAppSteerMessage, setCodexAppSteerStatusElement, retryCodexAppSteerMessage, deleteCodexAppSteerMessage, records: codexAppSteerRecords, index: userMessageIndex, messagesDiv, sent, errors, get outlineUpdates() { return outlineUpdates; }, get scrollbarUpdates() { return scrollbarUpdates; }, get currentSessionMessageCount() { return currentSessionMessageCount; }, setSession(id) { currentSessionId = id; }, setGenerating(value) { isGenerating = value; currentSessionRunning = value; }, setWsReady(value) { ws.readyState = value; }, }); `; return vm.runInNewContext(script, { document, messagesDiv, sent, errors, outlineUpdates, scrollbarUpdates }); } function assertFailedActionsContract() { const harness = createHarness(); const element = harness.makeMessage('retry-target'); harness.registerCodexAppSteerRecord('retry-target', { element, text: '原文本', attachments: [{ id: 'img-a', filename: 'a.png' }], sessionId: 'session-a', mode: 'plan', agent: 'codexapp', }); assert.strictEqual(harness.updateCodexAppSteerMessage('retry-target', 'failed', '插入失败'), true); const failedActions = element.querySelector('.codex-steer-actions'); assert(failedActions, 'failed 状态应渲染操作区'); const retry = failedActions.querySelector('.codex-steer-retry'); const remove = failedActions.querySelector('.codex-steer-delete'); assert(retry && remove, 'failed 状态应同时渲染重试和删除按钮'); assert.strictEqual(retry.textContent, '重试'); assert.strictEqual(remove.textContent, '删除'); assert.strictEqual(retry.getAttribute('aria-label'), '重试插入此消息'); assert.strictEqual(remove.getAttribute('aria-label'), '删除失败插入项'); retry.click(); retry.click(); assert.strictEqual(harness.sent.length, 1, '连续快速点击重试只应发送一次'); assert.deepStrictEqual(JSON.parse(JSON.stringify(harness.sent[0])), { type: 'message', text: '原文本', attachments: [{ id: 'img-a', filename: 'a.png' }], sessionId: 'session-a', mode: 'plan', agent: 'codexapp', clientMessageId: 'retry-target', }); assert.strictEqual(element.getAttribute('aria-busy'), 'true', 'pending 状态应暴露 aria-busy=true'); assert.strictEqual(element.querySelector('.codex-steer-actions'), null, 'pending 状态不应展示失败操作'); harness.updateCodexAppSteerMessage('retry-target', 'failed', '仍然失败'); assert(element.querySelector('.codex-steer-actions'), '再次失败后应恢复操作'); harness.updateCodexAppSteerMessage('retry-target', 'inserted', '已插入'); assert.strictEqual(element.querySelector('.codex-steer-actions'), null, '成功后不应展示失败操作'); assert.strictEqual(element.dataset.sessionMessage, 'true', '成功后才提交消息索引'); assert.strictEqual(element.dataset.messageIndex, '4', '成功提交应使用当前会话消息序号'); assert.strictEqual(harness.currentSessionMessageCount, 5, '成功提交只应递增一次'); harness.updateCodexAppSteerMessage('retry-target', 'inserted', '已插入'); assert.strictEqual(harness.currentSessionMessageCount, 5, '重复 inserted 不应重复递增'); } function assertDeleteAndBlockedRetryContract() { const harness = createHarness(); const first = harness.makeMessage('delete-target'); const second = harness.makeMessage('keep-target'); harness.registerCodexAppSteerRecord('delete-target', { element: first, text: '删除目标', attachments: [{ id: 'same-name', filename: 'same.png' }], sessionId: 'session-a', mode: 'yolo', agent: 'codexapp', }); harness.registerCodexAppSteerRecord('keep-target', { element: second, text: '保留目标', attachments: [{ id: 'same-name-2', filename: 'same.png' }], sessionId: 'session-a', mode: 'yolo', agent: 'codexapp', }); harness.updateCodexAppSteerMessage('delete-target', 'failed', '插入失败'); harness.updateCodexAppSteerMessage('keep-target', 'failed', '插入失败'); first.querySelector('.codex-steer-delete').click(); assert.strictEqual(harness.messagesDiv.children.includes(first), false, '删除只移除目标临时项'); assert.strictEqual(harness.messagesDiv.children.includes(second), true, '删除不应影响同名其他项'); assert.strictEqual(harness.index.has('delete-target'), false, '删除应清理用户消息索引'); assert.strictEqual(harness.records.has('delete-target'), false, '删除应清理临时插入记录'); assert.strictEqual(harness.records.has('keep-target'), true, '删除不应清理其他临时记录'); const blocked = harness.makeMessage('blocked-target'); harness.registerCodexAppSteerRecord('blocked-target', { element: blocked, text: '断线重试', attachments: [], sessionId: 'session-a', mode: 'yolo', agent: 'codexapp', }); harness.updateCodexAppSteerMessage('blocked-target', 'failed', '插入失败'); harness.setWsReady(3); blocked.querySelector('.codex-steer-retry').click(); assert.strictEqual(harness.sent.length, 0, 'WebSocket 断开时不能误发重试'); assert(blocked.querySelector('.codex-steer-actions'), 'WebSocket 断开时应保持失败态可恢复'); assert.strictEqual(blocked.getAttribute('aria-busy'), 'false', 'WebSocket 断开时不能永久卡 pending'); harness.setWsReady(1); harness.setSession('other-session'); blocked.querySelector('.codex-steer-retry').click(); assert.strictEqual(harness.sent.length, 0, '会话不匹配时不能误发重试'); assert(blocked.querySelector('.codex-steer-actions'), '会话不匹配时应保持失败态'); harness.setSession('session-a'); harness.setGenerating(false); blocked.querySelector('.codex-steer-retry').click(); assert.strictEqual(harness.sent.length, 0, 'turn 已结束时不能误发重试'); assert(blocked.querySelector('.codex-steer-actions'), 'turn 已结束时应保持失败态'); } function assertProgrammaticSessionMessageRetryContract() { const harness = createHarness(); const element = harness.makeMessage('programmatic-target'); harness.registerCodexAppSteerSessionMessage('session-a', { id: 'programmatic-target', role: 'user', content: '表单答案', attachments: [{ id: 'img-programmatic', filename: 'programmatic.png' }], timestamp: '2026-08-11T00:00:01.000Z', codexAppSteerStatus: 'pending', codexAppSteerMessage: '引导中...', }, element); assert.strictEqual(harness.records.has('programmatic-target'), true, '程序化 session_message 应补建可重试记录'); harness.updateCodexAppSteerMessage('programmatic-target', 'failed', '插入失败'); const retry = element.querySelector('.codex-steer-retry'); assert(retry, '程序化失败消息应展示可用重试按钮'); retry.click(); assert.deepStrictEqual(JSON.parse(JSON.stringify(harness.sent[0])), { type: 'message', text: '表单答案', attachments: [{ id: 'img-programmatic', filename: 'programmatic.png' }], sessionId: 'session-a', mode: 'yolo', agent: 'codexapp', clientMessageId: 'programmatic-target', }); harness.updateCodexAppSteerMessage('programmatic-target', 'inserted', '已插入'); assert.strictEqual(element.dataset.sessionMessage, 'true', '程序化临时项成功后才提交消息索引'); assert.strictEqual(element.dataset.messageIndex, '4', '程序化临时项应复用当前持久化消息序号'); assert.strictEqual(harness.currentSessionMessageCount, 5, '程序化临时项成功后只递增一次消息计数'); } function assertStaticContracts() { const appSource = fs.readFileSync(APP_PATH, 'utf8'); const styleSource = fs.readFileSync(STYLE_PATH, 'utf8'); assert(appSource.includes('const codexAppSteerRecords = new Map();'), '前端应维护独立临时插入记录'); assert(appSource.includes("clientMessageId: id"), '重试应复用稳定 clientMessageId'); assert(appSource.includes("aria-busy"), 'pending 卡片应暴露 aria-busy'); assert(styleSource.includes('.codex-steer-actions'), '失败操作样式应限定在 codex-steer 区段'); assert(styleSource.includes('.codex-steer-retry'), '应存在重试按钮样式'); assert(styleSource.includes('.codex-steer-delete'), '应存在删除按钮样式'); } assertStaticContracts(); assertFailedActionsContract(); assertDeleteAndBlockedRetryContract(); assertProgrammaticSessionMessageRetryContract(); console.log('Failed insert card frontend unit checks passed.');