markdown-images.e2e.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202
  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('./snapshots/markdown-images', import.meta.url))
  29. const UI_EXPECTED = fileURLToPath(new URL('./snapshots/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. session.append('turn/start', { turn: 1 })
  82. const user = session.append('user/message', createUserMessage({
  83. content: [{ type: 'text', text: 'Show the Markdown image policy.' }],
  84. source: { kind: 'user' },
  85. }), { surfaceOp: 'append' })
  86. session.append('session/title', {
  87. title: 'Markdown image policy',
  88. messageSeqs: [user.seq],
  89. source: { kind: 'fallback' },
  90. })
  91. session.append('step/start', { turn: 1, step: 1 })
  92. session.append('assistant/message', {
  93. turn: 1,
  94. step: 1,
  95. message: createMessage({
  96. role: 'assistant',
  97. content: [{
  98. type: 'text',
  99. text: [
  100. '## Markdown images',
  101. '',
  102. `![${REMOTE_ALT}](${remoteUrl})`,
  103. '',
  104. `![${LOCAL_ALT}](./local-image.png)`,
  105. '',
  106. 'REMOTE_IMAGE_DONE',
  107. ].join('\n'),
  108. }],
  109. source: { kind: 'model', provider: 'fixture', model: 'fixture' },
  110. }),
  111. }, { surfaceOp: 'append' })
  112. session.append('step/end', { turn: 1, step: 1 })
  113. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  114. const header = {
  115. type: 'session',
  116. version: SESSION_FORMAT_VERSION,
  117. id: '{{sessionId}}',
  118. createdAt: 0,
  119. cwd: '{{cwd}}',
  120. }
  121. return [
  122. JSON.stringify(header),
  123. ...session.events.map(event => JSON.stringify(event)),
  124. '',
  125. ].join('\n')
  126. }
  127. describe('web e2e: remote Markdown image rendering', () => {
  128. let scaffold: WebScaffold
  129. let imageOrigin: ImageOrigin
  130. let browser: Browser
  131. let page: Page
  132. let tripwire: ReturnType<typeof watchConsole>
  133. beforeAll(async () => {
  134. imageOrigin = await startImageOrigin()
  135. scaffold = await launchWebScaffold({})
  136. await seedSession(scaffold, markdownImageFixture(imageOrigin.url), SEED_ID)
  137. browser = await chromium.launch()
  138. page = await newEnglishPage(browser)
  139. tripwire = watchConsole(page)
  140. await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
  141. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  142. }, 120_000)
  143. afterAll(async () => {
  144. await browser?.close()
  145. await scaffold?.close()
  146. await stopServer(imageOrigin.server)
  147. })
  148. it.skipIf(MODE === 'record')('loads only the remote image and matches the conversation golden', async () => {
  149. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images'))
  150. const groupRow = page.locator('[role="treeitem"]').first()
  151. await groupRow.waitFor({ timeout: 15_000 })
  152. await groupRow.click()
  153. const sessionRow = page.locator('[role="treeitem"]').nth(1)
  154. await sessionRow.waitFor({ timeout: 10_000 })
  155. await sessionRow.click()
  156. await expect.poll(() => page.getByText('REMOTE_IMAGE_DONE', { exact: true }).count(), {
  157. timeout: 15_000,
  158. }).toBe(1)
  159. const image = page.getByRole('img', { name: REMOTE_ALT })
  160. await image.waitFor({ timeout: 10_000 })
  161. await expect.poll(() => image.evaluate(element => (element as HTMLImageElement).naturalWidth), {
  162. timeout: 10_000,
  163. }).toBeGreaterThan(0)
  164. expect(await image.evaluate((element) => {
  165. const computed = getComputedStyle(element)
  166. return {
  167. borderRadius: computed.borderRadius,
  168. decoding: element.getAttribute('decoding'),
  169. loading: element.getAttribute('loading'),
  170. maxWidth: computed.maxWidth,
  171. referrerPolicy: element.getAttribute('referrerpolicy'),
  172. }
  173. })).toEqual({
  174. borderRadius: '8px',
  175. decoding: 'async',
  176. loading: 'lazy',
  177. maxWidth: '100%',
  178. referrerPolicy: 'no-referrer',
  179. })
  180. expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0)
  181. expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1)
  182. expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }])
  183. const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
  184. .split(SEED_ID).join('{{seededId}}')
  185. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  186. expect(tripwire.pageErrors).toEqual([])
  187. expect(tripwire.warnings).toEqual([])
  188. await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
  189. }, 60_000)
  190. })