fix: 修复标题归属并更新发布包
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user