/** 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 = ''
otherPane.innerHTML = ''
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) })