Files
dsh-context/tests/real-react.client.spec.ts
T
bowenliang123 6e19c8e950 refactor(trend): rename the diff display mode to delta
Aligns with the token-meter vocabulary of recent dsh releases (signed
token delta) and with the existing zh label 增量. UI labels, i18n keys,
settings schema, client logic and tests all switch from diff to delta.
The settings schema is now loose on defaultTrendMode so a stale
persisted value degrades to the default instead of throwing, and the
client ignores unrecognized values the same way.
2026-08-24 18:24:52 +08:00

152 lines
7.2 KiB
TypeScript

import assert from 'node:assert/strict'
import { test } from 'vitest'
import { readFile } from 'node:fs/promises'
import { JSDOM } from 'jsdom'
import React from 'react'
import * as ReactDOM from 'react-dom'
import { createRoot } from 'react-dom/client'
test('real-React jsdom mount: rapid step<->turn toggles never hit React #185', async () => {
const bundle = await readFile(new URL('../lib/client.js', import.meta.url), 'utf8')
const dom = new JSDOM('<!doctype html><html><body></body></html>', { pretendToBeVisual: true })
globalThis.window = dom.window
globalThis.document = dom.window.document
// jsdom lacks scrollWidth/clientWidth semantics; emulate just enough layout
// for the scroll container: content width follows the bar count (so a
// granularity switch genuinely flips the overflow state — a static stub
// never changes the edge fades and cannot regress-test the React #185
// setState cascade this script exists for).
Object.defineProperty(dom.window.HTMLElement.prototype, 'scrollWidth', {
configurable: true,
get() {
if (this.classList && this.classList.contains('lc-chart-scroll')) {
const chart = this.querySelector('.lc-chart')
return Math.max(this.clientWidth, chart ? chart.children.length * 16 : 0)
}
return this.__scrollW ?? 0
},
})
Object.defineProperty(dom.window.HTMLElement.prototype, 'clientWidth', { configurable: true, get() { return this.__clientW ?? 400 } })
Object.defineProperty(dom.window.HTMLElement.prototype, 'scrollLeft', {
configurable: true,
get() { return this.__scrollL ?? 0 },
set(v) {
this.__scrollL = Math.max(0, Math.min(v, Math.max(0, this.scrollWidth - this.clientWidth)))
// Real browsers dispatch an async scroll event for programmatic scrolls;
// jsdom does not. The event lands mid-cascade and its dispatch is what
// disabled the same-value eager bailout in the no-deps layout effect
// (React #185 on a granularity switch) — emulate it or the bug stays
// invisible here.
const el = this
queueMicrotask(() => { if (el.isConnected) el.dispatchEvent(new dom.window.Event('scroll')) })
},
})
let handoff = null
globalThis.window.__ModuleLoader__ = {
load(h) { handoff = h },
}
const require = (spec) => {
if (spec === 'react') return React
if (spec === 'react-dom') return ReactDOM
if (spec === '@deepseek-ai/dsh-client-ui-primitives') {
// Glyph-only usage in this bundle; render inert svgs (never asserted).
return {
IconPlusOutline16: (p) => React.createElement('svg', p, null),
IconBranchOutline16: (p) => React.createElement('svg', p, null),
IconCloseOutline16: (p) => React.createElement('svg', p, null),
}
}
throw new Error(`unexpected module: ${spec}`)
}
// Run the bundle verbatim through the boot handoff (no string surgery — decoupled from bundler layout).
new Function(bundle)()
assert.ok(handoff !== null, 'bundle must register through __ModuleLoader__.load')
const plugin = handoff.factory(require)
const requests = []
for (let i = 0; i < 460; i++) {
requests.push({
seq: i + 1, turn: 1 + Math.floor(i / 12), step: i % 12, time: 1700000000000 + i * 1000,
system: 4400, tools: 8200, user: 1100, inject: 1700, assistant: 2600, tool: 2600,
total: 4400 + 8200 + 1100 + 1700 + 2600 + 2600, prompt: 594000,
})
}
const snapshot = {
ok: true, model: 'deepseek-v4-flash', provider: 'opencode-go', contextWindow: 1000000,
current: { system: 4400, tools: 8200, user: 1100, inject: 1700, assistant: 2600, tool: 2600, total: 20600 },
toolList: [], requests, events: [], nodes: [], droppedNodes: 0,
}
const localeRegs = []
let viewComponent = null
const ctx = {
get: () => undefined,
inject: () => {},
effect: (fn) => { fn(); return () => {} },
locale: {
register: (ns, dicts) => { localeRegs.push(dicts); return () => {} },
bind: () => (key, vars) => {
const dict = localeRegs[0].zh
let s = dict[key] !== undefined ? dict[key] : key
if (vars) for (const k in vars) s = s.replace('{' + k + '}', String(vars[k]))
return s
},
},
slots: {
inject: (name, fn) => { fn() },
// Capture per slot name: the overlay (ContextModal) registers AFTER the
// view tab, so an unconditional overwrite would capture the modal.
register: (opts, component) => { if (opts.name === 'conversation.view') viewComponent = component; return opts },
},
}
plugin.apply(ctx)
assert.ok(viewComponent !== null)
// The framework standard kit delivers the timeline as a push-fed projection;
// hand it straight to the stub so the chart renders on the first commit.
const viewProps = { sessionId: 's1', useProjection: (key) => (key === 'contextTimeline' ? snapshot : undefined) }
// Mount behind a boundary: catches #185-style crashes (without one an update-depth error unmounts the root).
const container = dom.window.document.createElement('div')
dom.window.document.body.appendChild(container)
const root = createRoot(container)
const boundaryErrors = []
class Boundary extends React.Component {
constructor(p) { super(p); this.state = { err: null } }
componentDidCatch(err) { boundaryErrors.push(err); this.setState({ err }) }
render() { return this.state.err ? React.createElement('pre', null, 'CRASHED') : this.props.children }
}
try {
root.render(React.createElement(Boundary, null, React.createElement(viewComponent, viewProps)))
await new Promise(r => setTimeout(r, 60))
assert.ok(container.textContent.includes('tokens'), 'chart rendered')
// Wide viewport: the step chart overflows (460 bars) but the turn chart
// fits (≈41 columns), so toggling flips the edge-fade state for real.
const scroller = container.querySelector('.lc-chart-scroll')
scroller.__clientW = 800
const buttons = [...container.querySelectorAll('.lc-gran .lc-gran-btn')]
// Two toggle groups: granularity (Step/Turn) + display mode (Total/Delta);
// the first two buttons are the granularity pair exercised below.
assert.equal(buttons.length, 4, 'toggle buttons rendered')
// Rapid toggles: the overflow state flips each time (wide step chart vs
// narrow turn chart), which used to cascade setEdges dispatches inside the
// no-deps layout effect until React #185 (maximum update depth) fired.
for (let i = 0; i < 8; i++) {
const btns = [...container.querySelectorAll('.lc-gran .lc-gran-btn')]
assert.equal(btns.length, 4, `toggle buttons still mounted after ${i} switches`)
btns[i % 2].click()
await new Promise(r => setTimeout(r, 20))
}
assert.equal(boundaryErrors.length, 0, 'no render crash: ' + (boundaryErrors[0] && boundaryErrors[0].message))
const finalBtns = [...container.querySelectorAll('.lc-gran .lc-gran-btn')]
finalBtns[0].click()
await new Promise(r => setTimeout(r, 20))
assert.equal(boundaryErrors.length, 0, 'no render crash: ' + (boundaryErrors[0] && boundaryErrors[0].message))
assert.ok(container.querySelectorAll('.lc-bar').length > 100, 'step mode re-rendered many bars')
console.log('✔ real-React repro passed: 8 rapid step<->turn toggles render without throwing')
} finally {
try { root.unmount() } catch {}
}
})