fix: 修复会话归组和文件预览编码
This commit is contained in:
@@ -5003,6 +5003,180 @@ async function runAdvancedSessionSearchRegression() {
|
||||
});
|
||||
}
|
||||
|
||||
function makeLargeSessionPreviewFixture(sessionId, tailFields = {}) {
|
||||
return JSON.stringify({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
content: 'Preview text with escaped pseudo keys: "cwd": "/tmp/string-cwd", "title": "String Title" and a backslash \\ marker.',
|
||||
toolCalls: [
|
||||
{
|
||||
name: 'mcp__preview__cwd',
|
||||
input: {
|
||||
arguments: {
|
||||
cwd: '/home/fineui-web-mcp',
|
||||
projectName: 'fineui-web-mcp',
|
||||
},
|
||||
},
|
||||
done: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
padding: 'x'.repeat(70 * 1024),
|
||||
id: sessionId,
|
||||
...tailFields,
|
||||
});
|
||||
}
|
||||
|
||||
function makeHeadTailJoinRiskFixture(sessionId, previewBytes) {
|
||||
const beforePadding = `{"id":${JSON.stringify(sessionId)},"title":"Boundary Join Risk","updated":"2026-08-06T00:02:00.000Z","agent":"codexapp","padding":"`;
|
||||
const beforeCwd = '","cwd":';
|
||||
const targetHeadBytes = previewBytes - Buffer.byteLength(beforeCwd);
|
||||
const paddingBytes = targetHeadBytes - Buffer.byteLength(beforePadding);
|
||||
assert(paddingBytes > 0, 'Boundary fixture should fit the preview head');
|
||||
const head = `${beforePadding}${'x'.repeat(paddingBytes)}${beforeCwd}`;
|
||||
assert(Buffer.byteLength(head) === previewBytes, 'Boundary fixture should end the head preview after cwd colon');
|
||||
return `${head}${' '.repeat(70 * 1024)}"/tmp/joined-preview-cwd","messages":[]}`;
|
||||
}
|
||||
|
||||
async function runSessionPreviewMetadataRegression() {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-session-preview-metadata-'));
|
||||
const configDir = path.join(tempRoot, 'config');
|
||||
const sessionsDir = path.join(tempRoot, 'sessions');
|
||||
const logsDir = path.join(tempRoot, 'logs');
|
||||
const homeDir = path.join(tempRoot, 'home');
|
||||
mkdirp(configDir);
|
||||
mkdirp(sessionsDir);
|
||||
mkdirp(logsDir);
|
||||
mkdirp(homeDir);
|
||||
|
||||
const fullParseBytes = 64 * 1024;
|
||||
const previewBytes = 16 * 1024;
|
||||
const tailCwd = path.join(homeDir, '上下游协同平台');
|
||||
const tailSessionId = 'preview-tail-top-level';
|
||||
const nullSessionId = 'preview-null-fields';
|
||||
const boundarySessionId = 'preview-boundary-join-risk';
|
||||
const tailSessionPath = path.join(sessionsDir, `${tailSessionId}.json`);
|
||||
const nullSessionPath = path.join(sessionsDir, `${nullSessionId}.json`);
|
||||
const boundarySessionPath = path.join(sessionsDir, `${boundarySessionId}.json`);
|
||||
|
||||
fs.writeFileSync(tailSessionPath, makeLargeSessionPreviewFixture(tailSessionId, {
|
||||
title: 'Escaped "Top" \\\\ Title',
|
||||
updated: '2026-08-06T00:00:00.000Z',
|
||||
created: '2026-08-05T23:00:00.000Z',
|
||||
pinnedAt: null,
|
||||
titleSource: 'llm',
|
||||
createdFrom: { kind: 'ccweb_prompt_user' },
|
||||
hasUnread: true,
|
||||
agent: 'codexapp',
|
||||
cwd: tailCwd,
|
||||
}));
|
||||
fs.writeFileSync(nullSessionPath, makeLargeSessionPreviewFixture(nullSessionId, {
|
||||
title: null,
|
||||
updated: '2026-08-06T00:01:00.000Z',
|
||||
created: null,
|
||||
pinnedAt: null,
|
||||
titleSource: null,
|
||||
createdFrom: { kind: null },
|
||||
hasUnread: false,
|
||||
agent: null,
|
||||
cwd: null,
|
||||
}));
|
||||
fs.writeFileSync(
|
||||
boundarySessionPath,
|
||||
makeHeadTailJoinRiskFixture(boundarySessionId, previewBytes)
|
||||
);
|
||||
assert(fs.statSync(tailSessionPath).size > fullParseBytes, 'Tail fixture must use oversized preview parsing');
|
||||
assert(fs.statSync(nullSessionPath).size > fullParseBytes, 'Null fixture must use oversized preview parsing');
|
||||
assert(fs.statSync(boundarySessionPath).size > fullParseBytes, 'Boundary fixture must use oversized preview parsing');
|
||||
|
||||
const port = await getFreePort();
|
||||
const password = 'SessionPreview!234';
|
||||
await withServer({
|
||||
PORT: String(port),
|
||||
CC_WEB_PASSWORD: password,
|
||||
CC_WEB_INTERNAL_MCP_TOKEN: 'SessionPreviewMcp!234',
|
||||
CC_WEB_CONFIG_DIR: configDir,
|
||||
CC_WEB_SESSIONS_DIR: sessionsDir,
|
||||
CC_WEB_LOGS_DIR: logsDir,
|
||||
CC_WEB_SESSION_META_FULL_PARSE_MAX_BYTES: String(fullParseBytes),
|
||||
CC_WEB_SESSION_META_PREVIEW_BYTES: String(previewBytes),
|
||||
HOME: homeDir,
|
||||
CLAUDE_PATH: MOCK_CLAUDE,
|
||||
CODEX_PATH: MOCK_CODEX_APP_SERVER,
|
||||
}, async () => {
|
||||
const { ws, messages } = await connectWs(port, password);
|
||||
const list = await nextMessage(messages, ws, (msg) => (
|
||||
msg.type === 'session_list' &&
|
||||
[tailSessionId, nullSessionId, boundarySessionId].every((id) => (
|
||||
msg.sessions.some((session) => session.id === id)
|
||||
))
|
||||
), 5000);
|
||||
const sessionById = new Map(list.sessions.map((session) => [session.id, session]));
|
||||
const tailSession = sessionById.get(tailSessionId);
|
||||
const nullSession = sessionById.get(nullSessionId);
|
||||
const boundarySession = sessionById.get(boundarySessionId);
|
||||
|
||||
assert(tailSession.cwd === tailCwd, `Oversized preview should read tail top-level cwd, got ${JSON.stringify(tailSession.cwd)}`);
|
||||
assert(tailSession.projectName === path.basename(tailCwd), `Project name should derive from top-level cwd, got ${JSON.stringify(tailSession.projectName)}`);
|
||||
assert(tailSession.title === 'Escaped "Top" \\\\ Title', `Top-level escaped title should be preserved, got ${JSON.stringify(tailSession.title)}`);
|
||||
assert(tailSession.createdFromKind === 'ccweb_prompt_user', 'Top-level createdFrom.kind should be read from preview metadata');
|
||||
assert(tailSession.hasUnread === true, 'Top-level hasUnread should be read from preview metadata');
|
||||
|
||||
assert(nullSession.title === 'Untitled', `Null top-level title should fall back safely, got ${JSON.stringify(nullSession.title)}`);
|
||||
assert(nullSession.cwd === '', `Null top-level cwd should not use nested cwd, got ${JSON.stringify(nullSession.cwd)}`);
|
||||
assert(nullSession.projectName === '', `Null top-level cwd should keep projectName empty, got ${JSON.stringify(nullSession.projectName)}`);
|
||||
assert(nullSession.createdFromKind === null, 'Null nested top-level kind should fall back to null');
|
||||
assert(nullSession.hasUnread === false, 'False top-level hasUnread should be preserved');
|
||||
|
||||
assert(boundarySession.cwd === '', `Preview parser should not join head cwd key with tail value, got ${JSON.stringify(boundarySession.cwd)}`);
|
||||
assert(boundarySession.projectName === '', `Boundary preview should not derive a projectName from a joined value, got ${JSON.stringify(boundarySession.projectName)}`);
|
||||
ws.close();
|
||||
});
|
||||
}
|
||||
|
||||
async function runFilePreviewEncodingRegression() {
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-file-preview-encoding-'));
|
||||
const configDir = path.join(tempRoot, 'config');
|
||||
const sessionsDir = path.join(tempRoot, 'sessions');
|
||||
const logsDir = path.join(tempRoot, 'logs');
|
||||
const workspace = path.join(tempRoot, '中文工作区');
|
||||
mkdirp(configDir); mkdirp(sessionsDir); mkdirp(logsDir); mkdirp(workspace);
|
||||
const text = '中文注释:文件预览编码兼容';
|
||||
fs.writeFileSync(path.join(workspace, 'utf8.cs'), text, 'utf8');
|
||||
fs.writeFileSync(path.join(workspace, 'utf8-bom.cs'), Buffer.concat([Buffer.from([0xef, 0xbb, 0xbf]), Buffer.from(text)]));
|
||||
fs.writeFileSync(path.join(workspace, 'utf16le.cs'), Buffer.concat([Buffer.from([0xff, 0xfe]), Buffer.from(text, 'utf16le')]));
|
||||
fs.writeFileSync(path.join(workspace, 'utf16be.cs'), Buffer.concat([Buffer.from([0xfe, 0xff]), Buffer.from(text, 'utf16le').swap16()]));
|
||||
fs.writeFileSync(path.join(workspace, 'gb18030.cs'), Buffer.from('d6d0cec4d7a2cacda3bacec4bcfed4a4c0c0b1e0c2ebbce6c8dd', 'hex'));
|
||||
const previewLimit = 200 * 1024;
|
||||
fs.writeFileSync(path.join(workspace, 'utf8-truncated.cs'), Buffer.concat([Buffer.alloc(previewLimit - 1, 0x61), Buffer.from('中尾')]));
|
||||
fs.writeFileSync(path.join(workspace, 'gb18030-truncated.cs'), Buffer.concat([Buffer.alloc(previewLimit - 3, 0x61), Buffer.from('d6d0d600', 'hex')]));
|
||||
fs.writeFileSync(path.join(workspace, 'binary.bin'), Buffer.from([0x00, 0xff, 0x01, 0x80, 0x00]));
|
||||
const sessionId = 'file-preview-encoding-session';
|
||||
fs.writeFileSync(path.join(sessionsDir, `${sessionId}.json`), JSON.stringify({ id: sessionId, cwd: workspace, title: '编码回归', messages: [] }));
|
||||
const port = await getFreePort();
|
||||
const password = 'FilePreviewEncoding!234';
|
||||
await withServer({ PORT: String(port), CC_WEB_PASSWORD: password, CC_WEB_CONFIG_DIR: configDir, CC_WEB_SESSIONS_DIR: sessionsDir, CC_WEB_LOGS_DIR: logsDir, HOME: tempRoot, CLAUDE_PATH: MOCK_CLAUDE, CODEX_PATH: MOCK_CODEX_APP_SERVER }, async () => {
|
||||
const { ws, messages, token } = await connectWs(port, password);
|
||||
await nextMessage(messages, ws, (msg) => msg.type === 'session_list');
|
||||
for (const filename of ['utf8.cs', 'utf8-bom.cs', 'utf16le.cs', 'utf16be.cs', 'gb18030.cs']) {
|
||||
const payload = await fetchAuthedJson(port, token, `/api/fs/read?sessionId=${sessionId}&path=${encodeURIComponent(filename)}`);
|
||||
assert(payload.content === text, `${filename} should decode Chinese text correctly, got ${JSON.stringify(payload.content)}`);
|
||||
assert(typeof payload.encoding === 'string' && payload.encoding.length > 0, `${filename} should report detected encoding`);
|
||||
}
|
||||
for (const [filename, encoding] of [['utf8-truncated.cs', 'utf-8'], ['gb18030-truncated.cs', 'gb18030']]) {
|
||||
const payload = await fetchAuthedJson(port, token, `/api/fs/read?sessionId=${sessionId}&path=${filename}`);
|
||||
assert(payload.truncated === true, `${filename} should exercise the preview-size truncation branch`);
|
||||
assert(payload.encoding === encoding, `${filename} should retain ${encoding} detection across a split multibyte character`);
|
||||
assert(!payload.content.includes('\ufffd'), `${filename} should omit an incomplete trailing character instead of rendering replacement text`);
|
||||
}
|
||||
const binaryResponse = await fetch(`http://127.0.0.1:${port}/api/fs/read?sessionId=${sessionId}&path=binary.bin`, { headers: { Authorization: `Bearer ${token}` } });
|
||||
assert(binaryResponse.status === 415, `Binary preview should remain 415, got ${binaryResponse.status}`);
|
||||
ws.close();
|
||||
});
|
||||
}
|
||||
|
||||
function assertWindowsStartupContract() {
|
||||
const source = fs.readFileSync(WINDOWS_START_PATH, 'utf8').replace(/\r\n/g, '\n');
|
||||
|
||||
@@ -5120,6 +5294,16 @@ async function main() {
|
||||
console.log('Advanced session search regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'session-preview-metadata') {
|
||||
await runSessionPreviewMetadataRegression();
|
||||
console.log('Session preview metadata regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'file-preview-encoding') {
|
||||
await runFilePreviewEncodingRegression();
|
||||
console.log('File preview encoding regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'usage-statistics') {
|
||||
assertUsageStatisticsUnitChecks();
|
||||
assertUsageStatisticsContract();
|
||||
@@ -5174,6 +5358,7 @@ async function main() {
|
||||
assertMultiAgentV2CompatibilityContract();
|
||||
assertWindowsStartupContract();
|
||||
assertCcwebDisplayImageContract();
|
||||
await runSessionPreviewMetadataRegression();
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
|
||||
const configDir = path.join(tempRoot, 'config');
|
||||
|
||||
Reference in New Issue
Block a user