document-preview.e2e.ts 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464
  1. /** Keyless document-preview smoke through a real Session, Files tab, and shipped renderers. */
  2. import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
  3. import { tmpdir } from 'node:os'
  4. import { join, relative } from 'node:path'
  5. import { fileURLToPath } from 'node:url'
  6. import type { Browser, Locator, Page } from 'playwright'
  7. import { chromium } from 'playwright'
  8. import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest'
  9. import { pdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
  10. import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
  11. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  12. const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
  13. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url))
  14. const EXPECTED = join(SNAPSHOT_DIR, 'document.expected.md')
  15. const PAGING_PATCH = join(SNAPSHOT_DIR, 'paging.patch.yml')
  16. const PAGE_LINES = 64
  17. const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url))
  18. const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
  19. const MODE = webSnapshotMode()
  20. const TINY_PNG = Buffer.from(
  21. 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
  22. 'base64',
  23. )
  24. /** Successful render evidence stays outside the committed snapshot inventory. */
  25. async function successShot(page: Page, name: string): Promise<void> {
  26. await mkdir(SHOT_DIR, { recursive: true })
  27. await page.screenshot({ path: join(SHOT_DIR, `${name}-${MODE}-${process.pid}.png`), fullPage: true })
  28. }
  29. /** Trigger the document owner's native scroll handler after a real first page overflows. */
  30. async function scrollForNextPage(body: Locator): Promise<void> {
  31. await body.evaluate((node) => {
  32. if (node.scrollHeight <= node.clientHeight) throw new Error('paging fixture does not overflow the document body')
  33. node.scrollTop = node.scrollHeight
  34. })
  35. }
  36. /** Read the solid vector fill away from antialiased page edges. */
  37. async function canvasColor(canvas: Locator): Promise<string> {
  38. return await canvas.evaluate((node) => {
  39. const surface = node as HTMLCanvasElement
  40. const context = surface.getContext('2d')
  41. if (context === null) throw new Error('PDF canvas has no 2D context')
  42. const pixel = context.getImageData(Math.floor(surface.width / 2), Math.floor(surface.height / 2), 1, 1).data
  43. if (Number(pixel[3]) !== 255) return 'transparent'
  44. if (Number(pixel[0]) - Number(pixel[2]) > 150) return 'red'
  45. if (Number(pixel[2]) - Number(pixel[0]) > 150) return 'blue'
  46. return 'other'
  47. })
  48. }
  49. describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => {
  50. let scaffold: WebScaffold
  51. let browser: Browser
  52. let page: Page
  53. let tripwire: ReturnType<typeof watchConsole>
  54. let outsideRoot: string | undefined
  55. beforeAll(async () => {
  56. outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-'))
  57. scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH })
  58. browser = await chromium.launch()
  59. page = await newEnglishPage(browser)
  60. tripwire = watchConsole(page)
  61. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  62. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  63. })
  64. afterAll(async () => {
  65. try {
  66. await browser?.close()
  67. } finally {
  68. try {
  69. await scaffold?.close()
  70. } finally {
  71. if (outsideRoot !== undefined) await rm(outsideRoot, { recursive: true, force: true })
  72. }
  73. }
  74. })
  75. it('opens text, isolated HTML, intrinsic images, and rendered PDF from the Session workspace', async () => {
  76. onTestFailed(async () => {
  77. await mkdir(SHOT_DIR, { recursive: true })
  78. await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`)
  79. })
  80. const settled = scaffold.whenTurnSettled()
  81. const input = page.locator('[data-composer-input]').first()
  82. await input.fill(PROMPT)
  83. await input.press('Enter')
  84. const sessionId = await settled
  85. await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
  86. const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
  87. if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
  88. if (outsideRoot === undefined) throw new Error('outside fixture directory is unavailable')
  89. const outsideScript = join(outsideRoot, 'outside.js')
  90. const outsideReference = relative(cwd, outsideScript).replace(/\\/g, '/')
  91. const markdownText = [
  92. '# Markdown smoke', '', 'Rendered from the workspace.', '',
  93. ...Array.from({ length: (PAGE_LINES - 4) / 2 }, (_, index) => [`Paragraph ${index + 1}: ${'visible prefix '.repeat(20)}`, '']).flat(),
  94. '# Markdown tail',
  95. ].join('\n')
  96. const codeLines = [
  97. ...Array.from({ length: PAGE_LINES }, (_, index) => index === 0 ? 'const prefix = "CODE_PREFIX";' : `// prefix line ${index + 1}`),
  98. 'const tail = "CODE_TAIL";',
  99. ]
  100. await Promise.all([
  101. writeFile(join(cwd, 'smoke.md'), markdownText),
  102. writeFile(join(cwd, 'pages.ts'), codeLines.join('\n')),
  103. writeFile(join(cwd, 'notes.unknown'), 'UNKNOWN_SUFFIX\nPlain fallback.'),
  104. writeFile(join(cwd, 'smoke.html'), [
  105. '<!doctype html><link rel="stylesheet" href="./local.css">',
  106. '<h1>HTML smoke</h1><p id="result">pending</p><p id="local-result">pending</p><p id="parent-result">pending</p>',
  107. '<p id="outside-result">pending</p>',
  108. '<script>document.getElementById("result").textContent="INLINE_OK";',
  109. 'try{parent.document.documentElement.setAttribute("data-document-preview-escape","true");document.getElementById("parent-result").textContent="parent-accessible"}',
  110. 'catch(error){const result=document.getElementById("parent-result");result.textContent="parent-blocked";result.dataset.error=error.name}</script>',
  111. '<script src="./local.js"></script>',
  112. `<script src="${outsideReference}"></script>`,
  113. ].join('\n')),
  114. writeFile(join(cwd, 'local.js'), 'document.getElementById("local-result").textContent="LOCAL_JS_OK";'),
  115. writeFile(join(cwd, 'local.css'), '#local-result { color: rgb(12, 34, 56); }'),
  116. writeFile(outsideScript, 'document.getElementById("outside-result").textContent="OUTSIDE_JS_OK";'),
  117. writeFile(join(cwd, 'tiny.png'), TINY_PNG),
  118. writeFile(join(cwd, 'large.svg'), [
  119. '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1600" viewBox="0 0 1200 1600">',
  120. '<script>parent.document.documentElement.setAttribute("data-image-preview-escape","true")</script>',
  121. '<rect width="1200" height="1600" fill="#2463eb"/>',
  122. '</svg>',
  123. ].join('')),
  124. writeFile(join(cwd, 'smoke.pdf'), pdfFixture()),
  125. ])
  126. const column = page.locator('[data-rightbar-col]')
  127. await page.locator('[data-sidebar-right-expand]').click()
  128. await column.locator('[data-sidebar-right-guide-entry="files"]').click()
  129. await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
  130. await column.locator('[data-files-reload]').click()
  131. const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
  132. const addTab = column.locator('[data-dockkit-add-tab]')
  133. expect(await column.locator('[data-dockkit-tab]').count()).toBe(1)
  134. const defaultTitle = await filesTab.locator('[data-dockkit-tab-title]').innerText()
  135. const initialFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count()
  136. expect(initialFilesClose).toBe(1)
  137. await filesTab.click({ button: 'right' })
  138. expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(1)
  139. await page.keyboard.press('Escape')
  140. await addTab.waitFor({ state: 'visible' })
  141. const initialAdd = await addTab.count()
  142. expect(initialAdd).toBe(1)
  143. await addTab.click()
  144. await column.locator('[data-sidebar-right-guide]').waitFor({ state: 'visible' })
  145. await expect.poll(() => column.locator('[data-dockkit-tab]').count()).toBe(2)
  146. const guideTab = column.locator('[data-dockkit-tab]').filter({ hasNot: page.getByText('Files', { exact: true }) })
  147. const guideClose = await guideTab.locator('[data-dockkit-tab-close]').count()
  148. const filesCloseWithGuide = await filesTab.locator('[data-dockkit-tab-close]').count()
  149. expect(guideClose).toBe(1)
  150. expect(filesCloseWithGuide).toBe(1)
  151. await expect.poll(() => addTab.count()).toBe(0)
  152. const addWithGuide = await addTab.count()
  153. await guideTab.hover()
  154. await guideTab.locator('[data-dockkit-tab-close]').click()
  155. await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
  156. await expect.poll(() => column.locator('[data-sidebar-right-guide]').count()).toBe(0)
  157. await expect.poll(() => column.locator('[data-dockkit-tab]').count()).toBe(1)
  158. await addTab.waitFor({ state: 'visible' })
  159. const restoredFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count()
  160. const restoredAdd = await addTab.count()
  161. expect(restoredFilesClose).toBe(1)
  162. expect(restoredAdd).toBe(1)
  163. const preview = column.locator('[data-document-preview]')
  164. const openFile = async (name: string): Promise<void> => {
  165. await filesTab.click()
  166. await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click()
  167. await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true)
  168. }
  169. const viewer = preview.locator('[data-document-viewer-menu]')
  170. const body = preview.locator('[data-textpreview-body]')
  171. const sections = ['# Document preview']
  172. sections.push([
  173. '## Sidebar tabs', '',
  174. `- Default tab: ${defaultTitle}`,
  175. `- Files close buttons (alone -> with guide -> restored): ${[initialFilesClose, filesCloseWithGuide, restoredFilesClose].join(' -> ')}`,
  176. `- Manual guide close buttons: ${guideClose}`,
  177. `- Add buttons (Files -> guide -> Files): ${[initialAdd, addWithGuide, restoredAdd].join(' -> ')}`,
  178. ].join('\n'))
  179. await openFile('smoke.md')
  180. await expect.poll(() => viewer.innerText()).toBe('Markdown')
  181. await preview.getByRole('heading', { name: 'Markdown smoke', exact: true }).waitFor({ timeout: 15_000 })
  182. expect(await preview.getByText('Rendered from the workspace.', { exact: true }).isVisible()).toBe(true)
  183. const heading = await preview.getByRole('heading', { name: 'Markdown smoke', exact: true }).innerText()
  184. const markdownTail = preview.getByRole('heading', { name: 'Markdown tail', exact: true })
  185. await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true)
  186. expect(await markdownTail.count()).toBe(0)
  187. await scrollForNextPage(body)
  188. await markdownTail.waitFor({ timeout: 15_000 })
  189. await expect.poll(() => preview.locator('[data-textpreview-more]').count()).toBe(0)
  190. expect(await preview.getByRole('heading', { name: heading, exact: true }).count()).toBe(1)
  191. expect(await preview.getByText('Rendered from the workspace.', { exact: true }).count()).toBe(1)
  192. const tailHeading = await markdownTail.innerText()
  193. await preview.getByRole('heading', { name: heading, exact: true }).scrollIntoViewIfNeeded()
  194. await successShot(page, 'markdown')
  195. const markdownTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.md', { exact: true }) })
  196. const markdownTabId = await markdownTab.getAttribute('data-dockkit-tab')
  197. expect(markdownTabId).not.toBeNull()
  198. const tabCount = await column.locator('[data-dockkit-tab]').count()
  199. const markdownViewers = [await viewer.innerText()]
  200. const sourceFonts: Array<{ fontSize: string; lineHeight: string }> = []
  201. for (const label of ['Code', 'Plain text']) {
  202. await viewer.click()
  203. await page.getByRole('menuitem', { name: label, exact: true }).click()
  204. await expect.poll(() => viewer.innerText()).toBe(label)
  205. if (label === 'Code') {
  206. await preview.locator('.shiki').waitFor({ timeout: 15_000 })
  207. expect(await preview.locator('.shiki').textContent()).toBe(markdownText)
  208. } else {
  209. await expect.poll(async () => (await preview.locator('[data-textpreview-line]').first().textContent())?.trim()).toBe('# Markdown smoke')
  210. }
  211. const source = label === 'Code' ? preview.locator('.shiki') : preview.locator('[data-textpreview-line]').first()
  212. sourceFonts.push(await source.evaluate((node) => {
  213. const style = getComputedStyle(node)
  214. return { fontSize: style.fontSize, lineHeight: style.lineHeight }
  215. }))
  216. expect(await markdownTab.getAttribute('data-dockkit-tab')).toBe(markdownTabId)
  217. expect(await column.locator('[data-dockkit-tab]').count()).toBe(tabCount)
  218. markdownViewers.push(await viewer.innerText())
  219. }
  220. expect(sourceFonts[1]).toEqual(sourceFonts[0])
  221. sections.push([
  222. '## Markdown', '',
  223. `- Heading: ${heading}`,
  224. `- Tail loaded by scrolling: ${tailHeading}`,
  225. `- Viewers: ${markdownViewers.join(' -> ')}`,
  226. `- Same tab: ${String(await markdownTab.getAttribute('data-dockkit-tab') === markdownTabId)}`,
  227. ].join('\n'))
  228. await openFile('smoke.html')
  229. await expect.poll(() => viewer.innerText()).toBe('HTML')
  230. const iframe = preview.locator('[data-html-preview]')
  231. await iframe.waitFor({ timeout: 15_000 })
  232. expect(await iframe.getAttribute('sandbox')).toBe('allow-scripts')
  233. expect(await iframe.evaluate((node) => {
  234. const host = node.closest('[data-textpreview-body]')
  235. if (!(host instanceof HTMLElement)) throw new Error('HTML preview body is unavailable')
  236. const outer = host.getBoundingClientRect()
  237. const frame = node.getBoundingClientRect()
  238. return {
  239. top: Math.round(frame.top - outer.top),
  240. right: Math.round(outer.right - frame.right),
  241. bottom: Math.round(outer.bottom - frame.bottom),
  242. left: Math.round(frame.left - outer.left),
  243. }
  244. })).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
  245. const html = page.frameLocator('[data-html-preview]')
  246. await html.getByRole('heading', { name: 'HTML smoke', exact: true }).waitFor({ timeout: 15_000 })
  247. await expect.poll(() => html.locator('#result').innerText()).toBe('INLINE_OK')
  248. await expect.poll(() => html.locator('#local-result').innerText()).toBe('LOCAL_JS_OK')
  249. await expect.poll(() => html.locator('#outside-result').innerText()).toBe('OUTSIDE_JS_OK')
  250. await expect.poll(() => html.locator('#local-result').evaluate(node => getComputedStyle(node).color)).toBe('rgb(12, 34, 56)')
  251. await expect.poll(() => html.locator('#parent-result').innerText()).toBe('parent-blocked')
  252. expect(await html.locator('#parent-result').getAttribute('data-error')).toBe('SecurityError')
  253. expect(await page.locator('html').getAttribute('data-document-preview-escape')).toBeNull()
  254. await successShot(page, 'html')
  255. sections.push([
  256. '## HTML', '',
  257. `- Viewer: ${await viewer.innerText()}`,
  258. `- Sandbox: ${await iframe.getAttribute('sandbox')}`,
  259. `- Inline script: ${await html.locator('#result').innerText()}`,
  260. `- Local script: ${await html.locator('#local-result').innerText()}`,
  261. `- Outside-workspace script: ${await html.locator('#outside-result').innerText()}`,
  262. `- Local stylesheet: ${await html.locator('#local-result').evaluate(node => getComputedStyle(node).color)}`,
  263. `- Parent access: ${await html.locator('#parent-result').innerText()} (${await html.locator('#parent-result').getAttribute('data-error')})`,
  264. `- Parent unchanged: ${String(await page.locator('html').getAttribute('data-document-preview-escape') === null)}`,
  265. ].join('\n'))
  266. await openFile('smoke.pdf')
  267. await expect.poll(() => viewer.innerText()).toBe('PDF')
  268. const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true })
  269. await canvas.waitFor({ state: 'visible', timeout: 30_000 })
  270. expect(await preview.locator('[role="toolbar"]').count()).toBe(0)
  271. expect(await preview.locator('[data-pdf-page]').count()).toBe(2)
  272. await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red')
  273. const firstColor = await canvasColor(canvas)
  274. expect(firstColor).toBe('red')
  275. const workerNames = await Promise.all(page.workers().map(worker => worker.evaluate(() => self.name)))
  276. expect(workerNames).toContain('dsh-pdf')
  277. await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded()
  278. const secondPage = preview.getByRole('img', { name: 'PDF page 2', exact: true })
  279. await secondPage.waitFor({ state: 'visible', timeout: 30_000 })
  280. await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue')
  281. const secondColor = await canvasColor(secondPage)
  282. expect(secondColor).toBe('blue')
  283. expect(await body.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true)
  284. const pdfTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.pdf', { exact: true }) })
  285. const pdfTabId = await pdfTab.getAttribute('data-dockkit-tab')
  286. expect(pdfTabId).not.toBeNull()
  287. await filesTab.click()
  288. await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
  289. await pdfTab.click()
  290. await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded()
  291. await secondPage.waitFor({ state: 'visible', timeout: 30_000 })
  292. await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue')
  293. const restoredColor = await canvasColor(secondPage)
  294. expect(restoredColor).toBe('blue')
  295. expect(await pdfTab.getAttribute('data-dockkit-tab')).toBe(pdfTabId)
  296. await successShot(page, 'pdf')
  297. sections.push([
  298. '## PDF', '',
  299. `- Viewer: ${await viewer.innerText()}`,
  300. `- Worker: ${workerNames.find(name => name === 'dsh-pdf')}`,
  301. `- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`,
  302. `- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`,
  303. `- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`,
  304. `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`,
  305. ].join('\n'))
  306. await openFile('tiny.png')
  307. await expect.poll(() => viewer.innerText()).toBe('Image')
  308. const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true })
  309. await tinyImage.waitFor({ state: 'visible', timeout: 15_000 })
  310. expect(await tinyImage.evaluate(node => ({
  311. width: (node as HTMLImageElement).naturalWidth,
  312. height: (node as HTMLImageElement).naturalHeight,
  313. draggable: (node as HTMLImageElement).draggable,
  314. }))).toEqual({ width: 1, height: 1, draggable: false })
  315. const centering = await tinyImage.evaluate((node) => {
  316. const image = node.getBoundingClientRect()
  317. const scroller = node.closest('[data-textpreview-body]')?.getBoundingClientRect()
  318. if (scroller === undefined) throw new Error('image document scroller is unavailable')
  319. return {
  320. horizontal: Math.abs((image.left + image.width / 2) - (scroller.left + scroller.width / 2)),
  321. vertical: Math.abs((image.top + image.height / 2) - (scroller.top + scroller.height / 2)),
  322. }
  323. })
  324. expect(centering.horizontal).toBeLessThan(10)
  325. expect(centering.vertical).toBeLessThan(10)
  326. await openFile('large.svg')
  327. await expect.poll(() => viewer.innerText()).toBe('Image')
  328. const largeImage = preview.getByRole('img', { name: 'Image preview: large.svg', exact: true })
  329. await largeImage.waitFor({ state: 'visible', timeout: 15_000 })
  330. expect(await largeImage.evaluate(node => ({
  331. naturalWidth: (node as HTMLImageElement).naturalWidth,
  332. naturalHeight: (node as HTMLImageElement).naturalHeight,
  333. width: getComputedStyle(node).width,
  334. height: getComputedStyle(node).height,
  335. }))).toEqual({ naturalWidth: 1200, naturalHeight: 1600, width: '1200px', height: '1600px' })
  336. expect(await body.evaluate(node => ({
  337. horizontal: node.scrollWidth > node.clientWidth,
  338. vertical: node.scrollHeight > node.clientHeight,
  339. }))).toEqual({ horizontal: true, vertical: true })
  340. const scrolled = await body.evaluate((node) => {
  341. node.scrollLeft = node.scrollWidth
  342. node.scrollTop = node.scrollHeight
  343. return { left: node.scrollLeft, top: node.scrollTop }
  344. })
  345. expect(scrolled.left).toBeGreaterThan(0)
  346. expect(scrolled.top).toBeGreaterThan(0)
  347. expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull()
  348. const releaseRead = Promise.withResolvers<undefined>()
  349. let waitingForRead = false
  350. const readPage = scaffold.ctx.workspaceFiles.read.bind(scaffold.ctx.workspaceFiles)
  351. const heldRead = vi.spyOn(scaffold.ctx.workspaceFiles, 'read').mockImplementation(async (agent, path, range, signal) => {
  352. if (path === 'pages.ts' && (range.offset ?? 1) === 1) {
  353. waitingForRead = true
  354. await releaseRead.promise
  355. }
  356. return readPage(agent, path, range, signal)
  357. })
  358. let initialReading = false
  359. try {
  360. await openFile('pages.ts')
  361. await expect.poll(() => waitingForRead).toBe(true)
  362. const reading = preview.locator('[data-document-loading]')
  363. initialReading = await reading.isVisible()
  364. expect(initialReading).toBe(true)
  365. expect(await preview.locator('[data-code-preview]').count()).toBe(0)
  366. const indicator = await reading.boundingBox()
  367. const scroller = await body.boundingBox()
  368. if (indicator === null || scroller === null) throw new Error('reading indicator or document body is not rendered')
  369. expect(indicator.y).toBeGreaterThanOrEqual(scroller.y)
  370. expect(indicator.y + indicator.height).toBeLessThanOrEqual(scroller.y + scroller.height)
  371. await successShot(page, 'code-reading')
  372. } finally {
  373. releaseRead.resolve(undefined)
  374. heldRead.mockRestore()
  375. }
  376. await expect.poll(() => viewer.innerText()).toBe('Code')
  377. const highlightedLines = preview.locator('.shiki .line')
  378. await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(PAGE_LINES)
  379. const codeBlock = preview.locator('.md-code-block')
  380. const codeScrollport = preview.locator('[data-code-block-content]')
  381. expect(await codeBlock.getAttribute('data-line-numbers')).toBe('true')
  382. await expect.poll(() => highlightedLines.first().evaluate(node => getComputedStyle(node, '::before').content))
  383. .not.toMatch(/^(?:none|normal)$/u)
  384. const numbering = await highlightedLines.first().evaluate((node) => {
  385. const line = getComputedStyle(node)
  386. const before = getComputedStyle(node, '::before')
  387. return {
  388. counterIncrement: line.counterIncrement,
  389. gutterWidth: Number.parseFloat(before.width),
  390. sourceInset: Number.parseFloat(line.paddingInlineStart),
  391. }
  392. })
  393. expect(numbering.counterIncrement).toBe('source-line 1')
  394. expect(numbering.gutterWidth).toBeGreaterThan(0)
  395. expect(numbering.sourceInset).toBeGreaterThan(numbering.gutterWidth)
  396. const prefix = await highlightedLines.allTextContents()
  397. expect(prefix).toEqual(codeLines.slice(0, PAGE_LINES))
  398. await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true)
  399. await scrollForNextPage(codeScrollport)
  400. await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(codeLines.length)
  401. const completed = await highlightedLines.allTextContents()
  402. expect(completed).toEqual(codeLines)
  403. await expect.poll(() => preview.locator('[data-textpreview-more]').count()).toBe(0)
  404. const scrollTop = await codeScrollport.evaluate((node) => {
  405. const target = Math.floor((node.scrollHeight - node.clientHeight) / 2)
  406. if (target <= 0) throw new Error('code fixture does not overflow the document body')
  407. node.scrollTop = target
  408. return target
  409. })
  410. await expect.poll(() => codeScrollport.evaluate((node) => {
  411. const codeBlock = node.parentElement
  412. const banner = codeBlock?.firstElementChild
  413. const firstLine = node.querySelector('.shiki .line')
  414. if (!(banner instanceof HTMLElement) || firstLine === null) throw new Error('missing rendered code banner or source line')
  415. const bounds = node.getBoundingClientRect()
  416. const clipTop = bounds.top + node.clientTop
  417. const bannerBounds = banner.getBoundingClientRect()
  418. return {
  419. scrollTop: node.scrollTop,
  420. scrollportBelowBanner: Math.abs(bannerBounds.bottom - bounds.top) < 1,
  421. firstLineAbove: firstLine.getBoundingClientRect().top < clipTop,
  422. }
  423. })).toEqual({ scrollTop, scrollportBelowBanner: true, firstLineAbove: true })
  424. await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
  425. await page.evaluate(() => navigator.clipboard.writeText(''))
  426. await codeBlock.getByRole('button', { name: 'Copy', exact: true }).click()
  427. await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(codeLines.join('\n'))
  428. sections.push([
  429. '## Code paging', '',
  430. `- Viewer: ${await viewer.innerText()}`,
  431. `- Initial reading indicator: ${initialReading}`,
  432. `- Lines: ${prefix.length} -> ${completed.length}`,
  433. `- Prefix retained: ${String(JSON.stringify(completed.slice(0, prefix.length)) === JSON.stringify(prefix))}`,
  434. `- Tail: ${completed.at(-1)}`,
  435. ].join('\n'))
  436. await openFile('notes.unknown')
  437. await expect.poll(() => viewer.innerText()).toBe('Plain text')
  438. const plainLines = preview.locator('[data-textpreview-line]')
  439. await expect.poll(() => plainLines.count()).toBe(2)
  440. const fallback = (await plainLines.allTextContents()).map(line => line.trim())
  441. expect(fallback).toEqual(['UNKNOWN_SUFFIX', 'Plain fallback.'])
  442. sections.push(['## Unknown suffix', '', `- Viewer: ${await viewer.innerText()}`, `- Text: ${fallback.join(' | ')}`].join('\n'))
  443. expect(tripwire.pageErrors).toEqual([])
  444. expect(tripwire.warnings).toEqual([])
  445. await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE)
  446. await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml'])
  447. })
  448. })