/** Actual React DOM; synthetic geometry/RAF/ResizeObserver. No native layout, model, or network claim. */ import assert from 'node:assert/strict' import { createRequire } from 'node:module' import { readFileSync } from 'node:fs' const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom') const dom = new JSDOM('
', { pretendToBeVisual: true, url: 'http://localhost/' }) for (const key of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'MouseEvent', 'KeyboardEvent', 'Event', 'CustomEvent', 'WheelEvent', 'MutationObserver', 'getComputedStyle']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true }) Object.defineProperty(globalThis, 'navigator', { value: dom.window.navigator, configurable: true }) Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }) let nextFrame = 0 const frames = new Map() const raf = (cb: FrameRequestCallback) => { frames.set(++nextFrame, cb); return nextFrame } const caf = (id: number) => frames.delete(id) Object.assign(globalThis, { requestAnimationFrame: raf, cancelAnimationFrame: caf }) Object.assign(dom.window, { requestAnimationFrame: raf, cancelAnimationFrame: caf }) const observers = new Set() class ResizeObserverFixture { targets = new Set() constructor(public callback: ResizeObserverCallback) { observers.add(this) } observe(target: Element) { this.targets.add(target) } unobserve(target: Element) { this.targets.delete(target) } disconnect() { this.targets.clear(); observers.delete(this) } } Object.assign(globalThis, { ResizeObserver: ResizeObserverFixture }) Object.assign(dom.window, { ResizeObserver: ResizeObserverFixture }) let height = 900 const scrollPositions = new WeakMap() // jsdom has no layout engine: expose deterministic geometry to real component code. Object.defineProperties(dom.window.HTMLElement.prototype, { scrollTop: { configurable: true, get() { return scrollPositions.get(this) ?? 0 }, set(value: number) { scrollPositions.set(this, this.classList.contains('cr-reasoning__scroll') ? Math.min(Math.max(0, value), Math.max(0, height - 200)) : value) } }, scrollHeight: { configurable: true, get() { return this.classList.contains('cr-reasoning__scroll') ? height : 0 } }, clientHeight: { configurable: true, get() { return this.classList.contains('cr-reasoning__scroll') ? 200 : 0 } }, offsetWidth: { configurable: true, get() { return 320 } }, clientWidth: { configurable: true, get() { return 300 } } }) dom.window.HTMLElement.prototype.getBoundingClientRect = function () { return { x: 0, y: 0, left: 0, top: 0, right: 320, bottom: 200, width: 320, height: 200, toJSON() {} } } const { act } = require('react') as typeof import('react') const { createRoot } = require('react-dom/client') as typeof import('react-dom/client') const { ThinkingBlock } = require('../src/renderer/src/workbuddy/conversation/SpecializedBlocks') as typeof import('../src/renderer/src/workbuddy/conversation/SpecializedBlocks') const { DisclosureStateProvider, DisclosureStateScope } = require('../src/renderer/src/workbuddy/conversation/disclosure-state') as typeof import('../src/renderer/src/workbuddy/conversation/disclosure-state') const MessageBubble = require('../src/renderer/src/components/MessageBubble').default as typeof import('../src/renderer/src/components/MessageBubble').default const { installReasoningReadingAnchor } = require('../src/renderer/src/workbuddy/conversation/reasoning-reading-anchor') as typeof import('../src/renderer/src/workbuddy/conversation/reasoning-reading-anchor') const root = createRoot(document.getElementById('root')!) let checks = 0 const check = (value: unknown, message: string) => { assert.ok(value, message); checks++ } const get = (selector: string) => { const el = document.querySelector(selector); assert.ok(el, selector); return el } const section = (id = 'a') => get(`[data-test-part="${id}"] .cr-reasoning`) const scroll = (id = 'a') => get(`[data-test-part="${id}"] .cr-reasoning__scroll`) const expanded = (id = 'a') => section(id).querySelector('button')!.getAttribute('aria-expanded') === 'true' const following = (id = 'a') => section(id).getAttribute('data-follow-state') === 'following' const clickHeader = async (id = 'a') => act(async () => section(id).querySelector('button')!.dispatchEvent(new MouseEvent('click', { bubbles: true }))) const flush = async () => act(async () => { const pending = [...frames.values()]; frames.clear(); pending.forEach(cb => cb(0)) }) const resize = async () => { await act(async () => { for (const observer of [...observers]) observer.callback([...observer.targets].map(target => ({ target })) as ResizeObserverEntry[], observer as unknown as ResizeObserver) }); await flush() } type Part = { id: string; text: string; active?: boolean; finished?: boolean } let parts: Part[] = [{ id: 'a', text: 'Reasoning A', active: true }] let identity = 'session-a:message-a', mounted = true function Fixture() { return {mounted && parts.map(part =>
)}
} const render = async () => { await act(async () => root.render()) } const resume = async (id = 'a') => { const button = [...section(id).querySelectorAll('button')].find(el => el.textContent?.includes('查看最新')); assert.ok(button, 'explicit resume action'); await act(async () => button.dispatchEvent(new MouseEvent('click', { bubbles: true }))); await flush() } async function testOuterAnchor() { const pane = document.createElement('div'), otherPane = document.createElement('div') pane.style.overflowAnchor = 'auto' pane.innerHTML = '
read
' otherPane.innerHTML = '
other
' document.body.append(pane, otherPane) let flowTop = 600, visible = true, pauses = 0, releases = 0, otherPauses = 0 const rect = (top: number) => ({ x: 0, y: top, left: 0, top, right: 320, bottom: top + 200, width: 320, height: 200, toJSON() {} }) pane.getBoundingClientRect = () => ({ ...rect(10), width: visible ? 320 : 0, height: visible ? 200 : 0 }) pane.getClientRects = () => (visible ? [rect(10)] : []) as unknown as DOMRectList const prepareNode = (node: HTMLElement) => { node.getBoundingClientRect = () => ({ ...rect(flowTop - pane.scrollTop + 10), width: visible ? 320 : 0, height: visible ? 200 : 0 }) node.getClientRects = () => (visible ? [node.getBoundingClientRect()] : []) as unknown as DOMRectList return node } let node = prepareNode(pane.querySelector('[data-reasoning-id]')!) const anchor = installReasoningReadingAnchor(pane, () => { pauses++ }, () => { releases++ }) const other = installReasoningReadingAnchor(otherPane, () => { otherPauses++ }, () => {}) const capture = () => node.dispatchEvent(new CustomEvent('ycode:reasoning-reading', { bubbles: true })) pane.scrollTop = 400; capture() check(pauses === 1 && otherPauses === 0 && pane.style.overflowAnchor === 'auto', 'outer capture is pane-local and preserves browser anchoring style') flowTop += 75; await resize() check(pane.scrollTop === 475, 'earlier-content growth adjusts outer scroll by anchor delta, not bottom') node.remove() node = prepareNode(document.createElement('section')); node.className = 'cr-reasoning'; node.dataset.reasoningId = 'part-a' pane.querySelector('[data-message-id]')!.append(node) flowTop += 40; await resize() check(pane.scrollTop === 515, 'reparented node reacquired by message and part identity') node.remove(); await resize(); check(pane.scrollTop === 515, 'missing anchor is safe no-op') const wrong = document.createElement('div'); wrong.dataset.messageId = 'message-b'; wrong.append(node); pane.append(wrong) flowTop += 25; await resize(); check(pane.scrollTop === 515, 'same part ID in wrong message cannot capture anchor') pane.querySelector('[data-message-id="message-a"]')!.append(node); wrong.remove() node.setAttribute('aria-hidden', 'true'); await resize(); check(pane.scrollTop === 515, 'hidden anchor is no-op') node.removeAttribute('aria-hidden'); visible = false; await resize(); check(pane.scrollTop === 515, 'display-none pane without layout rectangles is no-op') visible = true; await resize(); check(pane.scrollTop === 540, 'visible restored pane compensates prior offset once') flowTop += 20; pane.scrollTop += 20; await resize() check(pane.scrollTop === 560, 'already native-compensated anchor receives no duplicate correction') for (const event of [new WheelEvent('wheel', { bubbles: true }), new Event('touchstart', { bubbles: true }), new KeyboardEvent('keydown', { key: 'PageDown', bubbles: true }), new MouseEvent('pointerdown', { bubbles: true, button: 0 })]) { capture(); flowTop += 30 // Queue observer frame without running it; outside intention must cancel it. for (const observer of [...observers]) observer.callback([], observer as unknown as ResizeObserver) const before = pane.scrollTop pane.dispatchEvent(event); await flush() check(pane.scrollTop === before && pane.style.overflowAnchor === 'auto', `${event.type} releases outer anchor and cancels pending correction`) } capture(); const beforeKey = releases node.dispatchEvent(new WheelEvent('wheel', { bubbles: true })) pane.dispatchEvent(new KeyboardEvent('keydown', { key: 'a', bubbles: true })) check(releases === beforeKey, 'inner reading and non-scroll keys do not release anchor') anchor.reset(); flowTop += 100; const beforeReset = pane.scrollTop; await resize() check(pane.scrollTop === beforeReset && pane.style.overflowAnchor === 'auto', 'session/reset clears anchor and restores overflow style') capture(); const beforeDispose = pauses const staleObservers = [...observers] anchor.dispose(); other.dispose(); capture() check(pauses === beforeDispose && observers.size === 0 && pane.style.overflowAnchor === 'auto', 'dispose removes listener, observers, queued frames and overflow override') staleObservers.forEach(observer => observer.callback([], observer as unknown as ResizeObserver)) check(frames.size === 0, 'late resize callback after dispose cannot enqueue work') pane.remove(); otherPane.remove() } async function main() { await render(); await flush() check(expanded() && following(), 'active A initially opens and follows') check(scroll().scrollTop >= height - 200, 'active A goes to synthetic bottom') await act(async () => scroll().dispatchEvent(new Event('scroll', { bubbles: true }))) parts = [{ ...parts[0], active: false }, { id: 'b', text: 'Reasoning B', active: true }] await render(); await flush() check(!expanded('a') && expanded('b'), 'later reasoning auto-closes untouched A and opens B') await clickHeader('b'); parts[1].text += ' new tokens'; await render(); await flush() check(!expanded('b'), 'manual-closed B remains closed on tokens') await clickHeader('a'); check(expanded('a'), 'old reasoning can be opened manually') await flush(); check(scroll('a').scrollTop === 0, 'first old manual opening starts at top, not saved automatic bottom') parts.push({ id: 'c', text: 'Reasoning C', active: true }); parts[1].active = false await render(); await flush(); check(expanded('a'), 'manual-open old A remains open after later reasoning') identity = 'session-a:inputs'; parts = [{ id: 'a', text: 'Input reasoning', active: true }]; await render(); await flush() const intents = [ () => new WheelEvent('wheel', { deltaY: -20, bubbles: true }), () => new Event('touchstart', { bubbles: true }), () => new KeyboardEvent('keydown', { key: 'PageUp', bubbles: true }), () => new MouseEvent('pointerdown', { clientX: 315, bubbles: true }) ] for (const makeEvent of intents) { await act(async () => scroll().dispatchEvent(makeEvent())) check(!following(), `${makeEvent().type} pauses follow`) await act(async () => { scroll().scrollTop = height - 200; scroll().dispatchEvent(new Event('scroll', { bubbles: true })) }) check(!following(), 'reaching bottom does not silently resume') await resume(); check(following(), 'explicit latest resumes') } height += 200; await resize(); check(scroll().scrollTop >= height - 200, 'async content resize follows bottom') parts[0].text += ' queued token'; await render() await act(async () => { scroll().dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -10 })); scroll().scrollTop = 123; scroll().dispatchEvent(new Event('scroll', { bubbles: true })) }) await flush(); check(!following() && scroll().scrollTop === 123, 'manual intent wins over queued RAF') height += 300; await resize(); check(scroll().scrollTop === 123, 'paused resize retains reading position') mounted = false; await render(); mounted = true; await render(); await flush() check(!following() && scroll().scrollTop === 123, 'same-part remount restores paused reading position') await resume() await act(async () => section().querySelector('.cr-reasoning__text')!.dispatchEvent(new MouseEvent('pointerdown', { button: 0, bubbles: true }))) check(!following(), 'desktop text selection intention pauses follow') identity = 'session-a:empty'; parts = [{ id: 'a', text: '', active: true }]; await render() parts[0].text = 'First tokens'; await render(); await flush() const beforeResize = scroll().scrollTop; height += 150; await resize() check(scroll().scrollTop > beforeResize, 'empty-to-text mount attaches async resize observer') const finalPosition = scroll().scrollTop parts[0].finished = true; await render(); await flush() await act(async () => scroll().dispatchEvent(new WheelEvent('wheel', { bubbles: true }))) mounted = false; await render(); mounted = true; await render(); await flush() check(scroll().scrollTop === finalPosition, 'finish before native scroll event retains synchronously saved clamped follow position') identity = 'session-a:history'; parts = [{ id: 'a', text: 'Finished history', finished: true }]; await render(); await flush() check(!expanded(), 'finished history starts closed') await clickHeader(); await flush() check(expanded() && !following() && scroll().scrollTop === 0, 'first manual history opening starts at old top without follow') identity = 'session-b:history'; parts = [{ id: 'a', text: 'Other session', finished: true }]; await render(); await flush() check(!expanded(), 'session identity isolates manual open choice') identity = 'session-b:message-two'; parts = [{ id: 'a', text: 'Other message', active: true }]; await render(); await flush() check(expanded() && following(), 'message identity isolates paused follow state') // Exercise real MessageBubble grouping/reparenting, not only the leaf fixture. let message: import('../src/shared/types').ChatMessage = { id: 'actual-message', role: 'assistant', content: '', createdAt: 1, status: 'streaming', parts: [{ type: 'reasoning', id: 'actual-a', text: 'Actual reasoning A' }] } let actualSession = 'actual-session-a' const renderMessage = async () => { await act(async () => root.render()); await flush() } for (const next of [{ type: 'tool', toolId: 'auto-tool' }, { type: 'text', text: 'Answer' }] as import('../src/shared/types').MessagePart[]) { message = { ...message, id: `auto-${next.type}`, parts: [{ type: 'reasoning', id: 'actual-a', text: 'Automatic A' }] } await renderMessage() message = { ...message, parts: [...message.parts!, next] }; await renderMessage() check(get('.cr-reasoning > button').getAttribute('aria-expanded') === 'false', `new ${next.type} auto-closes untouched A`) } message = { ...message, id: 'actual-message', parts: [{ type: 'reasoning', id: 'actual-a', text: 'Actual reasoning A' }] } await renderMessage() const actual = () => get('.cr-reasoning__scroll') let readingEvents = 0 const receive = () => { readingEvents++ } document.getElementById('root')!.addEventListener('ycode:reasoning-reading', receive) await act(async () => actual().dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -20 }))) check(readingEvents === 1, 'manual reading bubbles scoped protection event to conversation ancestor') for (const next of [ { type: 'tool', toolId: 'actual-t' }, { type: 'text', text: 'Later answer' }, { type: 'reasoning', id: 'actual-b', text: 'Actual reasoning B' } ] as import('../src/shared/types').MessagePart[]) { message = { ...message, parts: [...message.parts!, next], tools: [{ id: 'actual-t', name: 'Read', args: { file_path: 'fixture' }, argsRaw: '{}', status: 'ok' }] } await renderMessage() const first = get('.cr-reasoning') check(first.querySelector('button')!.getAttribute('aria-expanded') === 'true', `${next.type} does not fold manually read old reasoning`) let ancestor = first.parentElement while (ancestor && ancestor.id !== 'root') { if (ancestor.classList.contains('cr-collapse')) check(ancestor.querySelector(':scope > .cr-collapse__header')?.getAttribute('aria-expanded') === 'true', `${next.type} protects containing disclosure`) ancestor = ancestor.parentElement } } message = { ...message, status: 'done' }; await renderMessage() const outer = get('.cr-collapse--completed > .cr-collapse__header') check(outer.getAttribute('aria-expanded') === 'true', 'completion does not conceal manual reasoning reader') await act(async () => { actual().scrollTop = 171; actual().dispatchEvent(new Event('scroll', { bubbles: true })) }) await act(async () => outer.dispatchEvent(new MouseEvent('click', { bubbles: true }))) check(get('.cr-collapse--completed > .cr-collapse__header').getAttribute('aria-expanded') === 'false', 'explicit ancestor close overrides read protection') await act(async () => { actual().scrollTop = 0; actual().dispatchEvent(new Event('scroll', { bubbles: true })) }) await flush() await act(async () => get('.cr-collapse--completed > .cr-collapse__header').dispatchEvent(new MouseEvent('click', { bubbles: true }))) await flush() check(actual().scrollTop === 171, 'hidden ancestor synthetic zero-scroll does not erase saved reading anchor on reopening') actualSession = 'actual-session-b'; await renderMessage() check(get('.cr-reasoning > button').getAttribute('aria-expanded') === 'false' && get('.cr-reasoning').getAttribute('data-follow-state') === 'paused', 'actual MessageBubble session switch with identical message/part IDs resets manual state') document.getElementById('root')!.removeEventListener('ycode:reasoning-reading', receive) // Integration contract only: ChatPane itself is not mounted in this isolated DOM fixture. const paneSource = readFileSync('src/renderer/src/components/ChatPane.tsx', 'utf8') check(/installReasoningReadingAnchor\(/.test(paneSource) && /\.dispose\(\)/.test(paneSource), 'ChatPane installs and disposes outer helper (source integration contract)') check(/reasoningReading\.current = true; followBottom\.current = false/.test(paneSource), 'outer reading event disables outer follow (source contract)') const styles = readFileSync('src/renderer/src/workbuddy/conversation/source-styles.css', 'utf8') check(/\.cr-reasoning\[data-follow-state="paused"\] \.cr-reasoning__scroll\s*\{\s*overflow-anchor:\s*auto/.test(styles), 'paused inner scroll retains native anchoring (CSS contract)') await act(async () => root.unmount()); check(observers.size === 0, 'resize observers disconnect on cleanup') await testOuterAnchor() dom.window.close() console.log(JSON.stringify({ result: 'PASS', suite: 'reasoning-follow-dom', assertions: checks, evidence: 'Actual ReactDOM with synthetic geometry/RAF/resize; not native scroll or pixel evidence' })) } main().then(() => process.exit(0)).catch(error => { console.error(error); dom.window.close(); process.exit(1) })