streaming-fence-highlight.e2e.ts 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. /** Keyless assembled-Web evidence for syntax highlighting during a streamed code fence. */
  2. import { fileURLToPath } from 'node:url'
  3. import type { Browser, Page } from 'playwright'
  4. import { chromium } from 'playwright'
  5. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  6. import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
  7. import { LlmAdapter } from '@deepseek-ai/dsh-llm'
  8. import type {} from '@deepseek-ai/dsh-agent-default-model'
  9. import {
  10. assertFixtureInventory,
  11. captureStableAria,
  12. compareOrRefreshGolden,
  13. launchWebScaffold,
  14. watchConsole,
  15. webSnapshotMode,
  16. type WebScaffold,
  17. } from './scaffold.ts'
  18. import { connectFreshWorkspace, newEnglishPage, saveFailureShot, writeComposerDraft } from './support.ts'
  19. const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/streaming-fence-highlight', import.meta.url))
  20. const MID_EXPECTED = fileURLToPath(new URL('./snapshots/streaming-fence-highlight/mid-stream.expected.md', import.meta.url))
  21. const MODE = webSnapshotMode()
  22. const PROVIDER = 'streaming-fence-highlight-test'
  23. const MODEL = 'streaming-fence'
  24. const PROMPT = 'Stream one TypeScript fence for the highlighting snapshot.'
  25. const FIRST_REPLY = '```ts\nconst first: number = 1\n'
  26. const OPEN_REPLY = `${FIRST_REPLY}const second = "two"\nlet tail`
  27. const REPLY = `${OPEN_REPLY}\n\`\`\``
  28. /** Deterministic model response held after each visible fence-growth frame. */
  29. class StreamingFenceAdapter extends LlmAdapter {
  30. private resolveFirstPaused!: () => void
  31. private resolveFirstContinuation!: () => void
  32. private resolveSecondPaused!: () => void
  33. private resolveSecondContinuation!: () => void
  34. private firstContinued = false
  35. private secondContinued = false
  36. readonly firstPaused = new Promise<void>((resolve) => { this.resolveFirstPaused = resolve })
  37. readonly secondPaused = new Promise<void>((resolve) => { this.resolveSecondPaused = resolve })
  38. private readonly firstContinuation = new Promise<void>((resolve) => { this.resolveFirstContinuation = resolve })
  39. private readonly secondContinuation = new Promise<void>((resolve) => { this.resolveSecondContinuation = resolve })
  40. grow(): void {
  41. if (this.firstContinued) return
  42. this.firstContinued = true
  43. this.resolveFirstContinuation()
  44. }
  45. finish(): void {
  46. if (this.secondContinued) return
  47. this.secondContinued = true
  48. this.resolveSecondContinuation()
  49. }
  50. continue(): void {
  51. this.grow()
  52. this.finish()
  53. }
  54. override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
  55. yield { type: 'block-start', index: 0, blockType: 'text' }
  56. yield { type: 'text-delta', index: 0, text: FIRST_REPLY }
  57. this.resolveFirstPaused()
  58. await this.firstContinuation
  59. yield { type: 'text-delta', index: 0, text: OPEN_REPLY.slice(FIRST_REPLY.length) }
  60. this.resolveSecondPaused()
  61. await this.secondContinuation
  62. if (options.signal?.aborted === true) throw options.signal.reason
  63. yield { type: 'text-delta', index: 0, text: '\n```' }
  64. yield { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }
  65. yield { type: 'finish', reason: { kind: 'stop' } }
  66. }
  67. }
  68. interface FenceTree {
  69. language: string
  70. pre: { className: string; style: string | null; tabIndex: string | null }
  71. lines: { text: string; style: string | null }[][]
  72. }
  73. /** Read the stable, user-visible subset of one rendered code fence. */
  74. async function fenceTree(block: ReturnType<Page['locator']>): Promise<FenceTree> {
  75. return await block.evaluate((element) => {
  76. const pre = element.querySelector<HTMLPreElement>('pre.shiki')
  77. if (pre === null) throw new Error('streaming fence did not render through the shiki arm')
  78. return {
  79. language: element.querySelector('[class*="infostring"]')?.textContent ?? '',
  80. pre: {
  81. className: pre.className,
  82. style: pre.style.cssText,
  83. tabIndex: pre.getAttribute('tabindex'),
  84. },
  85. lines: [...pre.querySelectorAll('.line')].map(line =>
  86. [...line.querySelectorAll('span')].map(span => ({
  87. text: span.textContent ?? '',
  88. style: span.style.cssText,
  89. })),
  90. ),
  91. }
  92. })
  93. }
  94. describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting', () => {
  95. let scaffold: WebScaffold
  96. let browser: Browser
  97. let page: Page
  98. let tripwire: ReturnType<typeof watchConsole>
  99. const adapter = new StreamingFenceAdapter()
  100. beforeAll(async () => {
  101. scaffold = await launchWebScaffold()
  102. scaffold.ctx.effect(
  103. () => scaffold.ctx.llm.registerAdapter([PROVIDER], adapter),
  104. 'streaming fence highlight adapter',
  105. )
  106. await scaffold.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL })
  107. browser = await chromium.launch()
  108. page = await newEnglishPage(browser)
  109. tripwire = watchConsole(page)
  110. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  111. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  112. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  113. }, 120_000)
  114. afterAll(async () => {
  115. adapter.continue()
  116. await browser?.close()
  117. await scaffold?.close()
  118. })
  119. it('renders the growing fence through shiki and preserves its token tree when the turn settles', async () => {
  120. onTestFailed(() => saveFailureShot(page, 'web-e2e-streaming-fence-highlight'))
  121. const input = page.locator('[data-composer-input]').first()
  122. const settled = scaffold.whenTurnSettled(30_000)
  123. await writeComposerDraft(page, input, PROMPT)
  124. await input.press('Enter')
  125. await adapter.firstPaused
  126. const streaming = page.locator('[data-streaming="true"]')
  127. await streaming.waitFor({ timeout: 10_000 })
  128. const block = streaming.locator('.md-code-block').filter({ hasText: 'const first' })
  129. await block.locator('pre.shiki span[style]').first().waitFor({ timeout: 10_000 })
  130. await block.evaluate((element) => {
  131. element.setAttribute('data-stream-block-retained', 'true')
  132. element.querySelector('pre.shiki')?.setAttribute('data-stream-pre-retained', 'true')
  133. element.querySelector('pre.shiki .line')?.setAttribute('data-stream-line-retained', 'true')
  134. })
  135. adapter.grow()
  136. await adapter.secondPaused
  137. await expect.poll(() => block.locator('pre.shiki .line').count()).toBe(3)
  138. expect(await block.evaluate(element => ({
  139. block: element.getAttribute('data-stream-block-retained'),
  140. pre: element.querySelector('pre.shiki')?.getAttribute('data-stream-pre-retained'),
  141. line: element.querySelector('pre.shiki .line')?.getAttribute('data-stream-line-retained'),
  142. }))).toEqual({ block: 'true', pre: 'true', line: 'true' })
  143. const midTree = await fenceTree(block)
  144. expect(midTree.language).toBe('ts')
  145. expect(midTree.lines).toHaveLength(3)
  146. expect(midTree.lines.flat().map(span => span.style)).toContain('color: var(--shiki-token-keyword);')
  147. const aria = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
  148. await compareOrRefreshGolden(
  149. MID_EXPECTED,
  150. `${aria}\n\n---\n\n${JSON.stringify(midTree, null, 2)}`,
  151. MODE,
  152. )
  153. adapter.finish()
  154. await settled
  155. await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
  156. const settledBlock = page.locator('.md-code-block').filter({ hasText: 'const first' })
  157. await settledBlock.locator('pre.shiki').waitFor({ timeout: 10_000 })
  158. expect(await fenceTree(settledBlock)).toEqual(midTree)
  159. expect(await settledBlock.evaluate(element => ({
  160. block: element.getAttribute('data-stream-block-retained'),
  161. pre: element.querySelector('pre.shiki')?.getAttribute('data-stream-pre-retained'),
  162. line: element.querySelector('pre.shiki .line')?.getAttribute('data-stream-line-retained'),
  163. }))).toEqual({ block: 'true', pre: 'true', line: 'true' })
  164. expect(tripwire.pageErrors).toEqual([])
  165. expect(tripwire.warnings).toEqual([])
  166. await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md'])
  167. }, 60_000)
  168. })