feat(conversation): follow active reasoning while preserving manual reading
This commit is contained in:
1 parent
35b9a62ed4
commit
4a72008b2e
12 files changed
+530
-40
No files matched your search
@@ -3,9 +3,9 @@ import assert from 'node:assert/strict'
|
||||
import { createRequire } from 'node:module'
|
||||
const { JSDOM } = createRequire(process.cwd() + '/package.json')(process.env.YCODE_TEST_DOM_MODULE || 'jsdom')
|
||||
const dom = new JSDOM('<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']) Object.defineProperty(globalThis, key, { value: dom.window[key], configurable: true })
|
||||
for (const key of ['window', 'document', 'Node', 'Element', 'HTMLElement', 'MouseEvent', 'KeyboardEvent', 'Event', 'CustomEvent']) 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 })
|
||||
Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true, requestAnimationFrame: dom.window.requestAnimationFrame.bind(dom.window), cancelAnimationFrame: dom.window.cancelAnimationFrame.bind(dom.window) })
|
||||
const { act } = require('react') as typeof import('react')
|
||||
const { createRoot } = require('react-dom/client') as typeof import('react-dom/client')
|
||||
const { CompletedFold } = require('../src/renderer/src/workbuddy/conversation/CompletedFold') as typeof import('../src/renderer/src/workbuddy/conversation/CompletedFold')
|
||||
@@ -34,7 +34,7 @@ async function main() {
|
||||
await act(async () => root.render(<Fixture />))
|
||||
assert.equal(expanded(outer), 'false')
|
||||
await keyboard(outer, 'Enter')
|
||||
for (const selector of [group, thinking, ordinary, question]) { assert.equal(expanded(selector), 'false'); await click(selector); assert.equal(expanded(selector), 'true') }
|
||||
for (const selector of [group, thinking, ordinary, question]) { assert.equal(expanded(selector), 'false'); await click(selector); assert.equal(expanded(selector), 'true', `${selector} opens on click`) }
|
||||
assert.equal(expanded(second), 'false', 'local summaries are independent')
|
||||
for (const selector of ['.cb-task-result-item-title', '.cb-plan-header-left']) { assert.equal(expanded(selector), 'true'); await click(selector); assert.equal(expanded(selector), 'false') }
|
||||
await keyboard(outer, ' ')
|
||||
@@ -59,8 +59,7 @@ async function main() {
|
||||
await act(async () => root.render(<Fixture id="live-message" finished={false} />))
|
||||
for (const selector of [group, thinking, ordinary, question]) await click(selector)
|
||||
await act(async () => root.render(<Fixture id="live-message" finished />))
|
||||
assert.equal(expanded(outer), 'false', 'completion starts folded after live events')
|
||||
await click(outer)
|
||||
assert.equal(expanded(outer), 'true', 'completion preserves manually opened reasoning reader')
|
||||
for (const selector of [group, thinking, ordinary, question]) assert.equal(expanded(selector), 'true', `${selector} survives live-to-completed reparenting`)
|
||||
await act(async () => root.unmount()); dom.window.close()
|
||||
console.log('completed-fold-nested: real question/tool/thinking/local-summary remount, event, keyboard and identity contracts passed')
|
||||
|
||||
@@ -355,10 +355,12 @@ const collapsedThinking = renderToStaticMarkup(<ThinkingBlock text="Hidden reaso
|
||||
// The source collapse keeps its content mounted for CSS transitions; it must be inaccessible while closed.
|
||||
assert.match(collapsedThinking, /cr-collapse--collapsed/)
|
||||
assert.match(collapsedThinking, /class="cr-collapse__content" aria-hidden="true" inert=""/)
|
||||
const expandedThinking = renderToStaticMarkup(<ThinkingBlock text="Visible reasoning" isFinished isLastContent renderMarkdown={text => <strong>{text}</strong>} />)
|
||||
const expandedThinking = renderToStaticMarkup(<ThinkingBlock text="Visible reasoning" isLastContent renderMarkdown={text => <strong>{text}</strong>} />)
|
||||
assert.match(expandedThinking, /Visible reasoning/)
|
||||
assert.match(expandedThinking, /<strong>Visible reasoning<\/strong>/)
|
||||
assert.doesNotMatch(expandedThinking, /cr-collapse--collapsed/)
|
||||
const historicalLatestThinking = renderToStaticMarkup(<ThinkingBlock text="History" isFinished isLastContent />)
|
||||
assert.match(historicalLatestThinking, /cr-collapse--collapsed/, 'finished history stays initially closed even when last')
|
||||
|
||||
const planTasks = Array.from({ length: 8 }, (_, index) => ({
|
||||
id: `task-${index}`,
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
/** 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) })
|
||||
@@ -42,7 +42,7 @@ for (const file of ['scripts/build-runtime-regressions.mjs', '.tmp/claude-approv
|
||||
|
||||
// Optional DOM tests use separately installed jsdom, not Electron/CLI or an upstream model.
|
||||
if (domModule) {
|
||||
for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression']) {
|
||||
for (const file of ['task-popconfirm-regression', 'question-interaction-regression', 'question-identity-regression', 'completed-fold-nested-regression', 'completed-fold-native-identity-regression', 'permission-intercept-dom-regression', 'reasoning-follow-dom-regression']) {
|
||||
const outfile = `.tmp/alignment-tests/${file}.cjs`
|
||||
await build({ entryPoints: [`scripts/${file}.tsx`], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' })
|
||||
const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 })
|
||||
@@ -56,5 +56,5 @@ if (domModule) {
|
||||
|
||||
console.log(JSON.stringify({ result: 'PASS', phase: 'alignment-offline', suites: tests.length + 3 }))
|
||||
console.log(JSON.stringify(domModule
|
||||
? { result: 'PASS', phase: 'alignment-dom', suites: 7 }
|
||||
: { result: 'SKIP', phase: 'alignment-dom', suites: 7, reason: 'YCODE_TEST_DOM_MODULE is not set; DOM coverage was not run. Use --require-dom to forbid this skip.' }))
|
||||
? { result: 'PASS', phase: 'alignment-dom', suites: 8 }
|
||||
: { result: 'SKIP', phase: 'alignment-dom', suites: 8, reason: 'YCODE_TEST_DOM_MODULE is not set; DOM coverage was not run. Use --require-dom to forbid this skip.' }))
|
||||
@@ -0,0 +1,13 @@
|
||||
/** Offline React DOM only. Geometry is synthetic; this is not native scroll/pixel evidence. */
|
||||
import { build } from 'esbuild'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { createRequire } from 'node:module'
|
||||
|
||||
const domModule = process.env.YCODE_TEST_DOM_MODULE
|
||||
if (!domModule) throw new Error('Set YCODE_TEST_DOM_MODULE to an installed jsdom module path. DOM coverage cannot be skipped.')
|
||||
if (typeof createRequire(process.cwd() + '/package.json')(domModule).JSDOM !== 'function') throw new Error('DOM module must export JSDOM')
|
||||
const outfile = '.tmp/alignment-tests/reasoning-follow-dom-regression.cjs'
|
||||
await build({ entryPoints: ['scripts/reasoning-follow-dom-regression.tsx'], outfile, bundle: true, platform: 'node', format: 'cjs', jsx: 'automatic', loader: { '.css': 'empty' }, logLevel: 'warning' })
|
||||
const result = spawnSync(process.execPath, [outfile], { stdio: 'inherit', timeout: 120000 })
|
||||
if (result.error) throw result.error
|
||||
process.exit(result.status ?? 1)
|
||||
Reference in new issue
Block a user