chore: rebuild release package
This commit is contained in:
@@ -538,6 +538,18 @@ function assertFrontendSidebarCollapseContract() {
|
||||
assert(source.includes("const SIDEBAR_DRAWER_MEDIA_QUERY = '(max-width: 768px)'"), 'Sidebar drawer mode should match the mobile CSS breakpoint');
|
||||
assert(source.includes("const SIDEBAR_DESKTOP_COLLAPSE_MEDIA_QUERY = '(width > 768px) and (hover: hover) and (pointer: fine)'"), 'Desktop collapse mode should match the hover rail CSS capabilities');
|
||||
assert(source.includes("app.classList.toggle('sidebar-collapsed', nextCollapsed)"), 'Desktop sidebar should keep its fixed state on the app root');
|
||||
assert(
|
||||
indexSource.includes('id="user-outline-panel" class="user-outline-panel"')
|
||||
&& indexSource.includes('id="ccweb-prompt-outline-panel" class="user-outline-panel ccweb-prompt-outline-panel"'),
|
||||
'User-message and pending-form locators should share the responsive outline panel layout'
|
||||
);
|
||||
const baseOutlinePanelStyleStart = styleSource.indexOf('.user-outline-panel {');
|
||||
const baseOutlinePanelStyleEnd = styleSource.indexOf('.user-outline-empty', baseOutlinePanelStyleStart);
|
||||
const baseOutlinePanelStyle = styleSource.slice(baseOutlinePanelStyleStart, baseOutlinePanelStyleEnd);
|
||||
assert(
|
||||
/\.user-outline-panel\s*\{[^}]*position:\s*absolute;[^}]*right:\s*0;/.test(baseOutlinePanelStyle),
|
||||
'Fixed-expanded desktop locators should retain their right-aligned base placement'
|
||||
);
|
||||
assert(source.includes('persistSidebarCollapsedPreference(nextCollapsed)'), 'Desktop sidebar toggle should persist the user preference');
|
||||
assert(
|
||||
source.includes("menuBtn.setAttribute('aria-expanded', String(expanded))")
|
||||
@@ -555,10 +567,13 @@ function assertFrontendSidebarCollapseContract() {
|
||||
);
|
||||
|
||||
assert(styleSource.includes('--sidebar-rail-width: 14px'), 'Collapsed desktop sidebar should retain a narrow hover rail');
|
||||
const desktopCollapseStyleStart = styleSource.indexOf('@media (width > 768px) and (hover: hover) and (pointer: fine)');
|
||||
const desktopCollapseStyleEnd = styleSource.indexOf('@media (prefers-reduced-motion: reduce)', desktopCollapseStyleStart);
|
||||
assert(
|
||||
styleSource.includes('@media (width > 768px) and (hover: hover) and (pointer: fine)'),
|
||||
desktopCollapseStyleStart >= 0 && desktopCollapseStyleEnd > desktopCollapseStyleStart,
|
||||
'Desktop hover behavior should stay isolated from touch and narrow-screen drawers'
|
||||
);
|
||||
const desktopCollapseStyle = styleSource.slice(desktopCollapseStyleStart, desktopCollapseStyleEnd);
|
||||
assert(
|
||||
styleSource.includes('margin-right: calc(var(--sidebar-rail-width) - var(--sidebar-width))')
|
||||
&& styleSource.includes('transform: translateX(calc(var(--sidebar-rail-width) - var(--sidebar-width)))'),
|
||||
@@ -569,6 +584,28 @@ function assertFrontendSidebarCollapseContract() {
|
||||
&& styleSource.includes('transform: translateX(0)'),
|
||||
'Collapsed sidebar should temporarily expand for pointer hover and keyboard focus'
|
||||
);
|
||||
assert(
|
||||
/\.app\.sidebar-collapsed \.user-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;[^}]*z-index:\s*120;/.test(desktopCollapseStyle),
|
||||
'Both collapsed-sidebar locator panels should open toward the chat canvas above the hover preview'
|
||||
);
|
||||
const mobileSidebarStyleStart = styleSource.indexOf('@media (max-width: 768px)');
|
||||
const mobileSidebarStyleEnd = styleSource.indexOf('@media (max-width: 480px)', mobileSidebarStyleStart);
|
||||
const mobileSidebarStyle = styleSource.slice(mobileSidebarStyleStart, mobileSidebarStyleEnd);
|
||||
assert(
|
||||
/\.user-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(mobileSidebarStyle),
|
||||
'Closed mobile drawer should keep both locator panels inside the chat canvas'
|
||||
);
|
||||
const compactMobileStyleStart = mobileSidebarStyleEnd;
|
||||
const compactMobileStyleEnd = styleSource.indexOf('/* === Utility === */', compactMobileStyleStart);
|
||||
const compactMobileStyle = styleSource.slice(compactMobileStyleStart, compactMobileStyleEnd);
|
||||
assert(
|
||||
/\.user-outline-panel\s*\{[^}]*left:\s*auto;[^}]*right:\s*0;[^}]*width:\s*min\(320px,\s*calc\(100vw - 20px\)\);/.test(compactMobileStyle),
|
||||
'Compact-mobile user locator should open left from the second grid column'
|
||||
);
|
||||
assert(
|
||||
/\.ccweb-prompt-outline-panel\s*\{[^}]*left:\s*0;[^}]*right:\s*auto;/.test(compactMobileStyle),
|
||||
'Compact-mobile pending-form locator should open right from the first grid column'
|
||||
);
|
||||
assert(styleSource.includes('.menu-btn:focus-visible'), 'Sidebar toggle should retain a visible keyboard focus treatment');
|
||||
assert(
|
||||
source.includes('if (isSidebarDesktopCollapseMode())')
|
||||
@@ -581,8 +618,8 @@ function assertFrontendSidebarCollapseContract() {
|
||||
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
|
||||
);
|
||||
assert(
|
||||
indexSource.includes('style.css?v=20260718-wasteland-bg-right-4')
|
||||
&& indexSource.includes('app.js?v=20260718-wasteland-bg-right-4'),
|
||||
indexSource.includes('style.css?v=20260718-shared-locator-panel-2')
|
||||
&& indexSource.includes('app.js?v=20260718-shared-locator-panel-2'),
|
||||
'Sidebar interaction assets should share the reviewed cache-busting version'
|
||||
);
|
||||
}
|
||||
@@ -874,8 +911,8 @@ function assertFrontendGildedThemeContract() {
|
||||
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
|
||||
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=20260718-wasteland-bg-right-4'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=20260718-wasteland-bg-right-4'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('style.css?v=20260718-shared-locator-panel-2'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=20260718-shared-locator-panel-2'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||||
}
|
||||
|
||||
function assertFrontendWastelandThemeContract() {
|
||||
@@ -1128,8 +1165,8 @@ function assertFrontendWastelandThemeContract() {
|
||||
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
|
||||
});
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260718-wasteland-bg-right-4'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=20260718-wasteland-bg-right-4'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('style.css?v=20260718-shared-locator-panel-2'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=20260718-shared-locator-panel-2'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||||
}
|
||||
|
||||
function assertFrontendCcwebPromptContract() {
|
||||
@@ -2130,6 +2167,472 @@ function assertSessionSwitchResilienceContract() {
|
||||
);
|
||||
}
|
||||
|
||||
function assertSessionRenderEpochRaceContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const beginSessionSwitchSource = extractFunctionSource(frontendSource, 'beginSessionSwitch');
|
||||
const renderMessagesSource = extractFunctionSource(frontendSource, 'renderMessages');
|
||||
const api = new Function(`
|
||||
let renderEpoch = 0;
|
||||
let loadedHistorySessionId = 'session-a';
|
||||
let activeSessionLoad = null;
|
||||
let currentSessionId = 'session-a';
|
||||
let closedCollabAgentIds = new Set();
|
||||
let collabAgentStateCache = new Map();
|
||||
let collabAgentIdsByToolUseId = new Map();
|
||||
let closedCollabAgentIdsByToolUseId = new Map();
|
||||
const scheduled = [];
|
||||
const messagesDiv = {
|
||||
nodes: [],
|
||||
scrollTop: 0,
|
||||
get scrollHeight() { return this.nodes.length; },
|
||||
get firstChild() { return this.nodes[0] || null; },
|
||||
get innerHTML() { return ''; },
|
||||
set innerHTML(value) { this.nodes = []; },
|
||||
appendChild(node) {
|
||||
this.nodes.push(...(node && node.__fragment ? node.nodes : [node]));
|
||||
},
|
||||
insertBefore(node) {
|
||||
this.nodes.unshift(...(node && node.__fragment ? node.nodes : [node]));
|
||||
},
|
||||
};
|
||||
const document = {
|
||||
createDocumentFragment() {
|
||||
return {
|
||||
__fragment: true,
|
||||
nodes: [],
|
||||
appendChild(node) { this.nodes.push(node); },
|
||||
};
|
||||
},
|
||||
};
|
||||
const currentCwd = '/tmp/session-render-race';
|
||||
function setTimeout(callback) { scheduled.push(callback); return scheduled.length; }
|
||||
function setSessionLoading(sessionId) {
|
||||
activeSessionLoad = sessionId ? { sessionId, overlayReleased: false } : null;
|
||||
}
|
||||
function requestSessionLoad() {}
|
||||
function collectClosedCollabAgentIds() { return new Set(); }
|
||||
function clearUserMessageIndex() {}
|
||||
function buildWelcomeMarkup() { return '<p>welcome</p>'; }
|
||||
function updateUserOutlinePanel() {}
|
||||
function renderPendingNotes() {}
|
||||
function scrollToBottom() {}
|
||||
function updateScrollbar() {}
|
||||
function buildMsgElement(message) { return { id: message.id }; }
|
||||
${beginSessionSwitchSource}
|
||||
${renderMessagesSource}
|
||||
return {
|
||||
beginSessionSwitch,
|
||||
renderMessages,
|
||||
flushTimers() {
|
||||
while (scheduled.length > 0) scheduled.shift()();
|
||||
},
|
||||
renderedIds: () => messagesDiv.nodes.map((node) => node.id),
|
||||
};
|
||||
`)();
|
||||
|
||||
const sessionAMessages = Array.from({ length: 11 }, (_, index) => ({ id: `a-${index}` }));
|
||||
api.renderMessages(sessionAMessages);
|
||||
assert(
|
||||
api.renderedIds().length === 10 && !api.renderedIds().includes('a-0'),
|
||||
'Render epoch race fixture should leave the oldest A message in the delayed batch'
|
||||
);
|
||||
api.beginSessionSwitch('session-b', { force: true, blocking: false });
|
||||
api.flushTimers();
|
||||
assert(
|
||||
JSON.stringify(api.renderedIds()) === JSON.stringify(sessionAMessages.map((message) => message.id)),
|
||||
'Beginning a B load must not cancel A delayed batches before the B snapshot is committed'
|
||||
);
|
||||
|
||||
api.renderMessages(sessionAMessages);
|
||||
api.beginSessionSwitch('session-b', { force: true, blocking: false });
|
||||
api.renderMessages([{ id: 'b-0' }]);
|
||||
api.flushTimers();
|
||||
assert(
|
||||
JSON.stringify(api.renderedIds()) === JSON.stringify(['b-0']),
|
||||
'Committing the B render must invalidate A delayed batches so stale messages cannot leak into B'
|
||||
);
|
||||
assert(
|
||||
!/\brenderEpoch\s*(?:\+\+|--|[+\-*/%]?=)/.test(beginSessionSwitchSource),
|
||||
'beginSessionSwitch should not advance renderEpoch before the replacement snapshot renders'
|
||||
);
|
||||
}
|
||||
|
||||
function assertSessionRequestIdRaceContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const sessionInfoStart = frontendSource.indexOf("case 'session_info':");
|
||||
const sessionInfoDecisionStart = frontendSource.indexOf('const messageRequestId', sessionInfoStart);
|
||||
const sessionInfoDecisionEnd = frontendSource.indexOf('if (!canSwitchToSessionInfo)', sessionInfoDecisionStart);
|
||||
assert(
|
||||
sessionInfoStart >= 0 && sessionInfoDecisionStart > sessionInfoStart && sessionInfoDecisionEnd > sessionInfoDecisionStart,
|
||||
'Frontend should keep an extractable request gate before applying session_info'
|
||||
);
|
||||
const sessionInfoDecisionSource = frontendSource.slice(sessionInfoDecisionStart, sessionInfoDecisionEnd);
|
||||
const decideSessionInfo = new Function('msg', 'snapshot', 'activeLoad', 'pendingNewSession', 'currentSessionId', `
|
||||
${sessionInfoDecisionSource}
|
||||
return { canSwitchToSessionInfo, matchesActiveLoad };
|
||||
`);
|
||||
const staleCurrentDecision = decideSessionInfo(
|
||||
{
|
||||
type: 'session_info',
|
||||
sessionId: 'session-a',
|
||||
requestId: 'request-old-a',
|
||||
messages: [{ id: 'stale-a' }],
|
||||
historyPending: false,
|
||||
isRunning: false,
|
||||
},
|
||||
{
|
||||
sessionId: 'session-a',
|
||||
agent: 'codexapp',
|
||||
messages: [{ id: 'stale-a' }],
|
||||
},
|
||||
{
|
||||
sessionId: 'session-b',
|
||||
requestId: 'request-new-b',
|
||||
snapshot: null,
|
||||
},
|
||||
null,
|
||||
'session-a'
|
||||
);
|
||||
const rejectsStaleCurrentSessionInfo = staleCurrentDecision.canSwitchToSessionInfo === false;
|
||||
|
||||
const finalizeLoadedSessionSource = extractFunctionSource(frontendSource, 'finalizeLoadedSession');
|
||||
const finalizeApi = new Function(`
|
||||
let activeSessionLoad = {
|
||||
sessionId: 'session-b',
|
||||
requestId: 'request-new-b',
|
||||
snapshot: { messages: [{ id: 'b-0' }] },
|
||||
};
|
||||
const cached = [];
|
||||
const finished = [];
|
||||
function cacheSessionSnapshot(snapshot) { cached.push(snapshot); }
|
||||
function finishSessionSwitch(sessionId) { finished.push(sessionId); }
|
||||
${finalizeLoadedSessionSource}
|
||||
return {
|
||||
finalizeLoadedSession,
|
||||
cached,
|
||||
finished,
|
||||
reset() { cached.length = 0; finished.length = 0; },
|
||||
};
|
||||
`)();
|
||||
finalizeApi.finalizeLoadedSession('session-b', 'request-old-b');
|
||||
const rejectsStaleFinalize = finalizeApi.cached.length === 0 && finalizeApi.finished.length === 0;
|
||||
finalizeApi.reset();
|
||||
finalizeApi.finalizeLoadedSession('session-b', 'request-new-b');
|
||||
const acceptsMatchingFinalize = finalizeApi.cached.length === 1 && finalizeApi.finished.length === 1;
|
||||
|
||||
const historyStart = frontendSource.indexOf("case 'session_history_chunk':");
|
||||
const historyEnd = frontendSource.indexOf("case 'session_message':", historyStart);
|
||||
assert(historyStart >= 0 && historyEnd > historyStart, 'Frontend should keep an extractable session_history_chunk handler');
|
||||
const historyCaseSource = frontendSource.slice(historyStart, historyEnd);
|
||||
const historyApi = new Function(`
|
||||
let activeSessionLoad = {
|
||||
sessionId: 'session-b',
|
||||
requestId: 'request-new-b',
|
||||
recoverCurrent: false,
|
||||
snapshot: { messages: [{ id: 'b-recent' }] },
|
||||
};
|
||||
let currentSessionId = 'session-b';
|
||||
let loadedHistorySessionId = 'session-b';
|
||||
const prepended = [];
|
||||
const finalized = [];
|
||||
function cloneMessages(messages) { return messages.slice(); }
|
||||
function isBlockingSessionLoad() { return false; }
|
||||
function prependHistoryMessages(messages) { prepended.push(...messages); }
|
||||
function finalizeLoadedSession(sessionId, requestId) { finalized.push({ sessionId, requestId }); }
|
||||
function handleHistoryMessage(msg) {
|
||||
switch (msg.type) {
|
||||
${historyCaseSource}
|
||||
}
|
||||
}
|
||||
return {
|
||||
handleHistoryMessage,
|
||||
prepended,
|
||||
finalized,
|
||||
reset() { prepended.length = 0; finalized.length = 0; },
|
||||
};
|
||||
`)();
|
||||
historyApi.handleHistoryMessage({
|
||||
type: 'session_history_chunk',
|
||||
sessionId: 'session-b',
|
||||
requestId: 'request-old-b',
|
||||
messages: [{ id: 'stale-history' }],
|
||||
remaining: 0,
|
||||
historyBaseIndex: 0,
|
||||
});
|
||||
const rejectsStaleHistoryChunk = historyApi.prepended.length === 0 && historyApi.finalized.length === 0;
|
||||
historyApi.reset();
|
||||
historyApi.handleHistoryMessage({
|
||||
type: 'session_history_chunk',
|
||||
sessionId: 'session-b',
|
||||
requestId: 'request-new-b',
|
||||
messages: [{ id: 'matching-history' }],
|
||||
remaining: 0,
|
||||
historyBaseIndex: 0,
|
||||
});
|
||||
const acceptsMatchingHistoryChunk = historyApi.prepended.some((message) => message.id === 'matching-history')
|
||||
&& historyApi.finalized.some((entry) => entry.requestId === 'request-new-b');
|
||||
|
||||
const failures = [];
|
||||
if (!rejectsStaleCurrentSessionInfo) {
|
||||
failures.push('A stale requestId-bearing session_info for the current A view must not overwrite the pending B load');
|
||||
}
|
||||
if (!rejectsStaleFinalize) {
|
||||
failures.push('finalizeLoadedSession must reject a stale requestId even when the sessionId still matches');
|
||||
}
|
||||
if (!acceptsMatchingFinalize) {
|
||||
failures.push('finalizeLoadedSession should still commit the matching active load');
|
||||
}
|
||||
if (!rejectsStaleHistoryChunk) {
|
||||
failures.push('session_history_chunk must reject stale requestIds before prepending or finalizing history');
|
||||
}
|
||||
if (!acceptsMatchingHistoryChunk) {
|
||||
failures.push('session_history_chunk should preserve the matching active request path');
|
||||
}
|
||||
assert(failures.length === 0, failures.join('; '));
|
||||
}
|
||||
|
||||
function assertBlockingFinishRafRequestRaceContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const isBlockingSessionLoadSource = extractFunctionSource(frontendSource, 'isBlockingSessionLoad');
|
||||
const clearSessionLoadingSource = extractFunctionSource(frontendSource, 'clearSessionLoading');
|
||||
const finishSessionSwitchSource = extractFunctionSource(frontendSource, 'finishSessionSwitch');
|
||||
const api = new Function(`
|
||||
let activeSessionLoad = {
|
||||
sessionId: 'session-a',
|
||||
requestId: 'request-a1',
|
||||
blocking: true,
|
||||
};
|
||||
const rafCallbacks = [];
|
||||
let scrollCount = 0;
|
||||
function setSessionLoading(sessionId) {
|
||||
activeSessionLoad = sessionId ? { sessionId, requestId: 'unexpected', blocking: true } : null;
|
||||
}
|
||||
function scrollToBottom() { scrollCount += 1; }
|
||||
function requestAnimationFrame(callback) { rafCallbacks.push(callback); return rafCallbacks.length; }
|
||||
${isBlockingSessionLoadSource}
|
||||
${clearSessionLoadingSource}
|
||||
${finishSessionSwitchSource}
|
||||
return {
|
||||
finishA1() { finishSessionSwitch('session-a', 'request-a1'); },
|
||||
replaceWithA2() {
|
||||
activeSessionLoad = {
|
||||
sessionId: 'session-a',
|
||||
requestId: 'request-a2',
|
||||
blocking: true,
|
||||
};
|
||||
},
|
||||
flushRaf() {
|
||||
while (rafCallbacks.length > 0) rafCallbacks.shift()();
|
||||
},
|
||||
activeRequestId: () => activeSessionLoad?.requestId || null,
|
||||
queuedRafCount: () => rafCallbacks.length,
|
||||
scrollCount: () => scrollCount,
|
||||
};
|
||||
`)();
|
||||
|
||||
api.finishA1();
|
||||
assert(
|
||||
api.queuedRafCount() === 1 && api.scrollCount() === 1 && api.activeRequestId() === 'request-a1',
|
||||
'Blocking A1 completion should defer clearing through the real finishSessionSwitch RAF path'
|
||||
);
|
||||
api.replaceWithA2();
|
||||
api.flushRaf();
|
||||
assert(
|
||||
api.activeRequestId() === 'request-a2',
|
||||
'The deferred A1 RAF callback must not clear a newer A2 load for the same sessionId'
|
||||
);
|
||||
}
|
||||
|
||||
function assertRecoverCurrentHistoryMergeContract() {
|
||||
const frontendSource = fs.readFileSync(PUBLIC_APP_PATH, 'utf8');
|
||||
const finalizeLoadedSessionSource = extractFunctionSource(frontendSource, 'finalizeLoadedSession');
|
||||
const historyStart = frontendSource.indexOf("case 'session_history_chunk':");
|
||||
const historyEnd = frontendSource.indexOf("case 'session_message':", historyStart);
|
||||
assert(historyStart >= 0 && historyEnd > historyStart, 'Frontend should keep an extractable recovery history handler');
|
||||
const historyCaseSource = frontendSource.slice(historyStart, historyEnd);
|
||||
const api = new Function(`
|
||||
let activeSessionLoad = {
|
||||
sessionId: 'session-recover',
|
||||
requestId: 'request-recover-new',
|
||||
recoverCurrent: true,
|
||||
blocking: false,
|
||||
snapshot: { messages: [{ id: 'recent' }] },
|
||||
};
|
||||
let currentSessionId = 'session-recover';
|
||||
let loadedHistorySessionId = 'session-recover';
|
||||
const prepended = [];
|
||||
const cached = [];
|
||||
const finished = [];
|
||||
function cloneMessages(messages) { return messages.map((message) => ({ ...message })); }
|
||||
function isBlockingSessionLoad() { return false; }
|
||||
function prependHistoryMessages(messages) { prepended.push(...messages); }
|
||||
function cacheSessionSnapshot(snapshot) { cached.push(JSON.parse(JSON.stringify(snapshot))); }
|
||||
function finishSessionSwitch(sessionId, requestId) { finished.push({ sessionId, requestId }); }
|
||||
${finalizeLoadedSessionSource}
|
||||
function handleHistoryMessage(msg) {
|
||||
switch (msg.type) {
|
||||
${historyCaseSource}
|
||||
}
|
||||
}
|
||||
return {
|
||||
handleHistoryMessage,
|
||||
snapshotMessageIds: () => activeSessionLoad.snapshot.messages.map((message) => message.id),
|
||||
prepended,
|
||||
cached,
|
||||
finished,
|
||||
};
|
||||
`)();
|
||||
|
||||
api.handleHistoryMessage({
|
||||
type: 'session_history_chunk',
|
||||
sessionId: 'session-recover',
|
||||
requestId: 'request-recover-stale',
|
||||
messages: [{ id: 'stale-history' }],
|
||||
remaining: 0,
|
||||
historyBaseIndex: 0,
|
||||
});
|
||||
const rejectsStaleChunk = JSON.stringify(api.snapshotMessageIds()) === JSON.stringify(['recent'])
|
||||
&& api.prepended.length === 0
|
||||
&& api.cached.length === 0
|
||||
&& api.finished.length === 0;
|
||||
|
||||
api.handleHistoryMessage({
|
||||
type: 'session_history_chunk',
|
||||
sessionId: 'session-recover',
|
||||
requestId: 'request-recover-new',
|
||||
messages: [{ id: 'history-later' }],
|
||||
remaining: 1,
|
||||
historyBaseIndex: 1,
|
||||
});
|
||||
const mergesIntermediateChunk = JSON.stringify(api.snapshotMessageIds())
|
||||
=== JSON.stringify(['history-later', 'recent']);
|
||||
const keepsRecoveryOffDom = api.prepended.length === 0;
|
||||
const defersRecoveryFinalize = api.cached.length === 0 && api.finished.length === 0;
|
||||
|
||||
api.handleHistoryMessage({
|
||||
type: 'session_history_chunk',
|
||||
sessionId: 'session-recover',
|
||||
requestId: 'request-recover-new',
|
||||
messages: [{ id: 'history-oldest' }],
|
||||
remaining: 0,
|
||||
historyBaseIndex: 0,
|
||||
});
|
||||
const cachedSnapshot = api.cached[0] || null;
|
||||
const cachesCompleteHistory = api.cached.length === 1
|
||||
&& cachedSnapshot.complete === true
|
||||
&& JSON.stringify((cachedSnapshot.messages || []).map((message) => message.id))
|
||||
=== JSON.stringify(['history-oldest', 'history-later', 'recent']);
|
||||
const finalizesMatchingRecovery = api.finished.length === 1
|
||||
&& api.finished[0].requestId === 'request-recover-new';
|
||||
const neverPrependsRecoveryHistory = api.prepended.length === 0;
|
||||
|
||||
const failures = [];
|
||||
if (!rejectsStaleChunk) failures.push('recoverCurrent must still reject stale requestId history chunks');
|
||||
if (!mergesIntermediateChunk) failures.push('recoverCurrent must merge each accepted history chunk into the active snapshot');
|
||||
if (!keepsRecoveryOffDom || !neverPrependsRecoveryHistory) failures.push('recoverCurrent history chunks must not prepend the live DOM');
|
||||
if (!defersRecoveryFinalize) failures.push('recoverCurrent must wait for the last history chunk before caching');
|
||||
if (!cachesCompleteHistory) failures.push('recoverCurrent finalization must cache recent and historical messages as one complete snapshot');
|
||||
if (!finalizesMatchingRecovery) failures.push('recoverCurrent finalization must retain the matching requestId');
|
||||
assert(failures.length === 0, failures.join('; '));
|
||||
}
|
||||
|
||||
function assertServerSessionHistoryRequestIdContract() {
|
||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
const handleLoadSessionSource = extractFunctionSource(serverSource, 'handleLoadSession');
|
||||
const api = new Function(`
|
||||
const sent = [];
|
||||
const fixture = {
|
||||
id: 'session-b',
|
||||
title: 'Session B',
|
||||
pinnedAt: null,
|
||||
permissionMode: 'yolo',
|
||||
model: 'gpt-5.5',
|
||||
agent: 'codexapp',
|
||||
hasUnread: false,
|
||||
cwd: '/tmp/session-b',
|
||||
totalCost: 0,
|
||||
totalUsage: null,
|
||||
updated: '2026-07-18T00:00:00.000Z',
|
||||
messages: Array.from({ length: 11 }, (_, index) => ({ id: 'b-' + index })),
|
||||
};
|
||||
const activeProcesses = new Map();
|
||||
const activeCodexAppTurns = new Map();
|
||||
const wsSessionMap = new Map();
|
||||
function sanitizeId(value) { return String(value || ''); }
|
||||
function reconcilePendingCrossConversationReplies() {}
|
||||
function loadSession() { return fixture; }
|
||||
function wsSend(ws, message) { sent.push(message); }
|
||||
function attachClientRequestId(message, source) {
|
||||
return source && source.requestId ? { ...message, requestId: source.requestId } : message;
|
||||
}
|
||||
function flushPendingCrossConversationReplies() {}
|
||||
function getSessionAgent(session) { return session.agent; }
|
||||
function splitHistoryMessages(messages) {
|
||||
return {
|
||||
recentMessages: messages.slice(1),
|
||||
olderChunks: [messages.slice(0, 1)],
|
||||
historyRemaining: 0,
|
||||
historyBuffered: messages.length,
|
||||
};
|
||||
}
|
||||
function crossConversationWaitState() {
|
||||
return {
|
||||
waitingOnChildren: false,
|
||||
pendingReplyCount: 0,
|
||||
readyReplyCount: 0,
|
||||
waitingReplyCount: 0,
|
||||
failedReplyCount: 0,
|
||||
pendingReplies: [],
|
||||
};
|
||||
}
|
||||
function detachWsFromActiveRuntimes() {}
|
||||
function saveSession() {}
|
||||
function publicTitleMetadata() { return {}; }
|
||||
function sessionModelLabel(session) { return session.model; }
|
||||
function isSessionRunning() { return false; }
|
||||
function attachActiveRuntimeToWs() {}
|
||||
function resolveClaudeSessionLocalMeta() { return null; }
|
||||
${handleLoadSessionSource}
|
||||
return {
|
||||
load(requestId) {
|
||||
sent.length = 0;
|
||||
handleLoadSession({}, { type: 'load_session', sessionId: fixture.id, requestId });
|
||||
return sent.slice();
|
||||
},
|
||||
};
|
||||
`)();
|
||||
const messages = api.load('request-new-b');
|
||||
const sessionInfo = messages.find((message) => message.type === 'session_info');
|
||||
const historyChunks = messages.filter((message) => message.type === 'session_history_chunk');
|
||||
assert(sessionInfo?.requestId === 'request-new-b', 'Server session_info fixture should echo the load requestId');
|
||||
assert(historyChunks.length > 0, 'Server load fixture should emit at least one delayed history chunk');
|
||||
assert(
|
||||
historyChunks.every((message) => message.requestId === sessionInfo.requestId),
|
||||
'Every server history chunk must echo the exact requestId carried by its session_info snapshot'
|
||||
);
|
||||
}
|
||||
|
||||
function assertSessionSwitchRaceContract() {
|
||||
const checks = [
|
||||
['render epoch behavior', assertSessionRenderEpochRaceContract],
|
||||
['frontend requestId behavior', assertSessionRequestIdRaceContract],
|
||||
['blocking finish RAF request behavior', assertBlockingFinishRafRequestRaceContract],
|
||||
['recoverCurrent history merge behavior', assertRecoverCurrentHistoryMergeContract],
|
||||
['server history requestId behavior', assertServerSessionHistoryRequestIdContract],
|
||||
];
|
||||
const failures = [];
|
||||
for (const [label, check] of checks) {
|
||||
try {
|
||||
check();
|
||||
} catch (err) {
|
||||
failures.push(`${label}: ${err?.message || err}`);
|
||||
}
|
||||
}
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Session switch race regression failed:\n- ${failures.join('\n- ')}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertCodexAppStaleRunningRecoveryContract() {
|
||||
const serverSource = fs.readFileSync(SERVER_PATH, 'utf8');
|
||||
const completeBlock = extractFunctionSource(serverSource, 'handleCodexAppTurnComplete');
|
||||
@@ -2820,6 +3323,11 @@ async function main() {
|
||||
console.log('Codex App stale running regression checks passed.');
|
||||
return;
|
||||
}
|
||||
if (regressionTarget === 'session-switch-race') {
|
||||
assertSessionSwitchRaceContract();
|
||||
console.log('Session switch race regression checks passed.');
|
||||
return;
|
||||
}
|
||||
throw new Error(`Unknown regression target: ${regressionTarget}`);
|
||||
}
|
||||
|
||||
@@ -2839,6 +3347,7 @@ async function main() {
|
||||
assertFrontendPrimaryCodexAppUiContract();
|
||||
assertSetTitleMcpContract();
|
||||
assertSessionSwitchResilienceContract();
|
||||
assertSessionSwitchRaceContract();
|
||||
assertCodexAppChildToolFallbackContract();
|
||||
|
||||
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-regression-'));
|
||||
|
||||
Reference in New Issue
Block a user