Files
obelisk/tests/session-view-state.test.mjs
T
tommy0103 bbf16d8f9f fix(app): keep live session updates responsive
Cache timeline DOM indexes and memoize the timeline so progress and flap updates avoid rescanning or rerendering old messages. Preserve scroll anchors and disclosure state across targeted live message replacements.
2026-07-14 00:53:38 +08:00

414 lines
16 KiB
JavaScript

import { test } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import {
captureSessionViewState,
createSessionDisclosureRegistry,
createSessionDomIndex,
findLastMessageAtOrAbove,
isFollowingSessionTail,
restoreSessionTail,
restoreSessionViewState,
} from '../app/src/renderer/src/session-view-state.mjs';
class FakeClassList {
constructor(classes = []) { this.classes = new Set(classes); }
add(...classes) { for (const value of classes) this.classes.add(value); }
contains(value) { return this.classes.has(value); }
}
function disclosure(key, classes = [], { rawOpen = false } = {}) {
const raw = { classList: new FakeClassList(rawOpen ? ['show'] : []) };
const pretty = { classList: new FakeClassList() };
const button = { classList: new FakeClassList() };
return {
dataset: { viewKey: key },
classList: new FakeClassList(classes),
querySelector(selector) {
if (selector === '.toolcall-raw') return raw;
if (selector === '.toolcall-pretty') return pretty;
if (selector === '.raw-toggle') return button;
return null;
},
raw,
pretty,
button,
};
}
function scrollItem(uuid, top, bottom) {
return {
dataset: { uuid },
getBoundingClientRect: () => ({ top, bottom }),
};
}
function detail(disclosures, scrollItems) {
return {
querySelectorAll(selector) {
if (selector === '[data-view-key]') return disclosures;
if (selector === '.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]') return scrollItems;
return [];
},
};
}
function domIndex(items) {
return createSessionDomIndex(detail([], items));
}
function wrap({ scrollTop, scrollHeight, clientHeight, top = 0 }) {
return {
scrollTop,
scrollHeight,
clientHeight,
getBoundingClientRect: () => ({ top }),
};
}
function functionSource(source, name) {
const start = source.indexOf(`function ${name}(`);
assert.notEqual(start, -1, `${name} should exist`);
const signatureEnd = source.indexOf(') {', start);
assert.notEqual(signatureEnd, -1, `${name} should have a function body`);
const bodyStart = signatureEnd + 2;
let depth = 0;
for (let index = bodyStart; index < source.length; index++) {
if (source[index] === '{') depth++;
if (source[index] === '}') depth--;
if (depth === 0) return source.slice(start, index + 1);
}
assert.fail(`${name} should have a complete function body`);
}
test('session refresh restores the visible scroll anchor from cached DOM indexes', () => {
const oldWrap = wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 });
const snapshot = captureSessionViewState({
wrap: oldWrap,
domIndex: domIndex([scrollItem('msg-1', -20, 180)]),
});
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2200, clientHeight: 600 });
restoreSessionViewState(snapshot, {
wrap: newWrap,
domIndex: domIndex([scrollItem('msg-1', 80, 280)]),
});
assert.equal(newWrap.scrollTop, 600, '100 px inserted above the anchor is compensated');
});
test('session refresh falls back to the owning message when its rendered root changes', () => {
const oldRoot = scrollItem('message-1-tools', -20, 180);
oldRoot.dataset.messageUuid = 'message-1';
const snapshot = captureSessionViewState({
wrap: wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 }),
domIndex: domIndex([oldRoot]),
});
const newRoot = scrollItem('message-1', 80, 280);
newRoot.dataset.messageUuid = 'message-1';
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2200, clientHeight: 600 });
restoreSessionViewState(snapshot, {
wrap: newWrap,
domIndex: domIndex([newRoot]),
});
assert.equal(snapshot.anchor.messageUuid, 'message-1');
assert.equal(newWrap.scrollTop, 600, 'message identity preserves the anchor across render shapes');
});
test('session refresh follows appended content only when already at the tail', () => {
const oldWrap = wrap({ scrollTop: 1390, scrollHeight: 2000, clientHeight: 600 });
const snapshot = captureSessionViewState({
wrap: oldWrap,
domIndex: domIndex([scrollItem('msg-last', 300, 590)]),
});
const newWrap = wrap({ scrollTop: 0, scrollHeight: 2400, clientHeight: 600 });
restoreSessionViewState(snapshot, {
wrap: newWrap,
domIndex: domIndex([scrollItem('msg-last', 300, 590)]),
});
assert.equal(newWrap.scrollTop, 2400);
});
test('tail-follow detection uses only the scroll container metrics', () => {
assert.equal(isFollowingSessionTail(wrap({
scrollTop: 1360,
scrollHeight: 2000,
clientHeight: 600,
})), true);
assert.equal(isFollowingSessionTail(wrap({
scrollTop: 1200,
scrollHeight: 2000,
clientHeight: 600,
})), false);
});
test('tail append preserves an active reader and follows only without newer scroll input', () => {
const reader = wrap({ scrollTop: 600, scrollHeight: 2400, clientHeight: 600 });
restoreSessionTail({ wrap: reader, followTail: false });
assert.equal(reader.scrollTop, 600);
const userMoved = wrap({ scrollTop: 800, scrollHeight: 2400, clientHeight: 600 });
restoreSessionTail({ wrap: userMoved, followTail: true, restoreScroll: false });
assert.equal(userMoved.scrollTop, 800);
const follower = wrap({ scrollTop: 1400, scrollHeight: 2400, clientHeight: 600 });
restoreSessionTail({ wrap: follower, followTail: true });
assert.equal(follower.scrollTop, 2400);
});
test('session refresh never restores an old anchor over newer user scrolling', () => {
const snapshot = captureSessionViewState({
wrap: wrap({ scrollTop: 500, scrollHeight: 2000, clientHeight: 600 }),
domIndex: domIndex([scrollItem('msg-1', -20, 180)]),
});
const userScrolledWrap = wrap({ scrollTop: 800, scrollHeight: 2200, clientHeight: 600 });
restoreSessionViewState(snapshot, {
wrap: userScrolledWrap,
domIndex: domIndex([scrollItem('msg-1', 80, 280)]),
restoreScroll: false,
});
assert.equal(userScrolledWrap.scrollTop, 800, 'newer user scroll wins over stale refresh state');
});
test('scroll progress locates the visible message without scanning the full session', () => {
let layoutReads = 0;
const messages = Array.from({ length: 2048 }, (_, index) => ({
getBoundingClientRect() {
layoutReads++;
return { bottom: (index + 1) * 20 };
},
}));
assert.equal(findLastMessageAtOrAbove(messages, 20100), 1004);
assert.ok(layoutReads < 20, `expected logarithmic layout reads, got ${layoutReads}`);
});
test('view-state capture locates its anchor logarithmically from the DOM index', () => {
let layoutReads = 0;
const items = Array.from({ length: 4096 }, (_, index) => ({
dataset: { uuid: `message-${index}` },
getBoundingClientRect() {
layoutReads++;
return { top: index * 20, bottom: (index + 1) * 20 };
},
}));
const snapshot = captureSessionViewState({
wrap: wrap({ scrollTop: 20000, scrollHeight: 90000, clientHeight: 600, top: 30000 }),
domIndex: {
items,
byUuid: new Map(items.map(item => [item.dataset.uuid, item])),
byMessageUuid: new Map(),
},
});
assert.equal(snapshot.anchor.uuid, 'message-1500');
assert.ok(layoutReads < 20, `expected logarithmic anchor reads, got ${layoutReads}`);
});
test('session DOM index scans the timeline once and groups roots by message UUID', () => {
let queries = 0;
const first = scrollItem('render-1', 0, 20);
first.dataset.messageUuid = 'message-1';
const second = scrollItem('render-2', 20, 40);
second.dataset.messageUuid = 'message-2';
const secondTools = scrollItem('render-2-tools', 40, 60);
secondTools.dataset.messageUuid = 'message-2';
const index = createSessionDomIndex({
querySelectorAll(selector) {
queries++;
assert.equal(selector, '.msg[data-uuid], .wf-card[data-uuid], .skill-card[data-uuid]');
return [first, second, secondTools];
},
});
assert.equal(queries, 1);
assert.deepEqual(index.items, [first, second, secondTools]);
assert.equal(index.byUuid.get('render-2-tools'), secondTools);
assert.deepEqual(index.byMessageUuid.get('message-2'), [second, secondTools]);
findLastMessageAtOrAbove(index.items, 35);
findLastMessageAtOrAbove(index.items, 55);
assert.equal(queries, 1, 'scroll reads reuse the index instead of querying the DOM');
});
test('disclosure registry restores only message roots replaced by the snapshot', () => {
const previous = disclosure('tool:call-1', ['open'], { rawOpen: true });
previous.closest = selector => selector === '[data-message-uuid]'
? { dataset: { messageUuid: 'message-1' } }
: null;
const registry = createSessionDisclosureRegistry();
registry.remember(previous);
const replacement = disclosure('tool:call-1');
const updatedRoot = {
matches: () => false,
querySelectorAll(selector) {
assert.equal(selector, '[data-view-key]');
return [replacement];
},
};
const untouchedRoot = {
matches: () => false,
querySelectorAll() {
assert.fail('unchanged message roots must not be scanned');
},
};
registry.reconcile({
byMessageUuid: new Map([
['message-1', [updatedRoot]],
['message-2', [untouchedRoot]],
]),
}, {
updatedIds: ['message-1'],
removedIds: [],
});
assert.equal(replacement.classList.contains('open'), true);
assert.equal(replacement.raw.classList.contains('show'), true);
assert.equal(replacement.pretty.classList.contains('hidden'), true);
assert.equal(replacement.button.classList.contains('active'), true);
});
test('removed messages discard their remembered disclosure state', () => {
const previous = disclosure('tool:call-1', ['open']);
previous.closest = () => ({ dataset: { messageUuid: 'message-1' } });
const registry = createSessionDisclosureRegistry();
registry.remember(previous);
registry.reconcile({ byMessageUuid: new Map() }, {
updatedIds: [],
removedIds: ['message-1'],
});
const replacement = disclosure('tool:call-1');
registry.reconcile({
byMessageUuid: new Map([['message-1', [{
matches: () => false,
querySelectorAll: () => [replacement],
}]]]),
}, {
updatedIds: ['message-1'],
removedIds: [],
});
assert.equal(replacement.classList.contains('open'), false);
});
test('disclosure registry restores root-level skill cards', () => {
const previous = disclosure('skill:message-1', ['skill-md-open']);
previous.closest = () => ({ dataset: { messageUuid: 'message-1' } });
const registry = createSessionDisclosureRegistry();
registry.remember(previous);
const replacement = disclosure('skill:message-1');
replacement.matches = selector => selector === '[data-view-key]';
replacement.dataset.messageUuid = 'message-1';
registry.reconcile({
byMessageUuid: new Map([['message-1', [replacement]]]),
}, {
updatedIds: ['message-1'],
removedIds: [],
});
assert.equal(replacement.classList.contains('skill-md-open'), true);
});
test('view-state capture and restore do not query the full DOM', () => {
const source = readFileSync(new URL('../app/src/renderer/src/session-view-state.mjs', import.meta.url), 'utf8');
const capture = functionSource(source, 'captureSessionViewState');
const restore = functionSource(source, 'restoreSessionViewState');
assert.doesNotMatch(capture, /querySelectorAll|scrollItems\(|\bdetail\b/);
assert.doesNotMatch(restore, /querySelectorAll|scrollItems\(|\bdetail\b/);
});
test('SessionDetail isolates unchanged rows and gives tail appends a scan-free path', () => {
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
const dataSource = readFileSync(new URL('../app/src/renderer/src/data.js', import.meta.url), 'utf8');
const loadMessages = functionSource(source, 'loadMessages');
const getSkillMd = functionSource(source, 'getSkillMd');
const toggleDisclosure = functionSource(source, 'toggleDisclosure');
assert.match(source, /import\s*\{[^}]*shallowRef[^}]*\}\s*from ['"]vue['"]/s);
assert.match(source, /const messages = shallowRef\(\[\]\)/);
assert.match(source, /applySnapshot/);
assert.match(source, /isFollowingSessionTail/);
assert.match(source, /captureSessionViewState/);
assert.match(source, /createSessionDomIndex/);
assert.match(source, /createSessionDisclosureRegistry/);
assert.match(source, /restoreSessionViewState/);
assert.match(source, /class="timeline"\s+v-memo="\[messages, state\.query\]"/);
assert.match(source, /:key="msg\.uuid"\s+v-memo=/);
assert.match(source, /v-memo="\[msg, state\.query\]"/);
assert.match(source, /loading\.value\s*=\s*!hadContent/);
assert.match(source, /scrollRevision/);
assert.match(source, /restoreScroll:\s*scrollRevision\s*===\s*scrollRevisionBeforePatch/);
assert.match(source, /requestAnimationFrame/);
assert.match(source, /findLastMessageAtOrAbove/);
assert.match(loadMessages, /if \(hadContent && !reconciliation\.tailOnly\)/);
assert.match(loadMessages, /domIndex:\s*sessionDomIndex/);
assert.match(loadMessages, /disclosureRegistry\.reconcile\(sessionDomIndex, reconciliation\)/);
assert.match(loadMessages, /if \(!reconciliation\.changed\)/);
assert.ok(
loadMessages.indexOf('applySnapshot(') < loadMessages.indexOf('captureSessionViewState('),
'the expensive disclosure and anchor scan happens only after classifying the snapshot',
);
assert.ok(
loadMessages.indexOf('applySnapshot(') < loadMessages.indexOf('isFollowingSessionTail('),
'tail state is sampled immediately before the patch, after the async snapshot load',
);
assert.ok(
loadMessages.indexOf('isFollowingSessionTail(') < loadMessages.indexOf('messages.value = reconciliation.messages'),
'tail state is sampled before assigning the new timeline',
);
assert.ok(
loadMessages.indexOf('if (!reconciliation.changed)') < loadMessages.indexOf('await nextTick()'),
'a no-op snapshot returns before awaiting a timeline patch',
);
assert.doesNotMatch(getSkillMd, /messages\.value/);
assert.match(source, /getSkillMd\(msg\)/);
assert.match(toggleDisclosure, /disclosureRegistry\.remember\(element\)/);
assert.ok(
(source.match(/:data-message-uuid="msg\.uuid"/g) || []).length >= 6,
'every timeline root identifies its owning message for targeted disclosure restore',
);
assert.match(dataSource, /messages:\s*markRaw\(assembledMessages\)/);
});
test('live totals and scroll position remain isolated across interleaved updates', () => {
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
const loadMessages = functionSource(source, 'loadMessages');
const syncTimelineDom = functionSource(source, 'syncTimelineDom');
const updateScrollProgress = functionSource(source, 'updateScrollProgress');
const navTo = functionSource(source, 'navTo');
assert.match(loadMessages, /await nextTick\(\);[\s\S]*syncTimelineDom\(\)/);
assert.match(syncTimelineDom, /createSessionDomIndex\(detailRef\.value\)/);
assert.match(syncTimelineDom, /totalMsgs\.value\s*=/);
assert.doesNotMatch(syncTimelineDom, /currentMsgIdx\.value\s*=/);
assert.match(updateScrollProgress, /currentMsgIdx\.value\s*=/);
assert.doesNotMatch(updateScrollProgress, /totalMsgs\.value\s*=/);
assert.doesNotMatch(updateScrollProgress, /querySelectorAll/);
assert.doesNotMatch(navTo, /querySelectorAll/);
});
test('message navigation keeps the top progress bar aligned with the current position', () => {
const source = readFileSync(new URL('../app/src/renderer/src/views/SessionDetail.vue', import.meta.url), 'utf8');
const setMessagePosition = functionSource(source, 'setMessagePosition');
const updateScrollProgress = functionSource(source, 'updateScrollProgress');
const navTo = functionSource(source, 'navTo');
assert.match(setMessagePosition, /currentMsgIdx\.value\s*=\s*index/);
assert.match(setMessagePosition, /progressPct\.value\s*=/);
assert.match(updateScrollProgress, /setMessagePosition\(bottomMsgIdx,\s*msgs\.length\)/);
assert.match(navTo, /setMessagePosition\(idx,\s*msgs\.length\)/);
});