Files
ycode/scripts/streaming-footer-regression.tsx

131 lines
5.5 KiB
TypeScript

import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { renderToStaticMarkup } from 'react-dom/server'
import type { ChatMessage } from '../src/shared/types'
import MessageBubble from '../src/renderer/src/components/MessageBubble'
import {
getSourceLoadingTips,
pickNextLoadingTip,
resolveLoadingTipLocale,
scheduleLoadingTips,
STREAMING_TIP_INITIAL_DELAY_MS,
STREAMING_TIP_ROTATION_INTERVAL_MS,
type LoadingTipScheduler
} from '../src/renderer/src/workbuddy/conversation/StreamingFooter'
import { SOURCE_LOADING_TIPS } from '../src/renderer/src/workbuddy/conversation/streaming-loading-tips'
type TimerId = ReturnType<typeof globalThis.setTimeout>
class FakeScheduler implements LoadingTipScheduler {
private now = 0
private nextId = 0
private timers = new Map<number, { at: number; callback: () => void; interval: number }>()
setTimeout(callback: () => void, delayMs: number): TimerId {
return this.add(callback, delayMs, 0)
}
clearTimeout(timer: TimerId): void {
this.timers.delete(timer as unknown as number)
}
setInterval(callback: () => void, intervalMs: number): TimerId {
return this.add(callback, intervalMs, intervalMs)
}
clearInterval(timer: TimerId): void {
this.timers.delete(timer as unknown as number)
}
tick(durationMs: number): void {
const end = this.now + durationMs
for (;;) {
const due = [...this.timers.entries()].filter(([, timer]) => timer.at <= end)
.sort((a, b) => a[1].at - b[1].at || a[0] - b[0])[0]
if (!due) break
const [id, timer] = due
this.now = timer.at
if (timer.interval === 0) this.timers.delete(id)
else timer.at += timer.interval
timer.callback()
}
this.now = end
}
private add(callback: () => void, delayMs: number, interval: number): TimerId {
const id = this.nextId++
this.timers.set(id, { at: this.now + delayMs, callback, interval })
return id as unknown as TimerId
}
}
assert.equal(STREAMING_TIP_INITIAL_DELAY_MS, 4_000)
assert.equal(STREAMING_TIP_ROTATION_INTERVAL_MS, 10_000)
assert.equal(SOURCE_LOADING_TIPS.en.length, 188)
assert.equal(SOURCE_LOADING_TIPS.zh.length, 275)
assert.ok(SOURCE_LOADING_TIPS.en.every(Boolean))
assert.ok(SOURCE_LOADING_TIPS.zh.every(Boolean))
assert.equal(resolveLoadingTipLocale('pt-BR'), 'en')
assert.equal(resolveLoadingTipLocale('id-ID'), 'en')
assert.equal(resolveLoadingTipLocale('zh-CN'), 'zh')
assert.equal(resolveLoadingTipLocale(undefined), 'zh')
assert.ok(getSourceLoadingTips('en-US', 'YCode').some(tip => tip.includes('YCode')))
assert.ok(getSourceLoadingTips('zh-CN', 'YCode').some(tip => tip.includes('YCode')))
assert.ok(getSourceLoadingTips('en-US', 'YCode').every(tip => !tip.includes('WorkBuddy')))
assert.ok(getSourceLoadingTips('zh-CN', 'YCode').every(tip => !tip.includes('WorkBuddy')))
assert.equal(pickNextLoadingTip([], null), null)
assert.equal(pickNextLoadingTip(['only'], 'only'), 'only')
assert.equal(pickNextLoadingTip(['first', 'second'], 'first', () => 0), 'second')
const scheduler = new FakeScheduler()
const shown: string[] = []
let paused = false
const cancel = scheduleLoadingTips({
tips: ['first tip', 'second tip'],
onTip: tip => shown.push(tip),
isPaused: () => paused,
scheduler
})
scheduler.tick(3_999)
assert.deepEqual(shown, [], 'the first tip waits for the source 4 second delay')
scheduler.tick(1)
assert.equal(shown.length, 1)
const firstTip = shown[0]
paused = true
scheduler.tick(10_000)
assert.equal(shown.length, 1, 'hovering pauses tip rotation')
paused = false
scheduler.tick(10_000)
assert.equal(shown.length, 2, 'tip rotation resumes on the next 10 second interval')
assert.notEqual(shown[1], firstTip, 'adjacent tips do not repeat')
cancel()
scheduler.tick(30_000)
assert.equal(shown.length, 2, 'unmount cleanup stops future rotations')
const streamingMessage = (content: string, parts: ChatMessage['parts']): ChatMessage => ({
id: `streaming-${content ? 'body' : 'empty'}`, role: 'assistant', status: 'streaming', createdAt: 1, content, parts
})
const emptyStream = renderToStaticMarkup(<MessageBubble message={streamingMessage('', [])} />)
assert.match(emptyStream, /wb-cr-streaming-footer/)
assert.match(emptyStream, /思考中/)
assert.doesNotMatch(emptyStream, /bubble__cursor/)
const bodyStream = renderToStaticMarkup(<MessageBubble message={streamingMessage('partial answer', [{ type: 'text', text: 'partial answer' }])} />)
assert.match(bodyStream, /partial /)
assert.match(bodyStream, /markdown-content__loading-char/)
assert.match(bodyStream, /wb-cr-streaming-footer/, 'the streaming footer remains while answer text is arriving')
const completed = renderToStaticMarkup(<MessageBubble message={{
id: 'completed-footer', role: 'assistant', status: 'done', createdAt: 1, content: 'final answer',
parts: [{ type: 'text', text: 'final answer' }], modelId: 'model-1', modelName: 'gpt-6-luna'
}} />)
assert.doesNotMatch(completed, /wb-cr-streaming-footer/)
assert.match(completed, /gpt-6-luna/, 'the completed message retains its frozen model label')
const recoveredCss = readFileSync('src/renderer/src/workbuddy/conversation/streaming-footer-source.css', 'utf8')
assert.match(recoveredCss, /width:min\(100%,760px\)/)
assert.match(recoveredCss, /animation:2\.2s linear infinite _cb-shining-text-sweep_13r4w_1/)
assert.match(recoveredCss, /animation:\.32s ease-out forwards _cb-tip-fade-in_13r4w_1/)
assert.match(recoveredCss, /margin-bottom:12px/)
console.log('streaming-footer-regression: persistent stream status, delayed/paused rotation, brand/locale mapping, source CSS, and completed model footer passed')