thinking-markdown.e2e.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296
  1. /** Expanded reasoning keeps Markdown semantics at the secondary typography tier. */
  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 { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  7. import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
  8. import type {} from '@deepseek-ai/dsh-session-title'
  9. import {
  10. assertFixtureInventory,
  11. captureStableAria,
  12. compareOrRefreshGolden,
  13. launchWebScaffold,
  14. seedSession,
  15. watchConsole,
  16. webSnapshotMode,
  17. type WebScaffold,
  18. } from './scaffold.ts'
  19. import { expandTurnProcesses, newEnglishPage, saveFailureShot } from './support.ts'
  20. const EXPECTED_DIR = fileURLToPath(new URL('./expected/thinking-markdown', import.meta.url))
  21. const UI_EXPECTED = fileURLToPath(new URL('./expected/thinking-markdown/ui.expected.md', import.meta.url))
  22. const MODE = webSnapshotMode()
  23. const SEED_ID = 'thinking-markdown-web-e2e'
  24. const DONE = 'THINKING_MARKDOWN_DONE'
  25. const LONG_TOKEN = 'workspace/packages/client/secondary-markdown/'.repeat(12)
  26. /** Closed Session using semantic Markdown and content wider than the message column. */
  27. function thinkingFixture(): string {
  28. const session = Session.create(SessionId('thinking-markdown-source'))
  29. session.append('turn/start', { turn: 1 })
  30. const user = session.append('user/message', createUserMessage({
  31. content: [{ type: 'text', text: 'Show reasoning and a main answer.' }],
  32. source: { kind: 'user' },
  33. }), { surfaceOp: 'append' })
  34. session.append('session/title', {
  35. title: 'Thinking Markdown', messageSeqs: [user.seq], source: { kind: 'fallback' },
  36. })
  37. session.append('step/start', { turn: 1, step: 1 })
  38. session.append('assistant/message', {
  39. stream: [], turn: 1, step: 1,
  40. message: createMessage({
  41. role: 'assistant',
  42. source: { kind: 'model', provider: 'fixture', model: 'fixture' },
  43. content: [{
  44. type: 'reasoning',
  45. text: [
  46. `## Compact reasoning ${'with a deliberately long summary '.repeat(8)}`,
  47. '',
  48. 'A paragraph with **strong text**, *emphasis*, [reference](https://example.com/), and `inline_code`.',
  49. '',
  50. ...[1, 2, 3, 4, 5, 6].flatMap(level => [`${'#'.repeat(level)} Level ${String(level)}`, '']),
  51. '- Unordered item',
  52. '- Second item',
  53. '- Inline list formula: $x_j$.',
  54. `- Long list atom: $\\underbrace{${'a'.repeat(240)}}_{long}$.`,
  55. '',
  56. '1. Ordered item',
  57. '2. Another item',
  58. '',
  59. '- Loose first paragraph.',
  60. '',
  61. ' Loose middle paragraph.',
  62. '',
  63. ' Loose last paragraph.',
  64. '',
  65. '> Quoted reasoning.',
  66. '',
  67. '---',
  68. '',
  69. '| First | Second | Third | Fourth | Fifth | Sixth |',
  70. '| --- | --- | --- | --- | --- | --- |',
  71. `| ${Array.from({ length: 6 }, (_, index) => `long_table_cell_${String(index)}_${'x'.repeat(40)}`).join(' | ')} |`,
  72. ...Array.from({ length: 20 }, (_, row) => `| ${Array.from({ length: 6 }, (_, column) => `Row ${String(row + 1)} column ${String(column + 1)}`).join(' | ')} |`),
  73. '',
  74. 'Short subscript: $x_j$.',
  75. '',
  76. 'Short scripts: $x_i^2$.',
  77. '',
  78. `Long atom: $\\underbrace{${'a'.repeat(240)}}_{long}$.`,
  79. '',
  80. `Inline math: $${'a+'.repeat(80)}z$.`,
  81. '',
  82. '$$',
  83. `${'a+'.repeat(80)}\\frac{1}{1+\\frac{1}{x}}`,
  84. '$$',
  85. '',
  86. '```typescript',
  87. 'const value = "reasoning code"',
  88. '```',
  89. '',
  90. LONG_TOKEN,
  91. ].join('\n'),
  92. }, { type: 'text', text: `# Main answer\n\n${DONE}` }],
  93. }),
  94. }, { surfaceOp: 'append' })
  95. session.append('step/end', { turn: 1, step: 1 })
  96. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  97. return [JSON.stringify({
  98. type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}',
  99. createdAt: 0, cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
  100. }), ...session.snapshotEvents().map(({ seq, time: _time, ...event }) => JSON.stringify({
  101. ...event, seq, time: new Date().setHours(12, 0, 0, 0) + seq * 1_000,
  102. })), ''].join('\n')
  103. }
  104. describe('web e2e: secondary Thinking Markdown', () => {
  105. let scaffold: WebScaffold
  106. let browser: Browser
  107. let page: Page
  108. let tripwire: ReturnType<typeof watchConsole>
  109. beforeAll(async () => {
  110. scaffold = await launchWebScaffold({})
  111. await seedSession(scaffold, thinkingFixture(), SEED_ID)
  112. browser = await chromium.launch({ ignoreDefaultArgs: ['--hide-scrollbars'] })
  113. page = await newEnglishPage(browser)
  114. tripwire = watchConsole(page)
  115. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  116. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  117. await page.locator('[role="treeitem"]').first().click()
  118. await page.locator('[role="treeitem"]').nth(1).click()
  119. await page.getByText(DONE, { exact: true }).waitFor({ timeout: 15_000 })
  120. await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
  121. await page.waitForFunction(() => {
  122. const frame = document.querySelector('[data-sidebar-collapsed]')
  123. return frame !== null && getComputedStyle(frame).gridTemplateColumns.split(' ')[0] === '56px'
  124. && frame.getAnimations().every(animation => animation.playState === 'finished' || animation.playState === 'idle')
  125. }, undefined, { timeout: 10_000 })
  126. await expandTurnProcesses(page)
  127. })
  128. afterAll(async () => {
  129. await browser?.close()
  130. await scaffold?.close()
  131. })
  132. it.skipIf(MODE === 'record')('renders semantic blocks without promoting headings or overflowing the column', async () => {
  133. onTestFailed(() => saveFailureShot(page, 'web-e2e-thinking-markdown'))
  134. const thinking = page.locator('[data-variant="think"]')
  135. const toggle = thinking.getByRole('button').first()
  136. const summary = thinking.locator('[class*="summaryText"]')
  137. const summaryStyle = await summary.evaluate((element) => {
  138. const style = getComputedStyle(element)
  139. return { fontSize: style.fontSize, lineHeight: style.lineHeight, color: style.color }
  140. })
  141. expect(await summary.evaluate(element => element.getBoundingClientRect().height))
  142. .toBeLessThanOrEqual(Number.parseFloat(summaryStyle.lineHeight) + 1)
  143. await toggle.click()
  144. const markdown = thinking.locator('[data-markdown-variant="compact"]')
  145. await markdown.locator('h1').waitFor({ timeout: 10_000 })
  146. expect(await markdown.locator('h1,h2,h3,h4,h5,h6').count()).toBe(7)
  147. expect(await markdown.locator('ul').count()).toBe(2)
  148. expect(await markdown.locator('ol').count()).toBe(1)
  149. expect(await markdown.locator('strong').textContent()).toBe('strong text')
  150. expect(await markdown.locator('em').textContent()).toBe('emphasis')
  151. expect(await markdown.getByRole('link', { name: 'reference' }).getAttribute('href')).toBe('https://example.com/')
  152. expect(await markdown.locator('pre code').textContent()).toContain('const value = "reasoning code"')
  153. expect(await markdown.locator('hr').count()).toBe(1)
  154. expect(await markdown.locator('table').count()).toBe(1)
  155. expect(await markdown.locator('.katex').count()).toBe(7)
  156. await markdown.locator('pre').scrollIntoViewIfNeeded()
  157. await expect.poll(() => markdown.locator('pre.shiki').count(), { timeout: 15_000 }).toBe(1)
  158. const snapshot = (await captureStableAria(page, '[data-variant="think"][data-expanded]', scaffold.workspaceCwd))
  159. .split(LONG_TOKEN).join('{{longToken}}')
  160. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  161. for (const width of [1680, 640]) {
  162. await page.setViewportSize({ width, height: 1000 })
  163. await page.evaluate(async () => { await document.fonts.ready })
  164. const styles = await markdown.evaluate((root, secondary) => {
  165. const elements = [...root.querySelectorAll<HTMLElement>('h1,h2,h3,h4,h5,h6,p,li,a,strong,em,pre,pre code,pre span,th,td,.katex')]
  166. const scrollers = [...root.querySelectorAll<HTMLElement>('[class*="tableScroll"],.katex-display,p:has(.katex),ul:has(.katex)')]
  167. const shortFormulas = [...root.querySelectorAll('p')].filter(element => element.textContent?.startsWith('Short '))
  168. // Native KaTeX baselines vary with host fonts; compact adds one pixel for descender ink.
  169. const shortMathNaturalHeight = shortFormulas.map((paragraph) => {
  170. const native = paragraph.cloneNode(true) as HTMLParagraphElement
  171. native.style.font = getComputedStyle(paragraph).font
  172. native.style.position = 'absolute'
  173. native.style.width = `${String(paragraph.clientWidth)}px`
  174. for (const formula of native.querySelectorAll<HTMLElement>('.katex')) formula.style.fontSize = secondary.fontSize
  175. document.body.appendChild(native)
  176. const height = native.getBoundingClientRect().height
  177. native.remove()
  178. return paragraph.getBoundingClientRect().height <= height + 1
  179. })
  180. const longAtom = [...root.querySelectorAll('p')].find(element => element.textContent?.startsWith('Long atom:'))
  181. const mathList = root.querySelector('ul:has(.katex)')
  182. const mathListItem = mathList?.querySelector('li')
  183. const banner = root.querySelector('[data-code-block-banner]')?.parentElement
  184. const loose = [...root.querySelectorAll('li p')].find(element => element.textContent === 'Loose middle paragraph.')
  185. return {
  186. secondarySize: elements.every(element => getComputedStyle(element).fontSize === secondary.fontSize),
  187. secondaryLine: elements.filter(element => !element.classList.contains('katex'))
  188. .every(element => getComputedStyle(element).lineHeight === secondary.lineHeight),
  189. tertiaryColor: elements.every(element => getComputedStyle(element).color === secondary.color),
  190. tertiaryMarkers: [...root.querySelectorAll('li')]
  191. .every(element => getComputedStyle(element, '::marker').color === secondary.color),
  192. contained: root.scrollWidth <= root.clientWidth + 1,
  193. bannerStatic: banner !== undefined && banner !== null && getComputedStyle(banner).position === 'static',
  194. looseSpacing: loose !== undefined && getComputedStyle(loose).marginTop === '4px' && getComputedStyle(loose).marginBottom === '4px',
  195. wideContentBounded: scrollers.length === 7 && scrollers.every(element => element.clientWidth <= root.clientWidth + 1),
  196. mathListMarkerSpace: mathList !== null && mathListItem !== null && mathListItem !== undefined
  197. && mathListItem.getBoundingClientRect().left - mathList.getBoundingClientRect().left >= Number.parseFloat(secondary.fontSize),
  198. shortMathNaturalHeight: shortMathNaturalHeight.length === 2 && shortMathNaturalHeight.every(Boolean),
  199. shortMathNoScrollbar: shortFormulas.every(paragraph => [paragraph, ...paragraph.querySelectorAll<HTMLElement>('span')].every((element) => {
  200. const style = getComputedStyle(element)
  201. const scrollsX = style.overflowX === 'auto' || style.overflowX === 'scroll'
  202. const scrollsY = style.overflowY === 'auto' || style.overflowY === 'scroll'
  203. return (!scrollsX || element.scrollWidth <= element.clientWidth)
  204. && (!scrollsY || element.scrollHeight <= element.clientHeight)
  205. })),
  206. longAtomScrolls: longAtom !== undefined && longAtom.scrollWidth > longAtom.clientWidth,
  207. displayMathScrolls: [...root.querySelectorAll<HTMLElement>('.katex-display')]
  208. .every(element => element.scrollWidth > element.clientWidth),
  209. }
  210. }, summaryStyle)
  211. expect(styles, `Thinking typography at ${String(width)}px`).toEqual({
  212. secondarySize: true, secondaryLine: true, tertiaryColor: true, tertiaryMarkers: true,
  213. contained: true, bannerStatic: true, looseSpacing: true,
  214. wideContentBounded: true, displayMathScrolls: true,
  215. shortMathNaturalHeight: true, shortMathNoScrollbar: true, longAtomScrolls: true, mathListMarkerSpace: true,
  216. })
  217. }
  218. const answerSize = await page.getByRole('heading', { name: 'Main answer', exact: true })
  219. .evaluate(element => Number.parseFloat(getComputedStyle(element).fontSize))
  220. expect(answerSize).toBeGreaterThan(Number.parseFloat(summaryStyle.fontSize))
  221. await page.setViewportSize({ width: 1680, height: 1000 })
  222. await page.locator('[data-conversation-scroll]').evaluate((host) => {
  223. const row = host.querySelector('[data-variant="think"] tbody tr:nth-child(12)')
  224. if (row === null) throw new Error('tall Thinking table row missing')
  225. host.scrollTop += row.getBoundingClientRect().top - host.getBoundingClientRect().top
  226. })
  227. await expect.poll(() => toggle.evaluate((button) => {
  228. const host = button.closest('[data-conversation-scroll]')
  229. const table = button.closest('[data-variant="think"]')?.querySelector('table')
  230. if (host === null || table === null || table === undefined) throw new Error('Thinking scroll context missing')
  231. const buttonRect = button.getBoundingClientRect()
  232. const tableRect = table.getBoundingClientRect()
  233. const x = buttonRect.left + buttonRect.width / 2
  234. const y = buttonRect.top + buttonRect.height / 2
  235. return {
  236. pinned: Math.abs(buttonRect.top - host.getBoundingClientRect().top) <= 1,
  237. tableUnderHeader: tableRect.top < y && tableRect.bottom > y,
  238. headerReceivesPointer: button.contains(document.elementFromPoint(x, y)),
  239. }
  240. }), { timeout: 5_000 }).toEqual({ pinned: true, tableUnderHeader: true, headerReceivesPointer: true })
  241. await toggle.click()
  242. expect(await thinking.getAttribute('data-expanded')).toBeNull()
  243. expect(await summary.evaluate(element => element.getBoundingClientRect().height))
  244. .toBeLessThanOrEqual(Number.parseFloat(summaryStyle.lineHeight) + 1)
  245. expect(await summary.evaluate(element => getComputedStyle(element).textOverflow)).toBe('ellipsis')
  246. await page.setViewportSize({ width: 1680, height: 1000 })
  247. await page.getByRole('tab', { name: 'Trajectory', exact: true }).click()
  248. await page.locator('tr[data-trajectory-row-key]', { hasText: DONE }).click()
  249. const details = page.getByRole('tabpanel')
  250. const trajectoryToggle = details.getByRole('button', { name: 'Thinking', exact: true })
  251. if (await trajectoryToggle.getAttribute('aria-expanded') !== 'true') await trajectoryToggle.click()
  252. const trajectoryHeading = details.locator('[data-markdown-variant="compact"] h1')
  253. await trajectoryHeading.waitFor({ timeout: 10_000 })
  254. expect(await trajectoryHeading.evaluate(element => getComputedStyle(element).fontSize)).toBe(summaryStyle.fontSize)
  255. const originalFontSize = await page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))
  256. try {
  257. for (const fontSize of [16, 17]) {
  258. await scaffold.ctx.settings.update('ui-theme', { fontSize })
  259. await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')))
  260. .toBe(`${String(fontSize)}px`)
  261. const typography = await details.evaluate((panel) => {
  262. const heading = panel.querySelector('[data-markdown-variant="compact"] h1')
  263. const paragraph = panel.querySelector('[data-markdown-variant="compact"] p')
  264. const answer = panel.querySelector('[class*="assistantOutput"] p')
  265. if (heading === null || paragraph === null || answer === null) throw new Error('Trajectory prose missing')
  266. const headingStyle = getComputedStyle(heading)
  267. const paragraphStyle = getComputedStyle(paragraph)
  268. return {
  269. headingSize: headingStyle.fontSize,
  270. paragraphSize: paragraphStyle.fontSize,
  271. lineHeight: paragraphStyle.lineHeight,
  272. noLargerThanAnswer: Number.parseFloat(paragraphStyle.fontSize) <= Number.parseFloat(getComputedStyle(answer).fontSize),
  273. }
  274. })
  275. expect(typography, `Trajectory with ${String(fontSize)}px content setting`).toEqual({
  276. headingSize: '13px', paragraphSize: '13px', lineHeight: '20px', noLargerThanAnswer: true,
  277. })
  278. }
  279. } finally {
  280. await scaffold.ctx.settings.update('ui-theme', { fontSize: Number.parseFloat(originalFontSize) })
  281. await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')))
  282. .toBe(originalFontSize)
  283. }
  284. expect(tripwire.pageErrors).toEqual([])
  285. expect(tripwire.warnings).toEqual([])
  286. await assertFixtureInventory(EXPECTED_DIR, ['ui.expected.md'])
  287. })
  288. })