238 lines
20 KiB
TypeScript
238 lines
20 KiB
TypeScript
/** 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('<!doctype html><html><body><main id="root"></main></body></html>', { 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<number, FrameRequestCallback>()
|
|
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<ResizeObserverFixture>()
|
|
class ResizeObserverFixture {
|
|
targets = new Set<Element>()
|
|
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<HTMLElement, number>()
|
|
// 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 = <T extends Element = HTMLElement>(selector: string) => { const el = document.querySelector<T>(selector); assert.ok(el, selector); return el }
|
|
const section = (id = 'a') => get(`[data-test-part="${id}"] .cr-reasoning`)
|
|
const scroll = (id = 'a') => get<HTMLElement>(`[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 <DisclosureStateProvider key={identity}>{mounted && parts.map(part => <DisclosureStateScope key={part.id} id={part.id}><div data-test-part={part.id}><ThinkingBlock text={part.text} isLastContent={part.active} isFinished={part.finished} /></div></DisclosureStateScope>)}</DisclosureStateProvider>
|
|
}
|
|
const render = async () => { await act(async () => root.render(<Fixture />)) }
|
|
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 = '<div class="chat-thread"><div data-message-id="message-a"><section class="cr-reasoning" data-reasoning-id="part-a"><div class="cr-reasoning__scroll">read</div></section></div></div>'
|
|
otherPane.innerHTML = '<div data-message-id="message-a"><section class="cr-reasoning" data-reasoning-id="part-a">other</section></div>'
|
|
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<HTMLElement>('[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(<MessageBubble message={message} sessionId={actualSession} />)); 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<HTMLElement>('.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) })
|