markdown-images.e2e.ts 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275
  1. // Real browser image loading and failure fallbacks through the shipped Web composition.
  2. import { open, writeFile } from 'node:fs/promises'
  3. import { createServer, type Server } from 'node:http'
  4. import { join } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import type { Browser, Page } from 'playwright'
  7. import { chromium } from 'playwright'
  8. import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
  9. import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
  10. import {
  11. SESSION_FORMAT_VERSION,
  12. Session,
  13. SessionId,
  14. } from '@deepseek-ai/dsh-session'
  15. import type {} from '@deepseek-ai/dsh-session-title'
  16. import {
  17. assertFixtureInventory,
  18. captureStableAria,
  19. compareOrRefreshGolden,
  20. launchWebScaffold,
  21. seedSession,
  22. watchConsole,
  23. webSnapshotMode,
  24. type WebScaffold,
  25. } from './scaffold.ts'
  26. import { newEnglishPage, saveFailureShot } from './support.ts'
  27. const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-images', import.meta.url))
  28. const UI_EXPECTED = fileURLToPath(new URL('./expected/markdown-images/ui.expected.md', import.meta.url))
  29. const MODE = webSnapshotMode()
  30. const SEED_ID = 'markdown-images-web-e2e'
  31. const REMOTE_ALT = 'Remote test image'
  32. const LOCAL_ALT = 'Local test image'
  33. const WORKSPACE_ALT = 'Workspace 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, outsidePath: 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. stream: [],
  95. turn: 1,
  96. step: 1,
  97. message: createMessage({
  98. role: 'assistant',
  99. content: [{
  100. type: 'text',
  101. text: [
  102. '## Markdown images',
  103. '',
  104. `![${REMOTE_ALT}](${remoteUrl})`,
  105. '',
  106. `![${LOCAL_ALT}](./local-image.png)`,
  107. '',
  108. `![${WORKSPACE_ALT}]({{cwd}}/valid.png)`,
  109. '',
  110. '![Oversized image]({{cwd}}/oversized.png)',
  111. '',
  112. `![Outside workspace image](${outsidePath})`,
  113. '',
  114. '![Missing image]({{cwd}}/missing.png)',
  115. '',
  116. '![]({{cwd}}/corrupt.png)',
  117. '',
  118. 'REMOTE_IMAGE_DONE',
  119. ].join('\n'),
  120. }],
  121. source: { kind: 'model', provider: 'fixture', model: 'fixture' },
  122. }),
  123. }, { surfaceOp: 'append' })
  124. session.append('step/end', { turn: 1, step: 1 })
  125. session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
  126. const header = {
  127. type: 'session',
  128. version: SESSION_FORMAT_VERSION,
  129. id: '{{sessionId}}',
  130. createdAt: 0,
  131. cwd: '{{cwd}}',
  132. isSeeded: false,
  133. delegationDepth: 0,
  134. }
  135. return [
  136. JSON.stringify(header),
  137. // Spaced event times, exactly as the sibling markdown fixtures pin them:
  138. // the stats line renders its LLM segment only while the step's measured
  139. // milliseconds exceed zero, so a fixture that leaves the times unset lets
  140. // the replay's own speed decide whether the golden matches.
  141. ...session.snapshotEvents().map(event => JSON.stringify({
  142. ...event,
  143. time: eventTimeOrigin + event.seq * 1_000,
  144. })),
  145. '',
  146. ].join('\n')
  147. }
  148. describe('web e2e: Markdown image rendering', () => {
  149. let scaffold: WebScaffold
  150. let imageOrigin: ImageOrigin
  151. let browser: Browser
  152. let page: Page
  153. let tripwire: ReturnType<typeof watchConsole>
  154. const mediaResponses = new Map<string, number>()
  155. beforeAll(async () => {
  156. imageOrigin = await startImageOrigin()
  157. scaffold = await launchWebScaffold({})
  158. await writeFile(join(scaffold.workspaceCwd, 'valid.png'), PNG)
  159. await writeFile(join(scaffold.workspaceCwd, 'corrupt.png'), 'invalid image')
  160. const oversized = await open(join(scaffold.workspaceCwd, 'oversized.png'), 'w')
  161. try {
  162. await oversized.truncate(20 * 1024 * 1024 + 1)
  163. } finally {
  164. await oversized.close()
  165. }
  166. const outsidePath = join(scaffold.persistenceRoot, 'outside.png')
  167. await writeFile(outsidePath, PNG)
  168. await writeFile(join(scaffold.workspaceCwd, 'active.html'), '<p>File preview</p><script>document.body.dataset.scriptRan = "yes"</script>')
  169. await seedSession(scaffold, markdownImageFixture(imageOrigin.url, outsidePath), SEED_ID)
  170. browser = await chromium.launch()
  171. page = await newEnglishPage(browser)
  172. tripwire = watchConsole(page)
  173. page.on('response', (response) => {
  174. const url = new URL(response.url())
  175. if (url.pathname !== '/api/file') return
  176. const path = url.searchParams.get('path')
  177. if (path !== null) mediaResponses.set(path, response.status())
  178. })
  179. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  180. await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
  181. }, 120_000)
  182. afterAll(async () => {
  183. await browser?.close()
  184. await scaffold?.close()
  185. if (imageOrigin !== undefined) await stopServer(imageOrigin.server)
  186. })
  187. it('authenticates file requests and isolates directly opened active content', async () => {
  188. const path = `/api/file?path=${encodeURIComponent(join(scaffold.workspaceCwd, 'active.html'))}`
  189. const unauthenticated = await fetch(new URL(path, scaffold.baseUrl))
  190. expect(unauthenticated.status).toBe(401)
  191. await unauthenticated.body?.cancel()
  192. const preview = await newEnglishPage(browser)
  193. await preview.context().addCookies(await page.context().cookies())
  194. try {
  195. const response = await preview.goto(new URL(path, scaffold.baseUrl).href)
  196. expect(response?.status()).toBe(200)
  197. await preview.getByText('File preview', { exact: true }).waitFor()
  198. expect(await preview.locator('body').getAttribute('data-script-ran')).toBeNull()
  199. } finally {
  200. await preview.close()
  201. }
  202. })
  203. it.skipIf(MODE === 'record')('loads permitted images and shows authored text for failures', async () => {
  204. onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-images'))
  205. await page.getByRole('treeitem').first().click()
  206. await page.getByRole('treeitem').nth(1).click()
  207. await expect.poll(() => page.getByText('REMOTE_IMAGE_DONE', { exact: true }).count(), {
  208. timeout: 15_000,
  209. }).toBe(1)
  210. const image = page.getByRole('img', { name: REMOTE_ALT })
  211. await image.waitFor({ timeout: 10_000 })
  212. await expect.poll(() => image.evaluate(element => (element as HTMLImageElement).naturalWidth), {
  213. timeout: 10_000,
  214. }).toBeGreaterThan(0)
  215. expect(await image.evaluate((element) => {
  216. const computed = getComputedStyle(element)
  217. return {
  218. borderRadius: computed.borderRadius,
  219. decoding: element.getAttribute('decoding'),
  220. loading: element.getAttribute('loading'),
  221. maxWidth: computed.maxWidth,
  222. referrerPolicy: element.getAttribute('referrerpolicy'),
  223. }
  224. })).toEqual({
  225. borderRadius: '8px',
  226. decoding: 'async',
  227. loading: 'lazy',
  228. maxWidth: '100%',
  229. referrerPolicy: 'no-referrer',
  230. })
  231. expect(await page.getByRole('img', { name: LOCAL_ALT }).count()).toBe(0)
  232. expect(await page.getByText(LOCAL_ALT, { exact: true }).count()).toBe(1)
  233. expect(imageOrigin.requests).toEqual([{ path: '/image.png', referer: undefined }])
  234. const workspaceImage = page.getByRole('img', { name: WORKSPACE_ALT })
  235. await expect.poll(() => mediaResponses.get(join(scaffold.workspaceCwd, 'valid.png'))).toBe(200)
  236. await expect.poll(() => workspaceImage.evaluate(element => (element as HTMLImageElement).naturalWidth, undefined, {
  237. timeout: 1_000,
  238. }))
  239. .toBe(1)
  240. const outsideImage = page.getByRole('img', { name: 'Outside workspace image' })
  241. await expect.poll(() => outsideImage.evaluate(element => (element as HTMLImageElement).naturalWidth)).toBe(1)
  242. for (const alt of ['Oversized image', 'Missing image']) {
  243. await page.getByText(alt, { exact: true }).waitFor()
  244. expect(await page.getByRole('img', { name: alt }).count()).toBe(0)
  245. }
  246. await page.getByText(join(scaffold.workspaceCwd, 'corrupt.png'), { exact: true }).waitFor()
  247. expect(mediaResponses).toEqual(new Map([
  248. [join(scaffold.workspaceCwd, 'valid.png'), 200],
  249. [join(scaffold.workspaceCwd, 'oversized.png'), 413],
  250. [join(scaffold.persistenceRoot, 'outside.png'), 200],
  251. [join(scaffold.workspaceCwd, 'missing.png'), 404],
  252. [join(scaffold.workspaceCwd, 'corrupt.png'), 200],
  253. ]))
  254. const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
  255. .split(SEED_ID).join('{{seededId}}')
  256. await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
  257. expect(tripwire.pageErrors).toEqual([])
  258. expect(tripwire.warnings).toEqual([])
  259. await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md'])
  260. }, 60_000)
  261. })