Files
obelisk/tests/session-timeline-virtualization.test.mjs
T
tommy0103 d9f31bbe8a fix(app): preserve session reader state across navigation
Cache semantic timeline anchors and disclosures per session instead of relying on KeepAlive. Restore state after virtualized layout stabilization while preserving explicit focus and tail-follow behavior.
2026-07-16 23:22:47 +08:00

121 lines
5.5 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { createViewportRangeExtractor } from '../app/src/renderer/src/session-timeline-viewport.mjs';
const sessionDetail = readFileSync(
new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url),
'utf8',
);
const timelineRow = readFileSync(
new URL('../app/src/renderer/src/components/SessionTimelineRow.vue', import.meta.url),
'utf8',
);
const timelinePresentation = readFileSync(
new URL('../app/src/renderer/src/session-timeline-presentation.mjs', import.meta.url),
'utf8',
);
const viewportModule = readFileSync(
new URL('../app/src/renderer/src/session-timeline-viewport.mjs', import.meta.url),
'utf8',
);
const appPackage = JSON.parse(readFileSync(
new URL('../app/package.json', import.meta.url),
'utf8',
));
test('SessionDetail renders a measured virtual window instead of the complete timeline DOM', () => {
assert.match(sessionDetail, /useSessionTimelineViewport/);
assert.match(sessionDetail, /v-for="virtualRow in virtualRows"/);
assert.match(sessionDetail, /:data-index="virtualRow\.index"/);
assert.match(sessionDetail, /:ref="measureElement"/);
assert.match(sessionDetail, /<SessionTimelineRow/);
assert.match(timelineRow, /buildSessionTimelinePresentation/);
assert.doesNotMatch(sessionDetail, /renderMarkdown|renderPrettyTool/);
assert.doesNotMatch(sessionDetail, /querySelectorAll/);
assert.doesNotMatch(sessionDetail + timelineRow, /v-memo/);
assert.doesNotMatch(sessionDetail, /session-view-state/);
assert.doesNotMatch(sessionDetail, /outerHTML/);
assert.doesNotMatch(sessionDetail, /closest\(['"]\.msg/);
});
test('timeline viewport owns measurement and anchoring while SessionDetail alone owns tail-follow', () => {
assert.equal(appPackage.devDependencies['@tanstack/vue-virtual'], '^3.13.32');
assert.match(viewportModule, /useVirtualizer/);
assert.match(viewportModule, /overscan/);
assert.match(viewportModule, /rangeExtractor/);
assert.match(viewportModule, /anchorTo:\s*'end'/);
assert.match(viewportModule, /followOnAppend:\s*false/);
assert.match(viewportModule, /completeInitialSnapshot/);
assert.match(viewportModule, /scrollToFn:\s*scrollPolicy\.scrollToFn/);
assert.match(viewportModule, /useScrollendEvent:\s*true/);
assert.match(viewportModule, /isScrollingResetDelay:\s*450/);
assert.doesNotMatch(viewportModule + sessionDetail, /settleUserScroll|flushDeferredAdjustment/);
assert.match(viewportModule, /useAnimationFrameWithResizeObserver:\s*true/);
assert.match(viewportModule, /scrollPaddingEnd/);
assert.match(viewportModule, /scrollToIndex/);
assert.match(viewportModule, /if \(!element\) return/);
assert.match(sessionDetail, /isScrolling:\s*\(\) => userScroll\.isActive\(\)/);
assert.doesNotMatch(sessionDetail, /timelineViewport\.isScrolling/);
assert.match(
sessionDetail,
/!userScroll\.hasUpwardIntent\(\)[\s\S]{0,100}timelineViewport\.isFollowingTail\(\)/,
);
});
test('timeline viewport buffers by rendered pixels instead of a fixed row count', () => {
const rangeExtractor = createViewportRangeExtractor({
getScrollElement: () => ({ clientHeight: 700, scrollTop: 5000 }),
getVirtualizer: () => ({
getVirtualItemForOffset: offset => ({ index: Math.floor(offset / 50) }),
}),
});
const indexes = rangeExtractor({
startIndex: 100,
endIndex: 113,
overscan: 6,
count: 1000,
});
assert.equal(indexes[0], 44);
assert.equal(indexes.at(-1), 170);
assert.equal(indexes.length, 127);
});
test('timeline count and disclosure classes come from renderer state rather than DOM state', () => {
assert.match(sessionDetail, /const totalMsgs = computed\(\(\) => timelineItems\.value\.length\)/);
assert.match(timelineRow, /disclosures\.isOpen/);
assert.match(timelineRow, /disclosures\.isRaw/);
assert.doesNotMatch(timelineRow, /function toggleDisclosure[\s\S]{0,200}classList/);
assert.doesNotMatch(timelineRow, /function toggleRaw[\s\S]{0,200}classList/);
assert.doesNotMatch(sessionDetail, /createSessionDisclosureRegistry/);
});
test('timeline row memoizes derived HTML behind stable content dependencies', () => {
assert.match(timelineRow, /const presentation = computed/);
assert.match(timelineRow, /query: props\.query/);
assert.match(timelineRow, /expandedText: expandedText\.value/);
assert.match(timelinePresentation, /toolPrettyHtml/);
assert.match(timelinePresentation, /toolResultHtml/);
assert.match(timelinePresentation, /renderMarkdown/);
});
test('cold startup does not enable append-follow before a real session snapshot exists', () => {
assert.match(sessionDetail, /if \(!latest\) return/);
assert.match(sessionDetail, /timelineViewport\.completeInitialSnapshot\(\)/);
});
test('live patch state advances only after the visible snapshot commit is accepted', () => {
const loadLiveSnapshot = sessionDetail.match(/async function loadLiveSnapshot\(\) \{([\s\S]*?)\n\}/)?.[1] || '';
const commitLiveSnapshot = sessionDetail.match(/async function commitLiveSnapshot\(snapshot\) \{([\s\S]*?)\n\}/)?.[1] || '';
assert.doesNotMatch(loadLiveSnapshot, /clearSessionDirty|acceptMessagePatch/);
assert.match(loadLiveSnapshot, /fetchSessionDetailPatch\(sessionId\)/);
assert.match(
commitLiveSnapshot,
/materializeSessionDetailPatch\(snapshot\.patchRequest\);[\s\S]*await commitSessionSnapshot\(latest\);[\s\S]*acceptMessagePatch[\s\S]*clearSessionDirty/,
);
assert.match(commitLiveSnapshot, /markSessionDirty\(snapshot\.sessionId\)/);
});