444 lines
16 KiB
JavaScript
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.');
|