document-preview.e2e.ts 43 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738
  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 { realOfficeBytes } from './office-fixture.ts'
  10. import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts'
  11. import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
  12. import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
  13. const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url))
  14. const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url))
  15. const EXPECTED = join(SNAPSHOT_DIR, 'document.expected.md')
  16. const PAGING_PATCH = join(SNAPSHOT_DIR, 'paging.patch.yml')
  17. const PAGE_LINES = 64
  18. const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url))
  19. const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
  20. const MODE = webSnapshotMode()
  21. const TINY_PNG = Buffer.from(
  22. 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=',
  23. 'base64',
  24. )
  25. /** Successful render evidence stays outside the committed snapshot inventory. */
  26. async function successShot(page: Page, name: string): Promise<void> {
  27. await mkdir(SHOT_DIR, { recursive: true })
  28. await page.screenshot({ path: join(SHOT_DIR, `${name}-${MODE}-${process.pid}.png`), fullPage: true })
  29. }
  30. /** Exercise native browser selection and copy, including the text overlay's canvas alignment. */
  31. async function copyPdfText(page: Page, preview: Locator, expected: string): Promise<void> {
  32. const text = preview.locator('[data-pdf-text] span:not(.markedContent)').filter({ hasText: expected }).first()
  33. await text.waitFor({ state: 'visible' })
  34. await expect.poll(() => text.evaluate(node => getComputedStyle(node).userSelect)).toBe('text')
  35. await text.click({ clickCount: 3 })
  36. await expect.poll(() => page.evaluate(() => window.getSelection()?.toString().trim())).toBe(expected)
  37. await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
  38. await page.keyboard.press('ControlOrMeta+C')
  39. await expect.poll(() => page.evaluate(async () => (await navigator.clipboard.readText()).trim())).toBe(expected)
  40. await expect.poll(() => preview.locator('[data-pdf-page]').first().evaluate((node) => {
  41. const canvas = node.querySelector('canvas')!.getBoundingClientRect()
  42. const layer = node.querySelector('.textLayer')!.getBoundingClientRect()
  43. return Math.max(Math.abs(layer.width - canvas.width), Math.abs(layer.height - canvas.height),
  44. Math.abs(layer.left - canvas.left), Math.abs(layer.top - canvas.top))
  45. })).toBeLessThan(1)
  46. }
  47. /** Trigger the document owner's native scroll handler after a real first page overflows. */
  48. async function scrollForNextPage(body: Locator): Promise<void> {
  49. await body.evaluate((node) => {
  50. if (node.scrollHeight <= node.clientHeight) throw new Error('paging fixture does not overflow the document body')
  51. node.scrollTop = node.scrollHeight
  52. })
  53. }
  54. /** Read the solid vector fill away from antialiased page edges. */
  55. async function canvasColor(canvas: Locator): Promise<string> {
  56. return await canvas.evaluate((node) => {
  57. const surface = node as HTMLCanvasElement
  58. const context = surface.getContext('2d')
  59. if (context === null) throw new Error('PDF canvas has no 2D context')
  60. const pixel = context.getImageData(Math.floor(surface.width / 2), Math.floor(surface.height / 2), 1, 1).data
  61. if (Number(pixel[3]) !== 255) return 'transparent'
  62. if (Number(pixel[0]) - Number(pixel[2]) > 150) return 'red'
  63. if (Number(pixel[2]) - Number(pixel[0]) > 150) return 'blue'
  64. return 'other'
  65. })
  66. }
  67. /** Select a workspace file through the Files tab and wait for its preview identity. */
  68. async function openPreviewFile(column: Locator, filesTab: Locator, preview: Locator, name: string): Promise<void> {
  69. await filesTab.click()
  70. await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click()
  71. await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true)
  72. }
  73. describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => {
  74. let scaffold: WebScaffold
  75. let browser: Browser
  76. let page: Page
  77. let tripwire: ReturnType<typeof watchConsole>
  78. let outsideRoot: string | undefined
  79. beforeAll(async () => {
  80. outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-'))
  81. scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: PAGING_PATCH })
  82. browser = await chromium.launch()
  83. page = await newEnglishPage(browser)
  84. tripwire = watchConsole(page)
  85. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  86. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  87. })
  88. afterAll(async () => {
  89. try {
  90. await browser?.close()
  91. } finally {
  92. try {
  93. await scaffold?.close()
  94. } finally {
  95. if (outsideRoot !== undefined) await rm(outsideRoot, { recursive: true, force: true })
  96. }
  97. }
  98. })
  99. it('opens text, isolated HTML, width-fitted images, and rendered PDF from the Session workspace', async () => {
  100. onTestFailed(async () => {
  101. await mkdir(SHOT_DIR, { recursive: true })
  102. await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`)
  103. })
  104. const settled = scaffold.whenTurnSettled()
  105. const input = page.locator('[data-composer-input]').first()
  106. await input.fill(PROMPT)
  107. await input.press('Enter')
  108. const sessionId = await settled
  109. await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
  110. const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
  111. if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
  112. if (outsideRoot === undefined) throw new Error('outside fixture directory is unavailable')
  113. const outsideScript = join(outsideRoot, 'outside.js')
  114. const outsideReference = relative(cwd, outsideScript).replace(/\\/g, '/')
  115. const markdownText = [
  116. '# Markdown smoke', '', 'Rendered from the workspace.', '',
  117. ...Array.from({ length: (PAGE_LINES - 4) / 2 }, (_, index) => [`Paragraph ${index + 1}: ${'visible prefix '.repeat(20)}`, '']).flat(),
  118. '# Markdown tail',
  119. ].join('\n')
  120. const codeLines = [
  121. ...Array.from({ length: PAGE_LINES }, (_, index) => index === 0 ? 'const prefix = "CODE_PREFIX";' : `// prefix line ${index + 1}`),
  122. 'const tail = "CODE_TAIL";',
  123. ]
  124. await Promise.all([
  125. writeFile(join(cwd, 'smoke.md'), markdownText),
  126. writeFile(join(cwd, 'pages.ts'), codeLines.join('\n')),
  127. writeFile(join(cwd, 'notes.unknown'), 'UNKNOWN_SUFFIX\nPlain fallback.'),
  128. writeFile(join(cwd, 'smoke.html'), [
  129. '<!doctype html><link rel="stylesheet" href="./local.css">',
  130. '<h1>HTML smoke</h1><p id="result">pending</p><p id="local-result">pending</p><p id="parent-result">pending</p>',
  131. '<p id="outside-result">pending</p>',
  132. '<script>document.getElementById("result").textContent="INLINE_OK";',
  133. 'try{parent.document.documentElement.setAttribute("data-document-preview-escape","true");document.getElementById("parent-result").textContent="parent-accessible"}',
  134. 'catch(error){const result=document.getElementById("parent-result");result.textContent="parent-blocked";result.dataset.error=error.name}</script>',
  135. '<script src="./local.js"></script>',
  136. `<script src="${outsideReference}"></script>`,
  137. ].join('\n')),
  138. writeFile(join(cwd, 'local.js'), 'document.getElementById("local-result").textContent="LOCAL_JS_OK";'),
  139. writeFile(join(cwd, 'local.css'), '#local-result { color: rgb(12, 34, 56); }'),
  140. writeFile(outsideScript, 'document.getElementById("outside-result").textContent="OUTSIDE_JS_OK";'),
  141. writeFile(join(cwd, 'tiny.png'), TINY_PNG),
  142. writeFile(join(cwd, 'large.svg'), [
  143. '<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1600" viewBox="0 0 1200 1600">',
  144. '<script>parent.document.documentElement.setAttribute("data-image-preview-escape","true")</script>',
  145. '<rect width="1200" height="1600" fill="#2463eb"/>',
  146. '</svg>',
  147. ].join('')),
  148. writeFile(join(cwd, 'smoke.pdf'), pdfFixture()),
  149. writeFile(join(cwd, 'user-unit.pdf'), pdfFixture(2)),
  150. ...[90, 180, 270].map(rotation => writeFile(join(cwd, `rotated-${rotation}.pdf`), pdfFixture(4, rotation))),
  151. writeFile(join(cwd, 'selection.pdf'), selectionPdfFixture()),
  152. ...['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx'].map(extension => writeFile(join(cwd, `unavailable.${extension}`), Buffer.from('PK\u0003\u0004OFFICE_BINARY_PREVIEW'))),
  153. writeFile(join(cwd, 'clip.mp4'), Buffer.from([0x00, 0x00, 0x00, 0x18, 0x66, 0x74, 0x79, 0x70])),
  154. ])
  155. const column = page.locator('[data-rightbar-col]')
  156. await page.locator('[data-sidebar-right-expand]').click()
  157. await column.locator('[data-sidebar-right-guide-entry="files"]').click()
  158. await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
  159. await column.locator('[data-files-reload]').click()
  160. const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
  161. const addTab = column.locator('[data-dockkit-add-tab]')
  162. expect(await column.locator('[data-dockkit-tab]').count()).toBe(1)
  163. const defaultTitle = await filesTab.locator('[data-dockkit-tab-title]').innerText()
  164. const initialFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count()
  165. expect(initialFilesClose).toBe(1)
  166. await filesTab.click({ button: 'right' })
  167. expect(await page.locator('[data-dockkit-tab-menu]:visible').count()).toBe(1)
  168. await page.keyboard.press('Escape')
  169. await addTab.waitFor({ state: 'visible' })
  170. const initialAdd = await addTab.count()
  171. expect(initialAdd).toBe(1)
  172. await addTab.click()
  173. await column.locator('[data-sidebar-right-guide]').waitFor({ state: 'visible' })
  174. await expect.poll(() => column.locator('[data-dockkit-tab]').count()).toBe(2)
  175. const guideTab = column.locator('[data-dockkit-tab]').filter({ hasNot: page.getByText('Files', { exact: true }) })
  176. const guideClose = await guideTab.locator('[data-dockkit-tab-close]').count()
  177. const filesCloseWithGuide = await filesTab.locator('[data-dockkit-tab-close]').count()
  178. expect(guideClose).toBe(1)
  179. expect(filesCloseWithGuide).toBe(1)
  180. await expect.poll(() => addTab.count()).toBe(0)
  181. const addWithGuide = await addTab.count()
  182. await guideTab.hover()
  183. await guideTab.locator('[data-dockkit-tab-close]').click()
  184. await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
  185. await expect.poll(() => column.locator('[data-sidebar-right-guide]').count()).toBe(0)
  186. await expect.poll(() => column.locator('[data-dockkit-tab]').count()).toBe(1)
  187. await addTab.waitFor({ state: 'visible' })
  188. const restoredFilesClose = await filesTab.locator('[data-dockkit-tab-close]').count()
  189. const restoredAdd = await addTab.count()
  190. expect(restoredFilesClose).toBe(1)
  191. expect(restoredAdd).toBe(1)
  192. const preview = column.locator('[data-document-preview]')
  193. const openFile = openPreviewFile.bind(undefined, column, filesTab, preview)
  194. const viewer = preview.locator('[data-document-viewer-menu]')
  195. const body = preview.locator('[data-textpreview-body]')
  196. const sections = ['# Document preview']
  197. sections.push([
  198. '## Sidebar tabs', '',
  199. `- Default tab: ${defaultTitle}`,
  200. `- Files close buttons (alone -> with guide -> restored): ${[initialFilesClose, filesCloseWithGuide, restoredFilesClose].join(' -> ')}`,
  201. `- Manual guide close buttons: ${guideClose}`,
  202. `- Add buttons (Files -> guide -> Files): ${[initialAdd, addWithGuide, restoredAdd].join(' -> ')}`,
  203. ].join('\n'))
  204. await openFile('smoke.md')
  205. await expect.poll(() => viewer.innerText()).toBe('Markdown')
  206. await preview.getByRole('heading', { name: 'Markdown smoke', exact: true }).waitFor({ timeout: 15_000 })
  207. expect(await preview.getByText('Rendered from the workspace.', { exact: true }).isVisible()).toBe(true)
  208. const heading = await preview.getByRole('heading', { name: 'Markdown smoke', exact: true }).innerText()
  209. const markdownTail = preview.getByRole('heading', { name: 'Markdown tail', exact: true })
  210. await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true)
  211. expect(await markdownTail.count()).toBe(0)
  212. await scrollForNextPage(body)
  213. await markdownTail.waitFor({ timeout: 15_000 })
  214. await expect.poll(() => preview.locator('[data-textpreview-more]').count()).toBe(0)
  215. expect(await preview.getByRole('heading', { name: heading, exact: true }).count()).toBe(1)
  216. expect(await preview.getByText('Rendered from the workspace.', { exact: true }).count()).toBe(1)
  217. const tailHeading = await markdownTail.innerText()
  218. await preview.getByRole('heading', { name: heading, exact: true }).scrollIntoViewIfNeeded()
  219. await successShot(page, 'markdown')
  220. const markdownTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.md', { exact: true }) })
  221. const markdownTabId = await markdownTab.getAttribute('data-dockkit-tab')
  222. expect(markdownTabId).not.toBeNull()
  223. const tabCount = await column.locator('[data-dockkit-tab]').count()
  224. const markdownViewers = [await viewer.innerText()]
  225. const sourceFonts: Array<{ fontSize: string; lineHeight: string }> = []
  226. for (const label of ['Code', 'Plain text']) {
  227. await viewer.click()
  228. await page.getByRole('menuitem', { name: label, exact: true }).click()
  229. await expect.poll(() => viewer.innerText()).toBe(label)
  230. if (label === 'Code') {
  231. await preview.locator('.shiki').waitFor({ timeout: 15_000 })
  232. expect(await preview.locator('.shiki').textContent()).toBe(markdownText)
  233. } else {
  234. await expect.poll(async () => (await preview.locator('[data-textpreview-line]').first().textContent())?.trim()).toBe('# Markdown smoke')
  235. }
  236. const source = label === 'Code' ? preview.locator('.shiki') : preview.locator('[data-textpreview-line]').first()
  237. sourceFonts.push(await source.evaluate((node) => {
  238. const style = getComputedStyle(node)
  239. return { fontSize: style.fontSize, lineHeight: style.lineHeight }
  240. }))
  241. expect(await markdownTab.getAttribute('data-dockkit-tab')).toBe(markdownTabId)
  242. expect(await column.locator('[data-dockkit-tab]').count()).toBe(tabCount)
  243. markdownViewers.push(await viewer.innerText())
  244. }
  245. expect(sourceFonts[1]).toEqual(sourceFonts[0])
  246. sections.push([
  247. '## Markdown', '',
  248. `- Heading: ${heading}`,
  249. `- Tail loaded by scrolling: ${tailHeading}`,
  250. `- Viewers: ${markdownViewers.join(' -> ')}`,
  251. `- Same tab: ${String(await markdownTab.getAttribute('data-dockkit-tab') === markdownTabId)}`,
  252. ].join('\n'))
  253. await openFile('smoke.html')
  254. await expect.poll(() => viewer.innerText()).toBe('HTML')
  255. const iframe = preview.locator('[data-html-preview]')
  256. await iframe.waitFor({ timeout: 15_000 })
  257. expect(await iframe.getAttribute('sandbox')).toBe('allow-scripts')
  258. expect(await iframe.evaluate((node) => {
  259. const host = node.closest('[data-textpreview-body]')
  260. if (!(host instanceof HTMLElement)) throw new Error('HTML preview body is unavailable')
  261. const outer = host.getBoundingClientRect()
  262. const frame = node.getBoundingClientRect()
  263. return {
  264. top: Math.round(frame.top - outer.top),
  265. right: Math.round(outer.right - frame.right),
  266. bottom: Math.round(outer.bottom - frame.bottom),
  267. left: Math.round(frame.left - outer.left),
  268. }
  269. })).toEqual({ top: 0, right: 0, bottom: 0, left: 0 })
  270. const html = page.frameLocator('[data-html-preview]')
  271. await html.getByRole('heading', { name: 'HTML smoke', exact: true }).waitFor({ timeout: 15_000 })
  272. await expect.poll(() => html.locator('#result').innerText()).toBe('INLINE_OK')
  273. await expect.poll(() => html.locator('#local-result').innerText()).toBe('LOCAL_JS_OK')
  274. await expect.poll(() => html.locator('#outside-result').innerText()).toBe('OUTSIDE_JS_OK')
  275. await expect.poll(() => html.locator('#local-result').evaluate(node => getComputedStyle(node).color)).toBe('rgb(12, 34, 56)')
  276. await expect.poll(() => html.locator('#parent-result').innerText()).toBe('parent-blocked')
  277. expect(await html.locator('#parent-result').getAttribute('data-error')).toBe('SecurityError')
  278. expect(await page.locator('html').getAttribute('data-document-preview-escape')).toBeNull()
  279. await successShot(page, 'html')
  280. sections.push([
  281. '## HTML', '',
  282. `- Viewer: ${await viewer.innerText()}`,
  283. `- Sandbox: ${await iframe.getAttribute('sandbox')}`,
  284. `- Inline script: ${await html.locator('#result').innerText()}`,
  285. `- Local script: ${await html.locator('#local-result').innerText()}`,
  286. `- Outside-workspace script: ${await html.locator('#outside-result').innerText()}`,
  287. `- Local stylesheet: ${await html.locator('#local-result').evaluate(node => getComputedStyle(node).color)}`,
  288. `- Parent access: ${await html.locator('#parent-result').innerText()} (${await html.locator('#parent-result').getAttribute('data-error')})`,
  289. `- Parent unchanged: ${String(await page.locator('html').getAttribute('data-document-preview-escape') === null)}`,
  290. ].join('\n'))
  291. await openFile('smoke.pdf')
  292. const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true })
  293. await canvas.waitFor({ state: 'visible', timeout: 30_000 })
  294. expect(await viewer.count()).toBe(0)
  295. expect(await preview.locator('[role="toolbar"]').count()).toBe(0)
  296. expect(await preview.locator('[data-pdf-page]').count()).toBe(2)
  297. await expect.poll(() => canvasColor(canvas), { timeout: 30_000 }).toBe('red')
  298. const firstColor = await canvasColor(canvas)
  299. expect(firstColor).toBe('red')
  300. const workerNames = await Promise.all(page.workers().map(worker => worker.evaluate(() => self.name)))
  301. expect(workerNames).toContain('dsh-pdf')
  302. await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded()
  303. const secondPage = preview.getByRole('img', { name: 'PDF page 2', exact: true })
  304. await secondPage.waitFor({ state: 'visible', timeout: 30_000 })
  305. await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue')
  306. const secondColor = await canvasColor(secondPage)
  307. expect(secondColor).toBe('blue')
  308. expect(await body.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true)
  309. const pdfTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('smoke.pdf', { exact: true }) })
  310. const pdfTabId = await pdfTab.getAttribute('data-dockkit-tab')
  311. expect(pdfTabId).not.toBeNull()
  312. await filesTab.click()
  313. await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
  314. await pdfTab.click()
  315. await preview.locator('[data-pdf-page="2"]').scrollIntoViewIfNeeded()
  316. await secondPage.waitFor({ state: 'visible', timeout: 30_000 })
  317. await expect.poll(() => canvasColor(secondPage), { timeout: 30_000 }).toBe('blue')
  318. const restoredColor = await canvasColor(secondPage)
  319. expect(restoredColor).toBe('blue')
  320. expect(await pdfTab.getAttribute('data-dockkit-tab')).toBe(pdfTabId)
  321. await copyPdfText(page, preview, 'Selectable PDF text')
  322. await successShot(page, 'pdf')
  323. sections.push([
  324. '## PDF', '',
  325. `- Viewer menu hidden: ${String(await viewer.count() === 0)}`,
  326. `- Worker: ${workerNames.find(name => name === 'dsh-pdf')}`,
  327. `- Continuous pages: ${await preview.locator('[data-pdf-page]').count()}`,
  328. `- Horizontal overflow: ${String(await body.evaluate(node => node.scrollWidth > node.clientWidth))}`,
  329. `- Canvas fills: ${[firstColor, secondColor, restoredColor].join(' -> ')}`,
  330. `- Same tab: ${String(await pdfTab.getAttribute('data-dockkit-tab') === pdfTabId)}`,
  331. '- Selected and copied text: Selectable PDF text',
  332. ].join('\n'))
  333. await openFile('user-unit.pdf')
  334. await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
  335. await copyPdfText(page, preview, 'Selectable PDF text')
  336. sections.push('## PDF page units\n\n- UserUnit 2: selected and copied text aligns with the canvas')
  337. const viewportSize = page.viewportSize()!
  338. try {
  339. for (const rotation of [90, 180, 270]) {
  340. await openFile(`rotated-${rotation}.pdf`)
  341. for (const width of [viewportSize.width, 1280]) {
  342. await page.setViewportSize({ ...viewportSize, width })
  343. await copyPdfText(page, preview, 'Selectable PDF text')
  344. // The fixture's only black pixels are text; canvas ink is independent of the overlay geometry.
  345. await expect.poll(() => preview.locator('[data-pdf-page]').first().evaluate((node) => {
  346. const canvas = node.querySelector('canvas')!
  347. const canvasBox = canvas.getBoundingClientRect()
  348. const textBox = node.querySelector('.textLayer span')!.getBoundingClientRect()
  349. const pixels = canvas.getContext('2d')!.getImageData(0, 0, canvas.width, canvas.height).data
  350. let ink = 0
  351. let aligned = 0
  352. for (let i = 0; i < pixels.length; i += 4) {
  353. if (pixels[i + 3]! < 128 || Math.max(pixels[i]!, pixels[i + 1]!, pixels[i + 2]!) > 80) continue
  354. ink++
  355. const x = canvasBox.left + ((i / 4) % canvas.width + 0.5) * canvasBox.width / canvas.width
  356. const y = canvasBox.top + (Math.floor(i / 4 / canvas.width) + 0.5) * canvasBox.height / canvas.height
  357. if (x >= textBox.left - 1 && x <= textBox.right + 1 && y >= textBox.top - 1 && y <= textBox.bottom + 1) aligned++
  358. }
  359. return ink === 0 ? 0 : aligned / ink
  360. })).toBeGreaterThan(0.95)
  361. }
  362. }
  363. } finally { await page.setViewportSize(viewportSize) }
  364. sections.push('## PDF page rotation\n\n- 90, 180, 270 degrees: selection and copied text align with canvas ink before and after resizing')
  365. await openFile('selection.pdf')
  366. await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
  367. const selectionLayer = preview.locator('.textLayer')
  368. const selectionText = selectionLayer.locator('span:not(.markedContent)')
  369. const titleText = selectionText.filter({ hasText: 'JOURNAL' })
  370. const priorityText = selectionText.filter({ hasText: 'HIGH / MEDIUM / LOW' }).first()
  371. // The text resize observer aligns the overlay after the canvas becomes visible.
  372. await titleText.waitFor({ state: 'visible' })
  373. await priorityText.waitFor({ state: 'visible' })
  374. const titleBox = await titleText.boundingBox()
  375. const priorityBox = await priorityText.boundingBox()
  376. if (titleBox === null || priorityBox === null) throw new Error('selection fixture text has no bounds')
  377. const start = { x: titleBox.x + 1, y: titleBox.y + titleBox.height / 2 }
  378. const end = { x: priorityBox.x + priorityBox.width / 2, y: priorityBox.y - 3 }
  379. const drag = async (from: typeof start, to: typeof end, through?: typeof end): Promise<string> => {
  380. await page.mouse.move(from.x, from.y)
  381. await page.mouse.down()
  382. try {
  383. if (through !== undefined) await page.mouse.move(through.x, through.y, { steps: 15 })
  384. await page.mouse.move(to.x, to.y, { steps: 15 })
  385. return await page.evaluate(() => window.getSelection()?.toString() ?? '')
  386. } finally { await page.mouse.up() }
  387. }
  388. const priority = { x: end.x, y: priorityBox.y + priorityBox.height / 2 }
  389. const forward = await drag(start, end, priority)
  390. expect(forward).toContain('JOURNAL')
  391. expect(forward).toContain('THREE TASKS')
  392. expect(forward).not.toContain('REFLECTION')
  393. expect(forward).not.toContain('AFTER TABLE')
  394. const backward = await drag(priority, start)
  395. expect(backward).toContain('THREE TASKS')
  396. expect(backward).not.toContain('REFLECTION')
  397. expect(backward).not.toContain('AFTER TABLE')
  398. expect(await selectionLayer.locator('br').first().evaluate(node => getComputedStyle(node, '::selection').backgroundColor))
  399. .toBe('rgba(0, 0, 0, 0)')
  400. await successShot(page, 'pdf-drag-selection')
  401. sections.push('## PDF drag selection\n\n- Table selection: forward and backward drags exclude later sections\n- Line-break highlight: transparent')
  402. await openFile('tiny.png')
  403. const tinyImage = preview.getByRole('img', { name: 'Image preview: tiny.png', exact: true })
  404. await tinyImage.waitFor({ state: 'visible', timeout: 15_000 })
  405. expect(await viewer.count()).toBe(0)
  406. expect(await tinyImage.evaluate(node => ({
  407. width: (node as HTMLImageElement).naturalWidth,
  408. height: (node as HTMLImageElement).naturalHeight,
  409. draggable: (node as HTMLImageElement).draggable,
  410. }))).toEqual({ width: 1, height: 1, draggable: false })
  411. const centering = await tinyImage.evaluate((node) => {
  412. const image = node.getBoundingClientRect()
  413. const scroller = node.closest('[data-textpreview-body]')?.getBoundingClientRect()
  414. if (scroller === undefined) throw new Error('image document scroller is unavailable')
  415. return {
  416. horizontal: Math.abs((image.left + image.width / 2) - (scroller.left + scroller.width / 2)),
  417. vertical: Math.abs((image.top + image.height / 2) - (scroller.top + scroller.height / 2)),
  418. }
  419. })
  420. expect(centering.horizontal).toBeLessThan(10)
  421. expect(centering.vertical).toBeLessThan(10)
  422. await openFile('large.svg')
  423. await expect.poll(() => viewer.innerText()).toBe('Image')
  424. await viewer.click()
  425. await page.getByRole('menuitem', { name: 'Plain text', exact: true }).waitFor({ timeout: 15_000 })
  426. await page.keyboard.press('Escape')
  427. const largeImage = preview.getByRole('img', { name: 'Image preview: large.svg', exact: true })
  428. await largeImage.waitFor({ state: 'visible', timeout: 15_000 })
  429. const fitted = await largeImage.evaluate((node) => {
  430. const image = node as HTMLImageElement
  431. const scroller = image.closest('[data-textpreview-body]')
  432. if (scroller === null) throw new Error('image document scroller is unavailable')
  433. const rect = image.getBoundingClientRect()
  434. return {
  435. naturalWidth: image.naturalWidth,
  436. naturalHeight: image.naturalHeight,
  437. width: rect.width,
  438. height: rect.height,
  439. paneWidth: scroller.clientWidth,
  440. paneHeight: scroller.clientHeight,
  441. }
  442. })
  443. expect(fitted).toMatchObject({ naturalWidth: 1200, naturalHeight: 1600 })
  444. expect(fitted.width).toBeLessThan(1200)
  445. // Width fit: the image fills the frame's 12px-inset box while the aspect ratio holds.
  446. expect(Math.abs((fitted.paneWidth - 24) - fitted.width)).toBeLessThanOrEqual(1)
  447. expect(fitted.height / fitted.width).toBeCloseTo(1600 / 1200, 2)
  448. const scrolled = await body.evaluate((node) => {
  449. node.scrollLeft = node.scrollWidth
  450. return { left: node.scrollLeft, horizontalOverflow: node.scrollWidth > node.clientWidth }
  451. })
  452. expect(scrolled).toEqual({ left: 0, horizontalOverflow: false })
  453. expect(await page.locator('html').getAttribute('data-image-preview-escape')).toBeNull()
  454. const releaseRead = Promise.withResolvers<undefined>()
  455. let waitingForRead = false
  456. const readPage = scaffold.ctx.workspaceFiles.read.bind(scaffold.ctx.workspaceFiles)
  457. const heldRead = vi.spyOn(scaffold.ctx.workspaceFiles, 'read').mockImplementation(async (agent, path, range, signal) => {
  458. if (path === 'pages.ts' && (range.offset ?? 1) === 1) {
  459. waitingForRead = true
  460. await releaseRead.promise
  461. }
  462. return readPage(agent, path, range, signal)
  463. })
  464. let initialReading = false
  465. try {
  466. await openFile('pages.ts')
  467. await expect.poll(() => waitingForRead).toBe(true)
  468. const reading = preview.locator('[data-document-loading]')
  469. initialReading = await reading.isVisible()
  470. expect(initialReading).toBe(true)
  471. expect(await preview.locator('[data-code-preview]').count()).toBe(0)
  472. const indicator = await reading.boundingBox()
  473. const scroller = await body.boundingBox()
  474. if (indicator === null || scroller === null) throw new Error('reading indicator or document body is not rendered')
  475. expect(indicator.y).toBeGreaterThanOrEqual(scroller.y)
  476. expect(indicator.y + indicator.height).toBeLessThanOrEqual(scroller.y + scroller.height)
  477. await successShot(page, 'code-reading')
  478. } finally {
  479. releaseRead.resolve(undefined)
  480. heldRead.mockRestore()
  481. }
  482. await expect.poll(() => viewer.innerText()).toBe('Code')
  483. const highlightedLines = preview.locator('.shiki .line')
  484. await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(PAGE_LINES)
  485. const codeBlock = preview.locator('.md-code-block')
  486. const codeScrollport = preview.locator('[data-code-block-content]')
  487. expect(await codeBlock.getAttribute('data-line-numbers')).toBe('true')
  488. await expect.poll(() => highlightedLines.first().evaluate(node => getComputedStyle(node, '::before').content))
  489. .not.toMatch(/^(?:none|normal)$/u)
  490. const numbering = await highlightedLines.first().evaluate((node) => {
  491. const line = getComputedStyle(node)
  492. const before = getComputedStyle(node, '::before')
  493. return {
  494. counterIncrement: line.counterIncrement,
  495. gutterWidth: Number.parseFloat(before.width),
  496. sourceInset: Number.parseFloat(line.paddingInlineStart),
  497. }
  498. })
  499. expect(numbering.counterIncrement).toBe('source-line 1')
  500. expect(numbering.gutterWidth).toBeGreaterThan(0)
  501. expect(numbering.sourceInset).toBeGreaterThan(numbering.gutterWidth)
  502. const prefix = await highlightedLines.allTextContents()
  503. expect(prefix).toEqual(codeLines.slice(0, PAGE_LINES))
  504. await expect.poll(() => preview.locator('[data-textpreview-more]').isEnabled()).toBe(true)
  505. await scrollForNextPage(codeScrollport)
  506. await expect.poll(() => highlightedLines.count(), { timeout: 15_000 }).toBe(codeLines.length)
  507. const completed = await highlightedLines.allTextContents()
  508. expect(completed).toEqual(codeLines)
  509. await expect.poll(() => preview.locator('[data-textpreview-more]').count()).toBe(0)
  510. const scrollTop = await codeScrollport.evaluate((node) => {
  511. const target = Math.floor((node.scrollHeight - node.clientHeight) / 2)
  512. if (target <= 0) throw new Error('code fixture does not overflow the document body')
  513. node.scrollTop = target
  514. return target
  515. })
  516. await expect.poll(() => codeScrollport.evaluate((node) => {
  517. const codeBlock = node.parentElement
  518. const banner = codeBlock?.firstElementChild
  519. const firstLine = node.querySelector('.shiki .line')
  520. if (!(banner instanceof HTMLElement) || firstLine === null) throw new Error('missing rendered code banner or source line')
  521. const bounds = node.getBoundingClientRect()
  522. const clipTop = bounds.top + node.clientTop
  523. const bannerBounds = banner.getBoundingClientRect()
  524. return {
  525. scrollTop: node.scrollTop,
  526. scrollportBelowBanner: Math.abs(bannerBounds.bottom - bounds.top) < 1,
  527. firstLineAbove: firstLine.getBoundingClientRect().top < clipTop,
  528. }
  529. })).toEqual({ scrollTop, scrollportBelowBanner: true, firstLineAbove: true })
  530. await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin })
  531. await page.evaluate(() => navigator.clipboard.writeText(''))
  532. await codeBlock.getByRole('button', { name: 'Copy', exact: true }).click()
  533. await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toBe(codeLines.join('\n'))
  534. sections.push([
  535. '## Code paging', '',
  536. `- Viewer: ${await viewer.innerText()}`,
  537. `- Initial reading indicator: ${initialReading}`,
  538. `- Lines: ${prefix.length} -> ${completed.length}`,
  539. `- Prefix retained: ${String(JSON.stringify(completed.slice(0, prefix.length)) === JSON.stringify(prefix))}`,
  540. `- Tail: ${completed.at(-1)}`,
  541. ].join('\n'))
  542. const officeMenus: number[] = []
  543. const configurationGuide = 'Read failed: Office previews are unavailable. Enable the document preview service on the computer running DeepSeek Harness.'
  544. for (const extension of ['doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx']) {
  545. await openFile(`unavailable.${extension}`)
  546. expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0)
  547. await preview.getByText(configurationGuide, { exact: true }).waitFor({ timeout: 15_000 })
  548. expect(await preview.locator('[data-textpreview-line]').count()).toBe(0)
  549. expect(await preview.getByText('OFFICE_BINARY_PREVIEW', { exact: false }).count()).toBe(0)
  550. officeMenus.push(await viewer.count())
  551. }
  552. await successShot(page, 'office-unavailable')
  553. sections.push([
  554. '## Office unavailable', '',
  555. `- DOC, DOCX, XLS, XLSX, PPT, PPTX viewer menus: ${officeMenus.join(' | ')}`,
  556. `- Guidance: ${configurationGuide}`,
  557. '- Binary text shown: false',
  558. '- Plain-text option and viewer picker: hidden',
  559. ].join('\n'))
  560. await openFile('notes.unknown')
  561. const plainLines = preview.locator('[data-textpreview-line]')
  562. await expect.poll(() => plainLines.count()).toBe(2)
  563. // Plain text is the only candidate, so no viewer menu renders.
  564. expect(await viewer.count()).toBe(0)
  565. const fallback = (await plainLines.allTextContents()).map(line => line.trim())
  566. expect(fallback).toEqual(['UNKNOWN_SUFFIX', 'Plain fallback.'])
  567. sections.push(['## Unknown suffix', '', `- Viewer menu hidden: ${String(await viewer.count() === 0)}`, `- Text: ${fallback.join(' | ')}`].join('\n'))
  568. await filesTab.click()
  569. await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'clip.mp4', exact: true }).click()
  570. const unsupported = column.locator('[data-textpreview-state="unsupported"]')
  571. await unsupported.waitFor({ timeout: 15_000 })
  572. const unsupportedLine = await unsupported.locator('[data-textpreview-unsupported]').innerText()
  573. expect(unsupportedLine).toContain('Preview is not available for this file type yet.')
  574. expect(await unsupported.locator('[data-textpreview-path]').innerText()).toContain('clip.mp4')
  575. expect(await unsupported.locator('[data-document-viewer-menu]').count()).toBe(0)
  576. expect(await unsupported.locator('[data-textpreview-tool="reload"]').count()).toBe(0)
  577. await successShot(page, 'unsupported')
  578. sections.push(['## Unviewable binary', '', '- State: unsupported', `- Line: ${unsupportedLine.trim()}`].join('\n'))
  579. expect(tripwire.pageErrors).toEqual([])
  580. expect(tripwire.warnings).toEqual([])
  581. await compareOrRefreshGolden(EXPECTED, sections.join('\n\n'), MODE)
  582. await assertFixtureInventory(SNAPSHOT_DIR, ['document.expected.md', 'paging.patch.yml'])
  583. })
  584. })
  585. describe.skipIf(MODE === 'record')('web e2e: Host Office preview', () => {
  586. let scaffold: WebScaffold
  587. let browser: Browser
  588. let page: Page
  589. afterAll(async () => {
  590. try { await browser?.close() } finally { await scaffold?.close() }
  591. })
  592. it('rejects renamed text and renders Chinese Office documents through the PDF worker', async () => {
  593. scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false,
  594. extraOverlayPath: fileURLToPath(new URL('../../../packages/client/ui-sidebar-documentpreview/tests/fixtures/office-cache.patch.yml', import.meta.url)),
  595. })
  596. browser = await chromium.launch()
  597. page = await newEnglishPage(browser)
  598. const tripwire = watchConsole(page)
  599. await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
  600. await connectFreshWorkspace(page, scaffold.workspaceCwd)
  601. onTestFailed(async () => {
  602. await saveFailureShot(page, `screenshots/0908-document-preview/office-${process.pid}`)
  603. })
  604. const settled = scaffold.whenTurnSettled()
  605. const input = page.locator('[data-composer-input]').first()
  606. await input.fill(PROMPT)
  607. await input.press('Enter')
  608. const sessionId = await settled
  609. const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd
  610. if (cwd === undefined) throw new Error('settled Session has no workspace cwd')
  611. await Promise.all([
  612. writeFile(join(cwd, 'renamed.docx'), 'This is plain text renamed to docx.'),
  613. writeFile(join(cwd, 'chinese.docx'), realOfficeBytes('docx', 'DSH Missing Preview Font')),
  614. writeFile(join(cwd, 'chinese.xlsx'), realOfficeBytes('xlsx')),
  615. writeFile(join(cwd, 'chinese.pptx'), realOfficeBytes('pptx')),
  616. ...(['doc', 'xls', 'ppt'] as const).map(extension => writeFile(join(cwd, `chinese.${extension}`), realOfficeBytes(extension))),
  617. ...['doc', 'xls', 'ppt'].map(extension => writeFile(join(cwd, `renamed.${extension}`), 'Plain text is not a binary Office document.')),
  618. ])
  619. const convert = vi.spyOn(scaffold.ctx.officeToPdf, 'convert')
  620. try {
  621. const column = page.locator('[data-rightbar-col]')
  622. await page.locator('[data-sidebar-right-expand]').click()
  623. await column.locator('[data-sidebar-right-guide-entry="files"]').click()
  624. await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' })
  625. await column.locator('[data-files-reload]').click()
  626. const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText('Files', { exact: true }) })
  627. const preview = column.locator('[data-document-preview]')
  628. await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'chinese.docx', exact: true }).click()
  629. expect(await preview.locator('[data-document-viewer-menu]').count()).toBe(0)
  630. const canvas = preview.getByRole('img', { name: 'PDF page 1', exact: true })
  631. await canvas.waitFor({ state: 'visible', timeout: 60_000 })
  632. await expect.poll(() => canvas.evaluate((node) => {
  633. const canvas = node as HTMLCanvasElement
  634. const context = canvas.getContext('2d')
  635. if (context === null) return false
  636. const bytes = context.getImageData(0, 0, canvas.width, canvas.height).data
  637. for (let index = 0; index < bytes.length; index += 4) {
  638. if (bytes[index + 3] === 255 && bytes[index]! < 200 && bytes[index + 1]! < 200 && bytes[index + 2]! < 200) return true
  639. }
  640. return false
  641. }), { timeout: 30_000 }).toBe(true)
  642. const workerNames = await Promise.all(page.workers().map(worker => worker.evaluate(() => self.name)))
  643. expect(workerNames).toContain('dsh-pdf')
  644. expect(workerNames.some(name => /libreoffice|soffice/i.test(name))).toBe(false)
  645. await copyPdfText(page, preview, 'Office preview')
  646. await copyPdfText(page, preview, '中文文档')
  647. expect((await preview.locator('[data-pdf-text]').allTextContents()).join('')).toContain('中文文档')
  648. expect(convert).toHaveBeenCalledTimes(1)
  649. await preview.getByRole('button', { name: 'Read the file again', exact: true }).click()
  650. await canvas.waitFor({ state: 'visible' })
  651. expect(convert).toHaveBeenCalledTimes(1)
  652. const notice = preview.locator('[data-office-font-notice]')
  653. const more = notice.getByRole('button', { name: 'Show more', exact: true })
  654. await more.waitFor({ state: 'visible' })
  655. const before = await canvas.evaluate(node => node.getBoundingClientRect().top)
  656. await more.click()
  657. const details = page.getByRole('dialog', { name: 'Missing fonts', exact: true })
  658. await details.getByText('DSH Missing Preview Font', { exact: true }).waitFor({ state: 'visible' })
  659. await successShot(page, 'office-font-details')
  660. await page.keyboard.press('Escape')
  661. await expect.poll(() => details.count()).toBe(0)
  662. expect(await more.evaluate(node => node === document.activeElement)).toBe(true)
  663. await more.click()
  664. await page.getByRole('button', { name: 'Close font details', exact: true }).click()
  665. expect(await more.isVisible()).toBe(true)
  666. await notice.getByRole('button', { name: 'Dismiss font notice', exact: true }).click()
  667. await expect.poll(() => notice.evaluate(node => node.getBoundingClientRect().height)).toBe(0)
  668. const after = await canvas.evaluate(node => node.getBoundingClientRect().top)
  669. expect(before - after).toBeGreaterThan(40)
  670. const topInset = await preview.evaluate((node) => {
  671. const body = node.querySelector('[data-textpreview-body]')!.getBoundingClientRect()
  672. const canvas = node.querySelector('canvas')!.getBoundingClientRect()
  673. return canvas.top - body.top
  674. })
  675. expect(topInset).toBe(0)
  676. await successShot(page, 'office-font-dismissed')
  677. await compareOrRefreshGolden(fileURLToPath(new URL('./expected/office-font-notice.md', import.meta.url)), [
  678. '# Office font notice', '',
  679. '- Requested absent family is listed: true',
  680. '- Escape restores focus to Show more: true',
  681. '- Closing details preserves the notice: true',
  682. '- Dismissing the notice collapses its occupied height: 0',
  683. `- Document top inset after dismissal: ${topInset}px`,
  684. ].join('\n'), MODE)
  685. await successShot(page, 'office-docx')
  686. for (const extension of ['doc', 'xls', 'xlsx', 'ppt', 'pptx']) {
  687. await openPreviewFile(column, filesTab, preview, `chinese.${extension}`)
  688. await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible', timeout: 60_000 })
  689. await expect.poll(async () => (await preview.locator('[data-pdf-text]').allTextContents()).join(''), { timeout: 30_000 }).toContain('中文文档')
  690. await successShot(page, `office-${extension}`)
  691. }
  692. expect(convert).toHaveBeenCalledTimes(6)
  693. await openPreviewFile(column, filesTab, preview, 'chinese.docx')
  694. await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
  695. await preview.getByRole('button', { name: 'Read the file again', exact: true }).click()
  696. await expect.poll(() => convert.mock.calls.length).toBe(7)
  697. await preview.getByRole('img', { name: 'PDF page 1', exact: true }).waitFor({ state: 'visible' })
  698. await openPreviewFile(column, filesTab, preview, 'renamed.docx')
  699. await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 })
  700. expect(await preview.locator('[data-textpreview-line]').count()).toBe(0)
  701. await successShot(page, 'office-invalid')
  702. expect(convert).toHaveBeenCalledTimes(8)
  703. for (const extension of ['doc', 'xls', 'ppt']) {
  704. await openPreviewFile(column, filesTab, preview, `renamed.${extension}`)
  705. await preview.getByText('Read failed: This Office file cannot be previewed. It may be damaged, password protected, or have the wrong extension.', { exact: true }).waitFor({ timeout: 30_000 })
  706. expect(await preview.locator('[data-textpreview-line]').count()).toBe(0)
  707. }
  708. expect(convert).toHaveBeenCalledTimes(11)
  709. expect(tripwire.pageErrors).toEqual([])
  710. } finally { convert.mockRestore() }
  711. })
  712. })