long-session.bench.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. /** Required browser budgets for opening, paging and continuing synthetic long history. */
  2. import { mkdtemp, rm, writeFile } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { performance } from 'node:perf_hooks'
  6. import { chromium, type Page, type CDPSession, type Locator } from 'playwright'
  7. import { expect, it } from 'vitest'
  8. import { launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from '../../apps/web/tests/scaffold.ts'
  9. import { newEnglishPage } from '../../apps/web/tests/support.ts'
  10. import { ciTimeBudget, PERFORMANCE_BUDGET_HEADROOM } from '../support/calibration.ts'
  11. import { HISTORY_TURNS, SESSION_ID, FIRST, DONE, DELTAS, PACE_MS, syntheticHistory, syntheticReply } from './synthetic-history.ts'
  12. const SAMPLES = 3
  13. const TAIL = '[data-chat-flow-key^="9:turn-tail"]'
  14. const REFERENCE = { open: 200, page: 260, trajectory: 160, first: 1100, streamTask: 1800, input: 500, streamWall: 1000 }
  15. const EXPECTED_OPEN_CI_MS = 900
  16. const EXPECTED_PAGE_CI_MS = 700
  17. const EXPECTED_TRAJECTORY_CI_MS = 500
  18. const OPEN_BUDGET_MS = Math.ceil(EXPECTED_OPEN_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
  19. const PAGE_BUDGET_MS = Math.ceil(EXPECTED_PAGE_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
  20. const TRAJECTORY_BUDGET_MS = Math.ceil(EXPECTED_TRAJECTORY_CI_MS * PERFORMANCE_BUDGET_HEADROOM)
  21. const REPLAY_DURATION_MS = (DELTAS + 4) * PACE_MS
  22. async function painted(page: Page): Promise<void> {
  23. // Two rAF callbacks include a rendering opportunity, not a GPU presentation timestamp.
  24. await page.evaluate(() => new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))))
  25. }
  26. async function measure(page: Page, action: () => Promise<void>): Promise<number> {
  27. const start = performance.now()
  28. await action()
  29. await painted(page)
  30. return performance.now() - start
  31. }
  32. async function taskMs(cdp: CDPSession): Promise<number> {
  33. const result = await cdp.send('Performance.getMetrics')
  34. const metric = result.metrics.find(metric => metric.name === 'TaskDuration')
  35. if (metric === undefined) throw new Error('Chromium TaskDuration missing')
  36. return metric.value * 1000
  37. }
  38. function median(values: number[]): number {
  39. return values.toSorted((a, b) => a - b)[Math.floor(values.length / 2)]!
  40. }
  41. function expectEndpointWithinBudget(value: number, budget: number): void {
  42. expect(value).toBeLessThanOrEqual(budget)
  43. }
  44. function expectInputOverlap(value: boolean): void {
  45. expect(value).toBe(true)
  46. }
  47. async function waitForReplyMarker(page: Page, marker: string, timeout = 30000) {
  48. return page.waitForFunction(({ marker, first, done }) => {
  49. const reply = Array.from(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')).at(-1)
  50. if (!reply) return false
  51. const text = document.createTreeWalker(reply, NodeFilter.SHOW_TEXT)
  52. let node: Node | null
  53. while ((node = text.nextNode())) {
  54. if (!node.textContent?.includes(marker) || !node.parentElement?.checkVisibility({ checkVisibilityCSS: true })) continue
  55. const composer = Array.from(document.querySelectorAll('[data-composer-input][contenteditable="true"]')).at(-1)
  56. const transcript = reply.textContent ?? ''
  57. return { atMs: window.performance.now(), focused: document.activeElement === composer, first: transcript.includes(first), done: transcript.includes(done) }
  58. }
  59. return false
  60. }, { marker, first: FIRST, done: DONE }, { polling: 'raf', timeout })
  61. }
  62. async function watchInputOverlap(composer: Locator): Promise<void> {
  63. await composer.evaluate((element, markers) => {
  64. element.removeAttribute('data-benchmark-input-witness')
  65. element.removeAttribute('data-benchmark-input-overlap')
  66. element.removeAttribute('data-benchmark-input-timing')
  67. element.addEventListener('input', (event) => {
  68. const transcript = Array.from(document.querySelectorAll('[data-chat-flow-kind="assistant-step"]')).at(-1)?.textContent ?? ''
  69. element.setAttribute('data-benchmark-input-overlap', String(event.isTrusted && transcript.includes(markers.first) && !transcript.includes(markers.done)))
  70. element.setAttribute('data-benchmark-input-witness', JSON.stringify({ trusted: event.isTrusted, first: transcript.includes(markers.first), done: transcript.includes(markers.done) }))
  71. element.setAttribute('data-benchmark-input-timing', JSON.stringify({ atMs: window.performance.now(), eventAtMs: event.timeStamp, focused: document.activeElement === element }))
  72. }, { once: true })
  73. }, { first: FIRST, done: DONE })
  74. }
  75. it('accepts recorded hosted open samples and rejects slower endpoints', () => {
  76. for (const value of [681.276514, 541.051233]) {
  77. expect(() => expectEndpointWithinBudget(value, ciTimeBudget(REFERENCE.open))).toThrow()
  78. expectEndpointWithinBudget(value, OPEN_BUDGET_MS)
  79. }
  80. const repeatedMedian = median([875.306861, 1083.683529, 814.700998])
  81. expect(repeatedMedian).toBe(875.306861)
  82. expect(() => expectEndpointWithinBudget(repeatedMedian, ciTimeBudget(REFERENCE.open))).toThrow()
  83. expect(() => expectEndpointWithinBudget(repeatedMedian, 875)).toThrow()
  84. expectEndpointWithinBudget(repeatedMedian, OPEN_BUDGET_MS)
  85. expect(OPEN_BUDGET_MS).toBe(1125)
  86. expect(() => expectEndpointWithinBudget(OPEN_BUDGET_MS + 1, OPEN_BUDGET_MS)).toThrow()
  87. expect(() => expectEndpointWithinBudget(2000, OPEN_BUDGET_MS)).toThrow()
  88. })
  89. it('accepts recorded hosted paging and Trajectory medians and rejects slower endpoints', () => {
  90. const endpoints = [
  91. { samples: [843.941625, 672.834329, 684.461818], reference: REFERENCE.page, budget: PAGE_BUDGET_MS, expectedBudget: 875 },
  92. { samples: [605.788061, 367.754027, 485.931656], reference: REFERENCE.trajectory, budget: TRAJECTORY_BUDGET_MS, expectedBudget: 625 },
  93. ]
  94. for (const { samples, reference, budget, expectedBudget } of endpoints) {
  95. const value = median(samples)
  96. expect(() => expectEndpointWithinBudget(value, ciTimeBudget(reference))).toThrow()
  97. expectEndpointWithinBudget(value, budget)
  98. expect(budget).toBe(expectedBudget)
  99. expect(() => expectEndpointWithinBudget(budget + 1, budget)).toThrow()
  100. }
  101. })
  102. it('waits for visible marker text in the latest Assistant step', async () => {
  103. const browser = await chromium.launch({ headless: true })
  104. try {
  105. const page = await browser.newPage()
  106. await page.setContent(`<div data-chat-flow-kind="assistant-step">${FIRST}</div><div data-chat-flow-kind="assistant-step"><span style="visibility:hidden">${FIRST}</span></div>`)
  107. await expect(waitForReplyMarker(page, FIRST, 100)).rejects.toThrow('Timeout')
  108. await page.locator('span').evaluate(element => { element.style.visibility = 'visible' })
  109. const observation = await waitForReplyMarker(page, FIRST)
  110. expect(await observation.jsonValue()).toMatchObject({ first: true, done: false })
  111. await observation.dispose()
  112. await expect(waitForReplyMarker(page, DONE, 100)).rejects.toThrow('Timeout')
  113. } finally {
  114. await browser.close()
  115. }
  116. })
  117. it('opens, pages, navigates and streams into a 240-turn browser history', async () => {
  118. if (webSnapshotMode() !== 'replay') throw new Error('browser benchmarks require keyless replay mode')
  119. const samples: { open: number; page: number; trajectory: number; first: number; streamTask: number; streamWall: number; input: number; inputOverlapped: boolean; heapMb: number; nodes: number }[] = []
  120. for (let sample = 0; sample < SAMPLES; sample++) {
  121. const failures: unknown[] = []
  122. const root = await mkdtemp(join(tmpdir(), 'dsh-browser-benchmark-'))
  123. try {
  124. const replayOverride = join(root, 'reply.json')
  125. await writeFile(replayOverride, JSON.stringify([{ kind: 'chunks', chunks: syntheticReply() }]))
  126. const scaffold = await launchWebScaffold({ replayFixture: join(root, 'override-only.jsonl'), replayOverride, paceMs: PACE_MS, replayContextWindow: 10000000 })
  127. try {
  128. const history = syntheticHistory()
  129. await seedSession(scaffold, history, SESSION_ID)
  130. console.log(JSON.stringify({ benchmark: 'long-session-browser/fixture', bytes: Buffer.byteLength(history) }))
  131. const browser = await chromium.launch({ headless: true })
  132. try {
  133. const page = await newEnglishPage(browser)
  134. const consoleWatch = watchConsole(page)
  135. page.setDefaultTimeout(30000)
  136. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  137. expect(new URL(page.url()).origin).toBe(scaffold.baseUrl)
  138. console.log(JSON.stringify({ benchmark: 'long-session-browser/server', url: scaffold.baseUrl, browser: browser.version(), sample }))
  139. await page.waitForSelector('[class*="frame"]')
  140. await page.getByRole('treeitem').first().click()
  141. const result = page.getByRole('treeitem').nth(1)
  142. await result.waitFor()
  143. const open = await measure(page, async () => {
  144. await result.click()
  145. await page.locator(TAIL).last().waitFor()
  146. await page.locator('[data-composer-input][contenteditable="true"]').last().waitFor()
  147. })
  148. const pages: number[] = []
  149. const initialTurns = await page.locator(TAIL).count()
  150. expect(initialTurns).toBeGreaterThan(0)
  151. expect(initialTurns).toBeLessThan(HISTORY_TURNS)
  152. let count = initialTurns
  153. while (count < HISTORY_TURNS) {
  154. pages.push(await measure(page, async () => {
  155. await page.getByRole('button', { name: 'Load earlier', exact: true }).click()
  156. await page.waitForFunction(({ selector, previous }) => document.querySelectorAll(selector).length > previous, { selector: TAIL, previous: count })
  157. }))
  158. count = await page.locator(TAIL).count()
  159. }
  160. const trajectory = await measure(page, async () => {
  161. await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
  162. await page.getByRole('searchbox', { name: 'Search trajectory', exact: true }).waitFor()
  163. await page.getByRole('row').last().waitFor()
  164. })
  165. await page.getByRole('tab', { name: 'Chat', exact: true }).click()
  166. await page.waitForFunction(({ selector, expected }) => document.querySelectorAll(selector).length === expected, { selector: TAIL, expected: HISTORY_TURNS })
  167. const composer = page.locator('[data-composer-input][contenteditable="true"]').last()
  168. await composer.fill('Continue the synthetic review and summarize the validation. '.repeat(30))
  169. const cdp = await page.context().newCDPSession(page)
  170. await cdp.send('Performance.enable')
  171. const beforeTask = await taskMs(cdp)
  172. const settled = scaffold.whenTurnSettled(60000).then(
  173. () => ({ ok: true as const }),
  174. (error: unknown) => ({ ok: false as const, error }),
  175. )
  176. await watchInputOverlap(composer)
  177. const started = performance.now()
  178. await page.keyboard.press('Enter')
  179. const firstMarker = await waitForReplyMarker(page, FIRST)
  180. const first = performance.now() - started
  181. // Keep focus across submission; mouse actionability must not delay the input probe.
  182. const input = await measure(page, async () => {
  183. await page.keyboard.type('next synthetic question')
  184. await expect.poll(() => composer.textContent()).toBe('next synthetic question')
  185. })
  186. const inputOverlapped = await composer.getAttribute('data-benchmark-input-overlap') === 'true'
  187. const firstObservation = await firstMarker.jsonValue()
  188. await firstMarker.dispose()
  189. console.log(JSON.stringify({ benchmark: 'long-session-browser/input', sample, first, input, firstObservation, witness: await composer.getAttribute('data-benchmark-input-witness'), inputTiming: await composer.getAttribute('data-benchmark-input-timing') }))
  190. expectInputOverlap(inputOverlapped)
  191. await (await waitForReplyMarker(page, DONE)).dispose()
  192. const settlement = await settled
  193. if (!settlement.ok) throw settlement.error
  194. await page.waitForFunction(({ selector, expected }) => document.querySelectorAll(selector).length === expected, { selector: TAIL, expected: HISTORY_TURNS + 1 })
  195. await painted(page)
  196. const streamWall = performance.now() - started
  197. const streamTask = await taskMs(cdp) - beforeTask
  198. await cdp.send('HeapProfiler.collectGarbage')
  199. const metrics = (await cdp.send('Performance.getMetrics')).metrics
  200. const heap = metrics.find(metric => metric.name === 'JSHeapUsedSize')
  201. if (heap === undefined) throw new Error('Chromium heap metric missing')
  202. samples.push({ open, page: Math.max(...pages), trajectory, first, streamTask, streamWall, input, inputOverlapped, heapMb: heap.value / 1048576, nodes: await page.locator('*').count() })
  203. console.log(JSON.stringify({ benchmark: 'long-session-browser/sample', sample, initialTurns, pages, ...samples.at(-1) }))
  204. await watchInputOverlap(composer)
  205. await composer.click()
  206. await page.keyboard.type('!')
  207. const lateInputOverlapped = await composer.getAttribute('data-benchmark-input-overlap') === 'true'
  208. expect(await composer.getAttribute('data-benchmark-input-witness')).toBe(JSON.stringify({ trusted: true, first: true, done: true }))
  209. expect(() => expectInputOverlap(lateInputOverlapped)).toThrow()
  210. expect(consoleWatch.pageErrors).toEqual([])
  211. expect(consoleWatch.warnings).toEqual([])
  212. } catch (error) { failures.push(error) } finally {
  213. await browser.close().catch((error: unknown) => failures.push(error))
  214. }
  215. } catch (error) { failures.push(error) } finally {
  216. await scaffold.close().catch((error: unknown) => failures.push(error))
  217. }
  218. } catch (error) { failures.push(error) } finally {
  219. await rm(root, { recursive: true, force: true }).catch((error: unknown) => failures.push(error))
  220. }
  221. if (failures.length > 0) throw new AggregateError(failures, 'browser benchmark failed')
  222. }
  223. const aggregate = Object.fromEntries(Object.keys(REFERENCE).map(key => [key, median(samples.map(sample => sample[key as keyof typeof REFERENCE]))]))
  224. const budgets: Record<string, number> = {
  225. ...Object.fromEntries(Object.entries(REFERENCE).map(([key, value]) => [key, ciTimeBudget(value) + (key === 'streamWall' ? REPLAY_DURATION_MS : 0)])),
  226. open: OPEN_BUDGET_MS, page: PAGE_BUDGET_MS, trajectory: TRAJECTORY_BUDGET_MS,
  227. }
  228. console.log(JSON.stringify({ benchmark: 'long-session-browser/median', turns: HISTORY_TURNS, deltas: DELTAS, paceMs: PACE_MS, samples, aggregate, referenceMs: REFERENCE, expectedOpenCiMs: EXPECTED_OPEN_CI_MS, expectedPageCiMs: EXPECTED_PAGE_CI_MS, expectedTrajectoryCiMs: EXPECTED_TRAJECTORY_CI_MS, budgets }))
  229. for (const [key, value] of Object.entries(aggregate)) expectEndpointWithinBudget(value, budgets[key]!)
  230. })