Files
cc-web/scripts/failed-insert-card-unit.js

444 lines
16 KiB
JavaScript

'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.');