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 class FakeScheduler implements LoadingTipScheduler { private now = 0 private nextId = 0 private timers = new Map 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() assert.match(emptyStream, /wb-cr-streaming-footer/) assert.match(emptyStream, /思考中/) assert.doesNotMatch(emptyStream, /bubble__cursor/) const bodyStream = renderToStaticMarkup() 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() 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')