markdown-images.e2e.ts 7.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. // Web e2e scenario: absolute HTTP(S) Markdown images. A validated session
  2. // assembled through the Session API is seeded cold into the real web
  3. // composition, then a separate image origin proves that the browser receives
  4. // a real network image while local-path Markdown remains inert alt text.
  5. import { createServer, type Server } from 'node:http'
  6. import { fileURLToPath } from 'node:url'
  7. import type { Browser, Page } from 'playwright'
  8. import { chromium } from 'playwright'
  9. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  10. import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  11. import {
  12. SESSION_FORMAT_VERSION,
  13. Session,
  14. SessionId,
  15. } from '@deepseek-ai/dsh-session'
  16. import type {} from '@deepseek-ai/dsh-session-title'
  17. import {
  18. assertFixtureInventory,
  19. captureStableAria,
  20. compareOrRefreshGolden,
  21. launchWebScaffold,
  22. seedSession,
  23. watchConsole,
  24. webSnapshotMode,
  25. type WebScaffold,
  26. } from './scaffold.ts'
  27. import { newEnglishPage, saveFailureShot } from './support.ts'
  28. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-images', import.meta.url))
  29. const UI_EXPECTED = fileURLToPath(new URL('./expected/markdown-images/ui.expected.md', import.meta.url))
  30. const MODE = webSnapshotMode()
  31. const SEED_ID = 'markdown-images-web-e2e'
  32. const REMOTE_ALT = 'Remote test image'
  33. const LOCAL_ALT = 'Local test image'
  34. const PNG = Buffer.from(
  35. 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
  36. 'base64',
  37. )
  38. interface ImageOrigin {
  39. server: Server
  40. url: string
  41. requests: Array<{ path: string | undefined; referer: string | undefined }>
  42. }
  43. /** Start the deterministic remote image origin used by this browser scenario. */
  44. async function startImageOrigin(): Promise<ImageOrigin> {
  45. const requests: ImageOrigin['requests'] = []
  46. const server = createServer((request, response) => {
  47. requests.push({ path: request.url, referer: request.headers.referer })
  48. response.writeHead(200, {
  49. 'cache-control': 'no-store',
  50. 'content-length': PNG.length,
  51. 'content-type': 'image/png',
  52. })
  53. response.end(PNG)
  54. })
  55. await new Promise<void>((resolve, reject) => {
  56. server.once('error', reject)
  57. server.listen(0, '127.0.0.1', resolve)
  58. })
  59. const address = server.address()
  60. if (address === null || typeof address === 'string') {
  61. throw new Error('image origin did not expose an IP socket')
  62. }
  63. return {
  64. server,
  65. url: `http://127.0.0.1:${String(address.port)}/image.png`,
  66. requests,
  67. }
  68. }
  69. /** Stop one image origin after the browser and host release their requests. */
  70. async function stopServer(server: Server): Promise<void> {
  71. await new Promise<void>((resolve, reject) => {
  72. server.close((error) => {
  73. if (error === undefined) resolve()
  74. else reject(error)
  75. })
  76. })
  77. }
  78. /** Build one closed, invariant-checked session fixture with remote and local image Markdown. */
  79. function markdownImageFixture(remoteUrl: string): string {
  80. const session = Session.create(SessionId('markdown-image-source'))
  81. const eventTimeOrigin = new Date().setHours(12, 0, 0, 0)
  82. session.append('turn/start', { turn: 1 })
  83. const user = session.append('user/message', createUserMessage({
  84. content: [{ type: 'text', text: 'Show the Markdown image policy.' }],
  85. source: { kind: 'user' },
  86. }), { surfaceOp: 'append' })
  87. session.append('session/title', {
  88. title: 'Markdown image policy',
  89. messageSeqs: [user.seq],
  90. source: { kind: 'fallback' },
  91. })
  92. session.append('step/start', { turn: 1, step: 1 })
  93. session.append('assistant/message', {
  94. turn: 1,
  95. step: 1,
  96. message: createMessage({
  97. role: 'assistant',
  98. content: [{
  99. type: 'text',
  100. text: [
  101. '## Markdown images',
  102. '',
  103. `![${REMOTE_ALT}](${remoteUrl})`,
  104. '',
  105. `![${LOCAL_ALT}](./local-image.png)`,
  106. '',
  107. 'REMOTE_IMAGE_DONE',
  108. ].join('\n'),
  109. }],
  110. source: { kind: 'model', provider: 'fixture', model: 'fixture' },
  111. }),
  112. }, { surfaceOp: 'append' })
  113. session.append('step/end', { turn: 1, step: 1 })
  114. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  115. const header = {
  116. type: 'session',
  117. version: SESSION_FORMAT_VERSION,
  118. id: '{{sessionId}}',
  119. createdAt: 0,
  120. cwd: '{{cwd}}',
  121. }
  122. return [
  123. JSON.stringify(header),
  124. // Spaced event times, exactly as the sibling markdown fixtures pin them:
  125. // the stats line renders its LLM segment only while the step's measured
  126. // milliseconds exceed zero, so a fixture that leaves the times unset lets
  127. // the replay's own speed decide whether the golden matches.
  128. ...session.events.map(event => JSON.stringify({
  129. ...event,
  130. time: eventTimeOrigin + event.seq * 1_000,
  131. })),
  132. '',
  133. ].join('\n')
  134. }
  135. describe('web e2e: remote Markdown image rendering', () => {
  136. let scaffold: WebScaffold
  137. let imageOrigin: ImageOrigin
  138. let browser: Browser
  139. let page: Page
  140. let tripwire: ReturnType<typeof watchConsole>
  141. beforeAll(async () => {
  142. imageOrigin = await startImageOrigin()
  143. scaffold = await launchWebScaffold({})
  144. await seedSession(scaffold, markdownImageFixture(imageOrigin.url), SEED_ID)
  145. browser = await chromium.launch()
  146. page = await newEnglishPage(browser)
  147. tripwire = watchConsole(page)
  148. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  149. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  150. }, 120_000)
  151. afterAll(async () => {
  152. await browser?.close()
  153. await scaffold?.close()
  154. await stopServer(imageOrigin.server)
  155. })
  156. it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => {
  157. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images'))
  158. const groupRow = page.locator('[role="treeitem"]').first()
  159. await groupRow.waitFor({ timeout: 15_000 })
  160. await groupRow.click()
  161. const sessionRow = page.locator('[role="treeitem"]').nth(1)
  162. await sessionRow.waitFor({ timeout: 10_000 })
  163. await sessionRow.click()
  164. await expect.poll(() => page.getByText('REMOTE_IMAGE_DONE', { exact: true }).count(), {
  165. timeout: 15_000,
  166. }).toBe(1)
  167. const image = page.getByRole('img', { name: REMOTE_ALT })
  168. await image.waitFor({ timeout: 10_000 })
  169. await expect.poll(() => image.evaluate(element => (element as HTMLImageElement).naturalWidth), {
  170. timeout: 10_000,
  171. }).toBeGreaterThan(0)
  172. expect(await image.evaluate((element) => {
  173. const computed = getComputedStyle(element)
  174. return {
  175. borderRadius: computed.borderRadius,
  176. decoding: element.getAttribute('decoding'),
  177. loading: element.getAttribute('loading'),
  178. maxWidth: computed.maxWidth,
  179. referrerPolicy: element.getAttribute('referrerpolicy'),
  180. }
  181. })).toEqual({
  182. borderRadius: '8px',
  183. decoding: 'async',
  184. loading: 'lazy',
  185. maxWidth: '100%',
  186. referrerPolicy: 'no-referrer',
  187. })
  188. expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0)
  189. expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1)
  190. expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }])
  191. const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
  192. .split(SEED_ID).join('{{seededId}}')
  193. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  194. expect(tripwire.pageErrors).toEqual([])
  195. expect(tripwire.warnings).toEqual([])
  196. await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
  197. }, 60_000)
  198. })