reasoning-chunks.stress.ts 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153
  1. /**
  2. * Opt-in browser stress reproduction for reasoning-stream renderer stalls.
  3. * The fixture emits 100,000 individual chunks through the normal async
  4. * carrier; the test measures event-loop and scheduled-interaction delay while
  5. * the assembled React surface keeps a collapsed Think row live.
  6. */
  7. import type { Browser, Page } from 'playwright'
  8. import { chromium } from 'playwright'
  9. import { expect, it, onTestFailed } from 'vitest'
  10. import { launchWebScaffold, watchConsole, type WebScaffold } from '../tests/scaffold.ts'
  11. import { newEnglishPage, saveFailureShot } from '../tests/support.ts'
  12. const CHUNK_COUNT = 100_000
  13. const CHUNKS_PER_INTERVAL = 128
  14. const CHUNK_INTERVAL_MS = 16
  15. const MAIN_THREAD_DELAY_BUDGET_MS = 250
  16. interface ReasoningChunkStormState {
  17. sessionId: string
  18. chunkCount: number
  19. chunksPerInterval: number
  20. intervalMs: number
  21. emitted: number
  22. marker: string
  23. emitting: boolean
  24. }
  25. interface StressProbe {
  26. intervalId: number
  27. intervalMs: number
  28. lastTickAt: number
  29. maxDelayMs: number
  30. samples: number
  31. interactionDueAt: number
  32. interactionHandledAt: number | null
  33. }
  34. interface StressWindow extends Window {
  35. __fxTiming?: {
  36. startReasoningChunkStorm(id: string, chunkCount: number, chunksPerInterval: number, intervalMs: number): string
  37. reasoningChunkStormState(): ReasoningChunkStormState | null
  38. }
  39. __reasoningStressProbe?: StressProbe
  40. }
  41. it('keeps the browser responsive while rendering 100,000 reasoning chunks', async () => {
  42. let scaffold: WebScaffold | undefined
  43. let browser: Browser | undefined
  44. let page: Page | undefined
  45. try {
  46. scaffold = await launchWebScaffold()
  47. browser = await chromium.launch({ headless: process.env.DSH_WEB_STRESS_HEADFUL !== '1' })
  48. page = await newEnglishPage(browser)
  49. const activePage = page
  50. await activePage.addInitScript(() => {
  51. localStorage.setItem('dsh.sessions.current', JSON.stringify({ sessionId: 'fx-alpha' }))
  52. })
  53. const tripwire = watchConsole(activePage)
  54. onTestFailed(() => saveFailureShot(activePage, 'web-stress-reasoning-chunks'))
  55. await activePage.goto(`${scaffold.baseUrl}?fixture`, { waitUntil: 'load' })
  56. await activePage.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  57. // Fixture settings deliberately reject writes, so its welcome notice
  58. // cannot acknowledge. Hide only that test overlay; the assembled chat
  59. // tree beneath it remains mounted and exercises the production renderer.
  60. await activePage.addStyleTag({ content: '[class*="onboardingOverlay"] { display: none !important; }' })
  61. await activePage.locator('[data-sample="bash"]').first().waitFor({ timeout: 30_000 })
  62. await activePage.evaluate(() => {
  63. const intervalMs = 50
  64. const now = performance.now()
  65. const probe: StressProbe = {
  66. intervalId: 0,
  67. intervalMs,
  68. lastTickAt: now,
  69. maxDelayMs: 0,
  70. samples: 0,
  71. interactionDueAt: now + 1_000,
  72. interactionHandledAt: null,
  73. }
  74. probe.intervalId = window.setInterval(() => {
  75. const tickAt = performance.now()
  76. probe.maxDelayMs = Math.max(probe.maxDelayMs, tickAt - probe.lastTickAt - intervalMs)
  77. probe.lastTickAt = tickAt
  78. probe.samples++
  79. }, intervalMs)
  80. document.body.addEventListener('reasoning-stress-interaction', () => {
  81. probe.interactionHandledAt = performance.now()
  82. }, { once: true })
  83. window.setTimeout(() => {
  84. document.body.dispatchEvent(new CustomEvent('reasoning-stress-interaction'))
  85. }, 1_000)
  86. ;(window as StressWindow).__reasoningStressProbe = probe
  87. })
  88. const marker = await activePage.evaluate(({ chunkCount, chunksPerInterval, intervalMs }) => {
  89. const hooks = (window as StressWindow).__fxTiming
  90. if (hooks === undefined) throw new Error('reasoning stress fixture hooks unavailable')
  91. return hooks.startReasoningChunkStorm('fx-alpha', chunkCount, chunksPerInterval, intervalMs)
  92. }, {
  93. chunkCount: CHUNK_COUNT,
  94. chunksPerInterval: CHUNKS_PER_INTERVAL,
  95. intervalMs: CHUNK_INTERVAL_MS,
  96. })
  97. const liveThink = activePage.locator('[data-variant="think"][data-state="running"]').last()
  98. await liveThink.waitFor({ timeout: 60_000 })
  99. await expect.poll(async () => await activePage.evaluate(() => {
  100. const hooks = (window as StressWindow).__fxTiming
  101. return hooks?.reasoningChunkStormState()?.emitted ?? 0
  102. }), { timeout: 540_000, interval: 100 }).toBe(CHUNK_COUNT)
  103. await expect.poll(() => liveThink.textContent(), { timeout: 60_000, interval: 100 }).toContain(marker)
  104. const report = await activePage.evaluate(() => {
  105. const win = window as StressWindow
  106. const probe = win.__reasoningStressProbe
  107. const state = win.__fxTiming?.reasoningChunkStormState()
  108. if (probe === undefined || state === undefined || state === null) {
  109. throw new Error('reasoning stress metrics unavailable')
  110. }
  111. window.clearInterval(probe.intervalId)
  112. const interactionDelayMs = probe.interactionHandledAt === null
  113. ? null
  114. : probe.interactionHandledAt - probe.interactionDueAt
  115. return {
  116. chunkCount: state.chunkCount,
  117. chunksPerInterval: state.chunksPerInterval,
  118. intervalMs: state.intervalMs,
  119. emitted: state.emitted,
  120. maxMainThreadDelayMs: Math.max(0, probe.maxDelayMs),
  121. interactionDelayMs,
  122. heartbeatSamples: probe.samples,
  123. }
  124. })
  125. process.stdout.write(`reasoning-chunk stress report: ${JSON.stringify(report)}\n`)
  126. expect(report).toMatchObject({
  127. chunkCount: CHUNK_COUNT,
  128. chunksPerInterval: CHUNKS_PER_INTERVAL,
  129. intervalMs: CHUNK_INTERVAL_MS,
  130. emitted: CHUNK_COUNT,
  131. })
  132. expect(report.heartbeatSamples).toBeGreaterThan(0)
  133. const interactionDelayMs = report.interactionDelayMs
  134. if (interactionDelayMs === null) throw new Error(`scheduled interaction was not handled: ${JSON.stringify(report)}`)
  135. expect(report.maxMainThreadDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
  136. expect(interactionDelayMs, JSON.stringify(report)).toBeLessThan(MAIN_THREAD_DELAY_BUDGET_MS)
  137. expect(tripwire.pageErrors).toEqual([])
  138. expect(tripwire.warnings).toEqual([])
  139. } finally {
  140. await browser?.close()
  141. await scaffold?.close()
  142. }
  143. }, 600_000)