|
|
@@ -0,0 +1,153 @@
|
|
|
+/**
|
|
|
+ * Opt-in browser stress reproduction for reasoning-stream renderer stalls.
|
|
|
+ * The fixture emits 100,000 individual chunks through the normal async
|
|
|
+ * carrier; the test measures event-loop and scheduled-interaction delay while
|
|
|
+ * the assembled React surface keeps a collapsed Think row live.
|
|
|
+ */
|
|
|
+import type { Browser, Page } from 'playwright'
|
|
|
+import { chromium } from 'playwright'
|
|
|
+import { expect, it, onTestFailed } from 'vitest'
|
|
|
+import { launchWebScaffold, watchConsole, type WebScaffold } from '../tests/scaffold.ts'
|
|
|
+import { newEnglishPage, saveFailureShot } from '../tests/support.ts'
|
|
|
+
|
|
|
+const CHUNK_COUNT = 100_000
|
|
|
+const CHUNKS_PER_INTERVAL = 128
|
|
|
+const CHUNK_INTERVAL_MS = 16
|
|
|
+const MAIN_THREAD_DELAY_BUDGET_MS = 250
|
|
|
+
|
|
|
+interface ReasoningChunkStormState {
|
|
|
+ sessionId: string
|
|
|
+ chunkCount: number
|
|
|
+ chunksPerInterval: number
|
|
|
+ intervalMs: number
|
|
|
+ emitted: number
|
|
|
+ marker: string
|
|
|
+ emitting: boolean
|
|
|
+}
|
|
|
+
|
|
|
+interface StressProbe {
|
|
|
+ intervalId: number
|
|
|
+ intervalMs: number
|
|
|
+ lastTickAt: number
|
|
|
+ maxDelayMs: number
|
|
|
+ samples: number
|
|
|
+ interactionDueAt: number
|
|
|
+ interactionHandledAt: number | null
|
|
|
+}
|
|
|
+
|
|
|
+interface StressWindow extends Window {
|
|
|
+ __fxTiming?: {
|
|
|
+ startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string
|
|
|
+ reasoningChunkStormState(): ReasoningChunkStormState | null
|
|
|
+ }
|
|
|
+ __reasoningStressProbe?: StressProbe
|
|
|
+}
|
|
|
+
|
|
|
+it('keeps the browser responsive while rendering 100,000 reasoning chunks', async () => {
|
|
|
+ let scaffold: WebScaffold | undefined
|
|
|
+ let browser: Browser | undefined
|
|
|
+ let page: Page | undefined
|
|
|
+ try {
|
|
|
+ scaffold = await launchWebScaffold()
|
|
|
+ browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== '1' })
|
|
|
+ page = await newEnglishPage(browser)
|
|
|
+ const activePage = page
|
|
|
+ await activePage.addInitScript(() => {
|
|
|
+ localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: 'fx-alpha' }))
|
|
|
+ })
|
|
|
+ const tripwire = watchConsole(activePage)
|
|
|
+ onTestFailed(() => saveFailureShot(activePage, 'web-stress-reasoning-chunks'))
|
|
|
+ await activePage.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
|
|
|
+ await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
|
|
+ // Fixture settings deliberately reject writes, so its welcome notice
|
|
|
+ // cannot acknowledge. Hide only that test overlay; the assembled chat
|
|
|
+ // tree beneath it remains mounted and exercises the production renderer.
|
|
|
+ await activePage.addStyleTag({ content: '[class*="onboardingOverlay"] { display: none !important; }' })
|
|
|
+ await activePage.locator('[data-sample="bash"]').first().waitFor({ timeout: 30_000 })
|
|
|
+
|
|
|
+ await activePage.evaluate(() => {
|
|
|
+ const intervalMs = 50
|
|
|
+ const now = performance.now()
|
|
|
+ const probe: StressProbe = {
|
|
|
+ intervalId: 0,
|
|
|
+ intervalMs,
|
|
|
+ lastTickAt: now,
|
|
|
+ maxDelayMs: 0,
|
|
|
+ samples: 0,
|
|
|
+ interactionDueAt: now + 1_000,
|
|
|
+ interactionHandledAt: null,
|
|
|
+ }
|
|
|
+ probe.intervalId = window.setInterval(() => {
|
|
|
+ const tickAt = performance.now()
|
|
|
+ probe.maxDelayMs = Math.max(probe.maxDelayMs, tickAt - probe.lastTickAt - intervalMs)
|
|
|
+ probe.lastTickAt = tickAt
|
|
|
+ probe.samples++
|
|
|
+ }, intervalMs)
|
|
|
+ document.body.addEventListener('reasoning-stress-interaction', () => {
|
|
|
+ probe.interactionHandledAt = performance.now()
|
|
|
+ }, { once: true })
|
|
|
+ window.setTimeout(() => {
|
|
|
+ document.body.dispatchEvent(new CustomEvent('reasoning-stress-interaction'))
|
|
|
+ }, 1_000)
|
|
|
+ ;(window as StressWindow).__reasoningStressProbe = probe
|
|
|
+ })
|
|
|
+
|
|
|
+ const marker = await activePage.evaluate(({ chunkCount, chunksPerInterval, intervalMs }) => {
|
|
|
+ const hooks = (window as StressWindow).__fxTiming
|
|
|
+ if (hooks === undefined) throw new Error('reasoning stress fixture hooks unavailable')
|
|
|
+ return hooks.startReasoningChunkStorm('fx-alpha', chunkCount, chunksPerInterval, intervalMs)
|
|
|
+ }, {
|
|
|
+ chunkCount: CHUNK_COUNT,
|
|
|
+ chunksPerInterval: CHUNKS_PER_INTERVAL,
|
|
|
+ intervalMs: CHUNK_INTERVAL_MS,
|
|
|
+ })
|
|
|
+
|
|
|
+ const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last()
|
|
|
+ await liveThink.waitFor({ timeout: 60_000 })
|
|
|
+ await expect.poll(async () => await activePage.evaluate(() => {
|
|
|
+ const hooks = (window as StressWindow).__fxTiming
|
|
|
+ return hooks?.reasoningChunkStormState()?.emitted ?? 0
|
|
|
+ }), { timeout: 540_000, interval: 100 }).toBe(CHUNK_COUNT)
|
|
|
+ await expect.poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }).toContain(marker)
|
|
|
+
|
|
|
+ const report = await activePage.evaluate(() => {
|
|
|
+ const win = window as StressWindow
|
|
|
+ const probe = win.__reasoningStressProbe
|
|
|
+ const state = win.__fxTiming?.reasoningChunkStormState()
|
|
|
+ if (probe === undefined || state === undefined || state === null) {
|
|
|
+ throw new Error('reasoning stress metrics unavailable')
|
|
|
+ }
|
|
|
+ window.clearInterval(probe.intervalId)
|
|
|
+ const interactionDelayMs = probe.interactionHandledAt === null
|
|
|
+ ? null
|
|
|
+ : probe.interactionHandledAt - probe.interactionDueAt
|
|
|
+ return {
|
|
|
+ chunkCount: state.chunkCount,
|
|
|
+ chunksPerInterval: state.chunksPerInterval,
|
|
|
+ intervalMs: state.intervalMs,
|
|
|
+ emitted: state.emitted,
|
|
|
+ maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs),
|
|
|
+ interactionDelayMs,
|
|
|
+ heartbeatSamples: probe.samples,
|
|
|
+ }
|
|
|
+ })
|
|
|
+ process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`)
|
|
|
+
|
|
|
+ expect(report).toMatchObject({
|
|
|
+ chunkCount: CHUNK_COUNT,
|
|
|
+ chunksPerInterval: CHUNKS_PER_INTERVAL,
|
|
|
+ intervalMs: CHUNK_INTERVAL_MS,
|
|
|
+ emitted: CHUNK_COUNT,
|
|
|
+ })
|
|
|
+ expect(report.heartbeatSamples).toBeGreaterThan(0)
|
|
|
+ const interactionDelayMs = report.interactionDelayMs
|
|
|
+ if (interactionDelayMs === null) throw new Error(`scheduled interaction was not handled: ${JSON.stringify(report)}`)
|
|
|
+ expect(report.maxMainThreadDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
|
|
|
+ expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
|
|
|
+ expect(tripwire.pageErrors).toEqual([])
|
|
|
+ expect(tripwire.warnings).toEqual([])
|
|
|
+ } finally {
|
|
|
+ await browser?.close()
|
|
|
+ await scaffold?.close()
|
|
|
+ }
|
|
|
+}, 600_000)
|