fix: 修复标题归属并更新发布包

This commit is contained in:
shiyue
2026-08-07 14:22:52 +08:00
parent 4e1f987d62
commit 70be7eb279
14 changed files with 613 additions and 17 deletions

View File

@@ -11,9 +11,10 @@ const WebSocket = require('ws');
const REPO_DIR = path.resolve(__dirname, '..');
const SERVER_PATH = path.join(REPO_DIR, 'server.js');
const WINDOWS_START_PATH = path.join(REPO_DIR, 'start.bat');
const PUBLIC_APP_PATH = path.join(REPO_DIR, 'public', 'app.js');
const PUBLIC_INDEX_PATH = path.join(REPO_DIR, 'public', 'index.html');
const PUBLIC_STYLE_PATH = path.join(REPO_DIR, 'public', 'style.css');
const PUBLIC_DIR = path.join(REPO_DIR, 'public');
const PUBLIC_APP_PATH = path.join(PUBLIC_DIR, 'app.js');
const PUBLIC_INDEX_PATH = path.join(PUBLIC_DIR, 'index.html');
const PUBLIC_STYLE_PATH = path.join(PUBLIC_DIR, 'style.css');
const SESSION_SEARCH_INDEX_PATH = path.join(REPO_DIR, 'lib', 'session-search-index.js');
const USAGE_STATISTICS_PATH = path.join(REPO_DIR, 'lib', 'usage-statistics.js');
const USAGE_STATISTICS_UNIT_PATH = path.join(REPO_DIR, 'scripts', 'usage-statistics-unit.js');
@@ -623,7 +624,7 @@ function assertFrontendSidebarCollapseContract() {
);
assert(
indexSource.includes('style.css?v=20260805-usage-loading-inline')
&& indexSource.includes('app.js?v=20260805-usage-loading-inline'),
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Sidebar interaction assets should share the reviewed cache-busting version'
);
}
@@ -947,7 +948,7 @@ function assertPlanListProgressContract() {
assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments');
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Plan progress CSS should use the current cache-busted URL');
assert(indexSource.includes('app.js?v=20260805-usage-loading-inline'), 'Plan progress frontend logic should use the current cache-busted URL');
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Plan progress frontend logic should use the dynamic cache-busted URL');
}
function assertFrontendGildedThemeContract() {
@@ -1063,7 +1064,7 @@ function assertFrontendGildedThemeContract() {
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
assert(indexSource.includes('app.js?v=20260805-usage-loading-inline'), 'Theme bundle app script should use the current cache-busted asset URL');
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Theme bundle app script should use the dynamic cache-busted asset URL');
}
function assertFrontendWastelandThemeContract() {
@@ -1317,7 +1318,7 @@ function assertFrontendWastelandThemeContract() {
});
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
assert(indexSource.includes('app.js?v=20260805-usage-loading-inline'), 'Wasteland registration should share the cache-busted theme bundle URL');
assert(indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'), 'Wasteland registration should share the dynamic cache-busted theme bundle URL');
}
function assertFrontendCcwebPromptContract() {
@@ -3033,6 +3034,18 @@ function assertTitleHistoryOutlineContract() {
assert(timeline[4].label === '旧历史兼容主题' && !timeline[4].targetMessageId, 'Legacy title history should fall back to the nearest preceding user message and remain read-only');
assert(timeline[0].label !== outlineApi.formatUserOutlineDate(titleChangedAt), 'A title crossing midnight should inherit the triggering message calendar day');
const repeatedAnchorTimeline = outlineApi.buildUserOutlineTimelineItems([
{ type: 'message', id: 'user-1', targetMessageId: 'hapi-message-user-1', label: '第一步', timestamp: beforeMidnight, messageIndex: 0 },
], [
{ title: '旧标题', changedAt: titleChangedAt, messageIndex: 1, anchorMessageIndex: 0, source: 'llm' },
{ title: '最新标题', changedAt: afterMidnight, messageIndex: 1, anchorMessageIndex: 0, source: 'llm' },
]);
assert(
repeatedAnchorTimeline.filter((item) => item.type === 'title').length === 1 &&
repeatedAnchorTimeline.find((item) => item.type === 'title')?.label === '最新标题',
'Repeated title changes for one user message should render only the latest heading'
);
const updateOutlineSource = extractFunctionSource(frontendSource, 'updateUserOutlinePanel');
assert(updateOutlineSource.includes('user-outline-title-event') && updateOutlineSource.includes('user-outline-date'), 'Outline renderer should include dedicated title and date nodes');
assert(updateOutlineSource.includes("item.type === 'message'"), 'Outline renderer should reserve buttons for selectable message nodes');
@@ -4109,6 +4122,216 @@ function assertUnlimitedImageAttachmentsContract() {
);
}
function assertFrontendAssetVersionContract() {
const indexSource = fs.readFileSync(PUBLIC_INDEX_PATH, 'utf8');
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
const failures = [];
const record = (label, check) => {
try {
check();
} catch (err) {
failures.push(`${label}: ${err?.message || err}`);
}
};
record('index template', () => {
assert(
!indexSource.includes('app.js?v=20260805-usage-loading-inline'),
'Index template should not pin app.js to the stale 20260805-usage-loading-inline cache-bust value'
);
assert(
indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Index template should load app.js with the __CC_WEB_FRONTEND_ASSET_VERSION__ placeholder'
);
assert(
indexSource.includes('__CC_WEB_FRONTEND_ASSET_VERSION__') &&
indexSource.includes('window.ccWebFrontendAssetVersion'),
'Index template should expose the same frontend asset version placeholder to the loaded script'
);
});
record('server asset hash', () => {
const computeSource = extractFunctionSource(serverSource, 'computeFrontendAssetVersion');
assert(
/crypto\.createHash\(['"]sha256['"]\)/.test(computeSource),
'computeFrontendAssetVersion should hash the app.js bytes with SHA-256'
);
assert(
/fs\.readFileSync\(\s*(?:PUBLIC_APP_PATH|path\.join\(\s*PUBLIC_DIR\s*,\s*['"]app\.js['"]\s*\))/.test(computeSource),
'computeFrontendAssetVersion should read public/app.js, not a hard-coded version string'
);
assert(
/\.digest\(['"]hex['"]\)/.test(computeSource),
'computeFrontendAssetVersion should return a hex digest value'
);
});
record('server index injection', () => {
assert(
serverSource.includes('const PUBLIC_DIR = path.resolve('),
'Server should normalize CC_WEB_PUBLIC_DIR before static path checks and index injection'
);
assert(
serverSource.includes('isPathInside(PUBLIC_DIR, filePath)'),
'Server should keep static responses inside the normalized public directory'
);
assert(
serverSource.includes('__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Server should know and replace the frontend asset version placeholder'
);
assert(
/replace(?:All)?\(\s*['"]__CC_WEB_FRONTEND_ASSET_VERSION__['"]\s*,\s*(?:frontendAssetVersion|computeFrontendAssetVersion\(\))/.test(serverSource),
'Server should replace __CC_WEB_FRONTEND_ASSET_VERSION__ with the current computed app.js asset version'
);
assert(
/filePath\s*={0,2}=+\s*(?:PUBLIC_INDEX_PATH|path\.join\(\s*PUBLIC_DIR\s*,\s*['"]index\.html['"]\s*\))/.test(serverSource) ||
/url\.pathname\s*={2,3}\s*['"]\/['"][\s\S]*?__CC_WEB_FRONTEND_ASSET_VERSION__/.test(serverSource),
'Server should inject the version when serving index.html'
);
});
record('server auth result', () => {
const authIndex = serverSource.indexOf("type: 'auth_result'");
const authSuccessIndex = serverSource.indexOf('success: true', authIndex);
const authBlock = authSuccessIndex >= 0
? serverSource.slice(authSuccessIndex, serverSource.indexOf('sendSessionList(ws);', authSuccessIndex))
: '';
assert(
authBlock.includes('frontendAssetVersion'),
'Successful auth_result should include frontendAssetVersion'
);
assert(
/frontendAssetVersion\s*:\s*computeFrontendAssetVersion\(\)/.test(authBlock),
'Successful auth_result frontendAssetVersion should come from the current computed app.js asset version'
);
});
record('frontend static hooks', () => {
assert(
frontendSource.includes('window.ccWebFrontendAssetVersion'),
'Frontend should read the asset version that index.html injected for the currently loaded app.js'
);
assert(
frontendSource.includes('function normalizeFrontendAssetVersion'),
'Frontend should normalize frontend asset versions before comparing them'
);
assert(
frontendSource.includes('let frontendAssetVersionReloadRequested = false') ||
frontendSource.includes('sessionStorage'),
'Frontend should keep a reload guard so one mismatch cannot trigger a reload loop'
);
assert(
frontendSource.includes('function shouldReloadForFrontendAssetVersion'),
'Frontend should expose shouldReloadForFrontendAssetVersion for executable regression coverage'
);
assert(
frontendSource.includes('msg.frontendAssetVersion'),
'Frontend auth_result handling should read msg.frontendAssetVersion'
);
assert(
frontendSource.includes('location.reload()') || frontendSource.includes('window.location.reload()'),
'Frontend should reload the page when shouldReloadForFrontendAssetVersion reports a real mismatch'
);
});
record('frontend reload behavior', () => {
const normalizeSource = extractFunctionSource(frontendSource, 'normalizeFrontendAssetVersion');
const shouldReloadSource = extractFunctionSource(frontendSource, 'shouldReloadForFrontendAssetVersion');
const reloadApi = new Function(`
let frontendAssetVersionReloadRequested = false;
const window = {
ccWebFrontendAssetVersion: 'aaaaaaaaaaaaaaaa',
sessionStorage: {
data: new Map(),
getItem(key) { return this.data.has(key) ? this.data.get(key) : null; },
setItem(key, value) { this.data.set(key, String(value)); },
removeItem(key) { this.data.delete(key); },
},
};
const sessionStorage = window.sessionStorage;
${normalizeSource}
${shouldReloadSource}
return { shouldReloadForFrontendAssetVersion };
`)();
assert(
reloadApi.shouldReloadForFrontendAssetVersion('aaaaaaaaaaaaaaaa', 'aaaaaaaaaaaaaaaa') === false,
'Same frontend asset version should not reload'
);
assert(
reloadApi.shouldReloadForFrontendAssetVersion('', 'aaaaaaaaaaaaaaaa') === false &&
reloadApi.shouldReloadForFrontendAssetVersion('not-a-version', 'aaaaaaaaaaaaaaaa') === false &&
reloadApi.shouldReloadForFrontendAssetVersion('bbbbbbbbbbbbbbbb', 'not-a-version') === false,
'Missing or illegal frontend asset versions should not reload'
);
assert(
reloadApi.shouldReloadForFrontendAssetVersion('bbbbbbbbbbbbbbbb', 'aaaaaaaaaaaaaaaa') === true,
'First valid frontend asset version mismatch should request one reload'
);
assert(
reloadApi.shouldReloadForFrontendAssetVersion('cccccccccccccccc', 'aaaaaaaaaaaaaaaa') === false,
'Repeated frontend asset version mismatches should be blocked by the reload guard'
);
});
if (failures.length > 0) {
throw new Error(`Frontend asset version contract failed:\n- ${failures.join('\n- ')}`);
}
}
async function runFrontendAssetVersionRegression() {
assertFrontendAssetVersionContract();
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-frontend-asset-version-'));
const configDir = path.join(tempRoot, 'config');
const sessionsDir = path.join(tempRoot, 'sessions');
const logsDir = path.join(tempRoot, 'logs');
mkdirp(configDir);
mkdirp(sessionsDir);
mkdirp(logsDir);
const expectedVersion = crypto.createHash('sha256')
.update(fs.readFileSync(PUBLIC_APP_PATH))
.digest('hex')
.slice(0, 16);
const port = await getFreePort();
const password = 'FrontendAssetVersion!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,
CC_WEB_PUBLIC_DIR: PUBLIC_DIR,
HOME: tempRoot,
CLAUDE_PATH: MOCK_CLAUDE,
CODEX_PATH: MOCK_CODEX_APP_SERVER,
}, async () => {
const response = await fetch(`http://127.0.0.1:${port}/`);
const html = await response.text();
const injectedGlobalVersion = html.match(/window\.ccWebFrontendAssetVersion\s*=\s*'([a-f0-9]{16,64})'/)?.[1] || '';
const injectedScriptVersion = html.match(/app\.js\?v=([a-f0-9]{16,64})/)?.[1] || '';
assert(response.ok, `Frontend asset version index request should succeed, got ${response.status}`);
assert(
injectedGlobalVersion === expectedVersion,
`Served index should expose the computed app.js version to the frontend, got ${JSON.stringify(injectedGlobalVersion)}`
);
assert(
injectedScriptVersion === expectedVersion && !html.includes('__CC_WEB_FRONTEND_ASSET_VERSION__'),
`Served index should replace every frontend asset version placeholder, got ${JSON.stringify(injectedScriptVersion)}`
);
const { ws, messages } = await connectWs(port, password);
const authResult = messages.find((message) => message.type === 'auth_result' && message.success);
assert(
authResult?.frontendAssetVersion === expectedVersion,
'Successful auth_result should send the same app.js version injected into index.html'
);
ws.close();
});
}
function extractFunctionSource(source, name) {
const start = source.indexOf(`function ${name}(`);
assert(start >= 0, `Server should define ${name}`);
@@ -4450,7 +4673,7 @@ function assertAdvancedSessionSearchContract() {
assert(indexSource.includes('id="advanced-search-panel"') && indexSource.includes('id="advanced-search-results"'),
'Advanced search workspace should expose stable panel and result hooks');
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline')
&& indexSource.includes('app.js?v=20260805-usage-loading-inline'),
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Advanced search CSS and frontend script should share the reviewed cache-bust');
const panelStyleStart = styleSource.indexOf('.advanced-search-panel {');
@@ -4568,7 +4791,7 @@ function assertUsageStatisticsContract() {
'Usage entry must use its own class so theme-specific settings pseudo-elements cannot leak');
assert(indexSource.includes('style.css?v=20260805-usage-loading-inline')
&& indexSource.includes('vendor/echarts.min.js?v=5.6.0')
&& indexSource.includes('app.js?v=20260805-usage-loading-inline'),
&& indexSource.includes('app.js?v=__CC_WEB_FRONTEND_ASSET_VERSION__'),
'Usage dashboard CSS, local ECharts runtime, and frontend script should share the current asset contract');
assert(indexSource.includes('id="usage-dashboard-refresh-label" aria-live="polite"')
&& !indexSource.includes('id="usage-dashboard-loading"'),
@@ -5276,6 +5499,11 @@ async function main() {
console.log('Title history outline regression checks passed.');
return;
}
if (regressionTarget === 'frontend-asset-version') {
await runFrontendAssetVersionRegression();
console.log('Frontend asset version regression checks passed.');
return;
}
if (regressionTarget === 'session-item-tooltip') {
assertSessionItemTooltipContract();
console.log('Session item tooltip regression checks passed.');
@@ -5330,6 +5558,7 @@ async function main() {
}
assertUnlimitedImageAttachmentsContract();
await runFrontendAssetVersionRegression();
assertFrontendGildedThemeContract();
assertFrontendWastelandThemeContract();
assertFrontendSidebarCollapseContract();