修复消息渲染并更新发布包
This commit is contained in:
126
scripts/codex-start-lock-unit.js
Normal file
126
scripts/codex-start-lock-unit.js
Normal file
@@ -0,0 +1,126 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { createCodexAppServerClient } = require('../lib/codex-app-server-client');
|
||||
const {
|
||||
acquireCodexHomeLock,
|
||||
readProcessStartTime,
|
||||
} = require('../lib/codex-home-lock');
|
||||
|
||||
const FAKE_SERVER = path.join(os.tmpdir(), `ccweb-fake-codex-${process.pid}.js`);
|
||||
fs.writeFileSync(FAKE_SERVER, String.raw`
|
||||
'use strict';
|
||||
const fs = require('fs');
|
||||
const readline = require('readline');
|
||||
const attemptFile = process.env.FAKE_ATTEMPT_FILE;
|
||||
const failureMode = process.env.FAKE_FAILURE_MODE || 'busy';
|
||||
let attempt = 0;
|
||||
try { attempt = Number(fs.readFileSync(attemptFile, 'utf8')) || 0; } catch {}
|
||||
attempt += 1;
|
||||
fs.writeFileSync(attemptFile, String(attempt));
|
||||
if (attempt <= Number(process.env.FAKE_BUSY_ATTEMPTS || 0)) {
|
||||
process.stderr.write(failureMode === 'busy' ? 'database is locked (code: 5)\n' : 'fatal config error\n');
|
||||
process.exit(1);
|
||||
}
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on('line', (line) => {
|
||||
let message;
|
||||
try { message = JSON.parse(line); } catch { return; }
|
||||
if (message.id && message.method === 'initialize') {
|
||||
process.stdout.write(JSON.stringify({ id: message.id, result: { serverInfo: { name: 'fake' } } }) + '\n');
|
||||
}
|
||||
});
|
||||
`, 'utf8');
|
||||
|
||||
function tempHome() {
|
||||
return fs.mkdtempSync(path.join(os.tmpdir(), 'ccweb-codex-home-'));
|
||||
}
|
||||
|
||||
async function testLockMutualExclusion() {
|
||||
const home = tempHome();
|
||||
const first = await acquireCodexHomeLock({ env: { CODEX_HOME: home } });
|
||||
await assert.rejects(
|
||||
() => acquireCodexHomeLock({ env: { CODEX_HOME: home }, waitMs: 80, pollMs: 10, pollMaxMs: 20 }),
|
||||
(error) => error.code === 'CODEX_HOME_LOCK_BUSY' && error.owner?.pid === process.pid,
|
||||
);
|
||||
await first.release();
|
||||
assert.equal(fs.existsSync(path.join(home, '.cc-web-codex.lock')), false);
|
||||
}
|
||||
|
||||
async function testStaleLockAndPidReuseProtection() {
|
||||
const home = tempHome();
|
||||
const lockPath = path.join(home, '.cc-web-codex.lock');
|
||||
fs.writeFileSync(lockPath, JSON.stringify({
|
||||
pid: process.pid,
|
||||
startTime: 'definitely-not-the-current-start-time',
|
||||
token: 'stale',
|
||||
createdAt: new Date().toISOString(),
|
||||
}));
|
||||
const lock = await acquireCodexHomeLock({ env: { CODEX_HOME: home }, waitMs: 100 });
|
||||
assert.equal(lock.owner.pid, process.pid);
|
||||
await lock.release();
|
||||
assert.equal(fs.existsSync(lockPath), false);
|
||||
assert.equal(typeof readProcessStartTime(process.pid), 'string');
|
||||
}
|
||||
|
||||
async function createClient(home, attemptFile, extraEnv = {}) {
|
||||
return createCodexAppServerClient({
|
||||
command: process.execPath,
|
||||
args: [FAKE_SERVER],
|
||||
env: {
|
||||
...process.env,
|
||||
CODEX_HOME: home,
|
||||
FAKE_ATTEMPT_FILE: attemptFile,
|
||||
...extraEnv,
|
||||
},
|
||||
startRetryDelayMs: 1,
|
||||
startRetryMaxDelayMs: 2,
|
||||
lockWaitMs: 100,
|
||||
});
|
||||
}
|
||||
|
||||
async function testBusyRetryAndRelease() {
|
||||
const home = tempHome();
|
||||
const attempts = path.join(home, 'attempts');
|
||||
fs.writeFileSync(attempts, '0');
|
||||
const client = await createClient(home, attempts, { FAKE_BUSY_ATTEMPTS: '2' });
|
||||
await client.start();
|
||||
assert.equal(fs.readFileSync(attempts, 'utf8'), '3');
|
||||
client.stop();
|
||||
for (let i = 0; i < 30 && fs.existsSync(path.join(home, '.cc-web-codex.lock')); i += 1) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
assert.equal(fs.existsSync(path.join(home, '.cc-web-codex.lock')), false);
|
||||
}
|
||||
|
||||
async function testNonBusyDoesNotRetry() {
|
||||
const home = tempHome();
|
||||
const attempts = path.join(home, 'attempts');
|
||||
fs.writeFileSync(attempts, '0');
|
||||
const client = await createClient(home, attempts, { FAKE_BUSY_ATTEMPTS: '1', FAKE_FAILURE_MODE: 'fatal' });
|
||||
await assert.rejects(() => client.start(), (error) => error.code === 'CODEX_APP_START_FAILED');
|
||||
assert.equal(fs.readFileSync(attempts, 'utf8'), '1');
|
||||
assert.equal(fs.existsSync(path.join(home, '.cc-web-codex.lock')), false);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
try {
|
||||
await testLockMutualExclusion();
|
||||
await testStaleLockAndPidReuseProtection();
|
||||
await testBusyRetryAndRelease();
|
||||
await testNonBusyDoesNotRetry();
|
||||
console.log('codex-start-lock-unit: ok');
|
||||
} finally {
|
||||
try { fs.unlinkSync(FAKE_SERVER); } catch {}
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
console.error(error.stack || error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -1613,6 +1613,91 @@ function assertFrontendMarkdownLinkContract() {
|
||||
assert(styleSource.includes('.msg-bubble a.local-file-link'), 'Local file links should have a distinct message style');
|
||||
}
|
||||
|
||||
function assertMarkdownRenderingContract() {
|
||||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
|
||||
const normalizeMarkdownText = require(path.join(PUBLIC_DIR, 'markdown-normalizer.js'));
|
||||
const { createCodexAppRuntime } = require(path.join(REPO_DIR, 'lib', 'codex-app-runtime'));
|
||||
const assistantRendererStart = source.indexOf('function renderAssistantContent');
|
||||
const assistantRendererEnd = source.indexOf('function createAssistantImageElement', assistantRendererStart);
|
||||
const assistantRenderer = assistantRendererStart >= 0 && assistantRendererEnd > assistantRendererStart
|
||||
? source.slice(assistantRendererStart, assistantRendererEnd)
|
||||
: '';
|
||||
|
||||
const indentedChineseText = ' 目前代码里 `PlanList` 的运行时和前端渲染逻辑仍然存在,问题更像是事件没有被识别。';
|
||||
assert(
|
||||
normalizeMarkdownText(indentedChineseText).startsWith('目前代码里'),
|
||||
'Clearly indented Chinese prose should be normalized before Markdown parsing'
|
||||
);
|
||||
assert(
|
||||
normalizeMarkdownText(' 这是一段普通说明。\n\n 这是下一段普通说明。') === '这是一段普通说明。\n\n这是下一段普通说明。',
|
||||
'Multi-paragraph prose indentation should be removed independently'
|
||||
);
|
||||
assert(normalizeMarkdownText('\t这是一段普通说明。') === '这是一段普通说明。', 'Tab-indented prose should also be normalized');
|
||||
const fencedCode = '说明文字。\n\n```js\n const value = 1;\n```';
|
||||
assert(normalizeMarkdownText(fencedCode) === fencedCode, 'Explicit fenced code blocks should remain byte-for-byte unchanged');
|
||||
const indentedCode = ' const value = 1;\n console.log(value);';
|
||||
assert(normalizeMarkdownText(indentedCode) === indentedCode, 'Indented source code should retain its indentation');
|
||||
const whitespaceOnly = '结论一。\n \n结论二。';
|
||||
assert(normalizeMarkdownText(whitespaceOnly) === whitespaceOnly, 'Whitespace-only lines should never be treated as prose indentation');
|
||||
assert(
|
||||
assistantRenderer.includes("block.type === 'todo_list'") && assistantRenderer.includes('createTodoListElement(block)'),
|
||||
'Structured todo_list blocks should continue to render through the PlanList card path'
|
||||
);
|
||||
assert(
|
||||
indexSource.includes('markdown-normalizer.js')
|
||||
&& source.includes('typeof window.ccNormalizeMarkdownText === \'function\'')
|
||||
&& source.includes('return marked.parse(normalizedText)'),
|
||||
'Assistant Markdown rendering should load and apply the shared text normalizer'
|
||||
);
|
||||
|
||||
const streamed = [];
|
||||
const runtime = createCodexAppRuntime({
|
||||
wsSend: (_ws, payload) => streamed.push(payload),
|
||||
loadSession: () => null,
|
||||
saveSession: () => {},
|
||||
});
|
||||
const entry = { ws: {}, toolCalls: [], fullText: '' };
|
||||
runtime.processCodexAppNotification(entry, {
|
||||
method: 'item/agentMessage/delta',
|
||||
params: { itemId: 'msg-first', delta: '先给出的结论。' },
|
||||
}, 'markdown-session');
|
||||
runtime.processCodexAppNotification(entry, {
|
||||
method: 'item/completed',
|
||||
params: { item: { id: 'msg-first', type: 'agentMessage', text: '先给出的结论。' } },
|
||||
}, 'markdown-session');
|
||||
['rs-noise-1', 'rs-noise-2', 'rs-noise-3'].forEach((itemId) => {
|
||||
runtime.processCodexAppNotification(entry, {
|
||||
method: 'item/agentMessage/delta',
|
||||
params: { itemId, delta: ' ' },
|
||||
}, 'markdown-session');
|
||||
runtime.processCodexAppNotification(entry, {
|
||||
method: 'item/completed',
|
||||
params: { item: { id: itemId, type: 'agentMessage', text: ' ' } },
|
||||
}, 'markdown-session');
|
||||
});
|
||||
runtime.processCodexAppNotification(entry, {
|
||||
method: 'item/agentMessage/delta',
|
||||
params: { itemId: 'msg-second', delta: '普通正文不应带缩进。' },
|
||||
}, 'markdown-session');
|
||||
const lastLine = entry.fullText.split('\n').filter((line) => line.trim()).pop() || '';
|
||||
assert(lastLine === '普通正文不应带缩进。', 'Whitespace-only agent message chunks must not indent the following message text');
|
||||
assert(entry.fullText.startsWith('先给出的结论。'), 'Whitespace-only chunks must not create leading whitespace on the first message');
|
||||
assert(
|
||||
!streamed.some((payload) => payload.type === 'text_delta' && !payload.text.trim()),
|
||||
'Whitespace-only chunks must not be streamed to the transcript'
|
||||
);
|
||||
runtime.processCodexAppNotification(entry, {
|
||||
method: 'item/agentMessage/delta',
|
||||
params: { itemId: 'msg-code', delta: ' ' },
|
||||
}, 'markdown-session');
|
||||
runtime.processCodexAppNotification(entry, {
|
||||
method: 'item/agentMessage/delta',
|
||||
params: { itemId: 'msg-code', delta: 'const value = 1;' },
|
||||
}, 'markdown-session');
|
||||
assert(entry.fullText.includes(' const value = 1;'), 'Leading indentation should be retained when later chunks complete the same code message');
|
||||
}
|
||||
|
||||
function assertFrontendMcpReloadContract() {
|
||||
const source = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
assert(source.includes('function mcpStartupStatusToastText(status)'), 'Frontend should format MCP startup status toast text');
|
||||
@@ -7162,6 +7247,12 @@ async function main() {
|
||||
console.log('Plan List progress regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'markdown-rendering') {
|
||||
assertMarkdownRenderingContract();
|
||||
assertPlanListProgressContract();
|
||||
console.log('Markdown rendering regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'runtime-image-send') {
|
||||
await runRuntimeImageSendTarget();
|
||||
console.log('Runtime image send regression checks passed.');
|
||||
@@ -7300,6 +7391,7 @@ async function main() {
|
||||
assertComposerSlashRoutingContract();
|
||||
assertFrontendCcwebPromptContract();
|
||||
assertFrontendMarkdownLinkContract();
|
||||
assertMarkdownRenderingContract();
|
||||
assertMockCodexAppPromptUserNotTextTriggered();
|
||||
assertFrontendMcpReloadContract();
|
||||
assertCcwebMcpRecoveryContract();
|
||||
|
||||
Reference in New Issue
Block a user