/** Keyless document-preview smoke through a real Session, Files tab, shipped renderers, and the default-application controls. */ import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { nativeFileManager } from '@deepseek-ai/dsh-native-command' import { delimiter, join, relative } from 'node:path' import { fileURLToPath } from 'node:url' import type { Browser, Locator, Page } from 'playwright' import { chromium } from 'playwright' import { afterAll, beforeAll, describe, expect, it, onTestFailed, vi } from 'vitest' import { createLaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment' import { realOfficeBytes } from './office-fixture.ts' import { excelFixture, excelFreezeFixture, excelHtmlFixture, excelHtmlText, meetingMinutesFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-fixture.ts' import { excelDrawingFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/excel-drawing-fixture.ts' import { xlsFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/xls-fixture.ts' import { pdfFixture, selectionPdfFixture } from '../../../packages/client/ui-sidebar-documentpreview/tests/pdf-fixture.ts' import { assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts' import { openSettings, connectFreshWorkspace, connectFreshWorkspaceZh, newEnglishPage, saveFailureShot, scrollIntoView } from './support.ts' const FIXTURE = fileURLToPath(new URL('../../../snapshots/web/lifecycle-chrome/session.v3.jsonl', import.meta.url)) const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/document-preview', import.meta.url)) const EXPECTED = join(SNAPSHOT_DIR, 'document.expected.md') const PAGING_PATCH = join(SNAPSHOT_DIR, 'paging.patch.yml') const PAGE_LINES = 64 const SHOT_DIR = fileURLToPath(new URL('../../../.artifacts/screenshots/0908-document-preview', import.meta.url)) const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' const MODE = webSnapshotMode() /** The stubbed opener runs as a POSIX script; Windows and WSL keep their real file associations out of the lane. */ const STUB_OPENER = nativeFileManager() !== 'explorer' const TINY_PNG = Buffer.from( 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=', 'base64', ) /** Successful render evidence stays outside the committed snapshot inventory. */ async function successShot(page: Page, name: string): Promise { await mkdir(SHOT_DIR, { recursive: true }) await page.screenshot({ path: join(SHOT_DIR, `${name}-${MODE}-${process.pid}.png`), fullPage: true }) } /** Check the visible loader group against the document body's center. */ async function expectDocumentLoading(preview: Locator): Promise { const loading = preview.getByRole('status', { name: 'Rendering document...', exact: true }) await expect.poll(() => loading.textContent()).toBe('Rendering document...') await expect.poll(() => loading.evaluate((node) => { const body = node.closest('[data-textpreview-body]')!.getBoundingClientRect() const spinner = node.querySelector('svg')!.getBoundingClientRect() const label = node.querySelector('span')!.getBoundingClientRect() return Math.max(Math.abs(spinner.width - 28), Math.abs(spinner.height - 28), Math.abs(spinner.x + spinner.width / 2 - body.x - body.width / 2), Math.abs((spinner.top + label.bottom) / 2 - body.y - body.height / 2)) })).toBeLessThanOrEqual(1) } /** Paper edges expose the document backdrop even when fixed zoom overflows the viewport. */ async function expectPdfPageSpacing(preview: Locator): Promise { await preview.getByRole('img', { name: 'PDF page 2', exact: true }).waitFor({ state: 'visible' }) await expect.poll(() => preview.locator('[data-pdf-preview]').evaluate((node) => { const body = node.getBoundingClientRect() const pages = [...node.querySelectorAll('canvas')].map(page => page.getBoundingClientRect()) const first = pages[0]! const last = pages.at(-1)! const inset = Math.min(first.top - body.top, body.bottom - last.bottom, ...pages.flatMap(page => [page.left - body.left, body.right - page.right])) return Math.max(Math.abs(pages[1]!.top - first.bottom - 12), 12 - inset) })).toBeLessThanOrEqual(1) } /** Exercise native browser selection and copy, including the text overlay's canvas alignment. */ async function copyPdfText(page: Page, preview: Locator, expected: string): Promise { const text = preview.locator('[data-pdf-text] span:not(.markedContent)').filter({ hasText: expected }).first() await text.waitFor({ state: 'visible' }) await expect.poll(() => text.evaluate(node => getComputedStyle(node).userSelect)).toBe('text') await expect.poll(() => text.evaluate(node => ({ background: getComputedStyle(node, '::selection').backgroundColor, color: getComputedStyle(node, '::selection').color, }))).toEqual({ background: 'color(srgb 0.231373 0.509804 0.964706 / 0.4)', color: 'rgba(0, 0, 0, 0)' }) await text.click({ clickCount: 3 }) await expect.poll(() => page.evaluate(() => window.getSelection()?.toString().trim())).toBe(expected) await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) await page.keyboard.press('ControlOrMeta+C') await expect.poll(() => page.evaluate(async () => (await navigator.clipboard.readText()).trim())).toBe(expected) await expect.poll(() => preview.locator('[data-pdf-page]').first().evaluate((node) => { const canvas = node.querySelector('canvas')!.getBoundingClientRect() const layer = node.querySelector('.textLayer')!.getBoundingClientRect() return Math.max(Math.abs(layer.width - canvas.width), Math.abs(layer.height - canvas.height), Math.abs(layer.left - canvas.left), Math.abs(layer.top - canvas.top)) })).toBeLessThan(1) } /** Trigger the document owner's native scroll handler after a real first page overflows. */ async function scrollForNextPage(body: Locator): Promise { await body.evaluate((node) => { if (node.scrollHeight <= node.clientHeight) throw new Error('paging fixture does not overflow the document body') node.scrollTop = node.scrollHeight }) } /** Read the solid vector fill away from antialiased page edges. */ async function canvasColor(canvas: Locator): Promise { return await canvas.evaluate((node) => { const surface = node as HTMLCanvasElement const context = surface.getContext('2d') if (context === null) throw new Error('PDF canvas has no 2D context') const pixel = context.getImageData(Math.floor(surface.width / 2), Math.floor(surface.height / 2), 1, 1).data if (Number(pixel[3]) !== 255) return 'transparent' if (Number(pixel[0]) - Number(pixel[2]) > 150) return 'red' if (Number(pixel[2]) - Number(pixel[0]) > 150) return 'blue' return 'other' }) } /** Wait for device resolution, subject to the page bitmap allocation limit. */ async function expectPdfResolution(canvas: Locator): Promise { await expect.poll(() => canvas.evaluate((node) => { const bitmap = node as HTMLCanvasElement const width = Number.parseFloat(bitmap.style.getPropertyValue('--pdf-page-width')) const height = Number.parseFloat(bitmap.style.getPropertyValue('--pdf-page-height')) const expected = Math.min(bitmap.getBoundingClientRect().width * window.devicePixelRatio, Math.sqrt(16_777_216 * width / height)) return Math.abs(bitmap.width - expected) })).toBeLessThanOrEqual(1) } /** Select a workspace file through the Files tab and wait for its preview identity. */ async function openPreviewFile(column: Locator, filesTab: Locator, preview: Locator, name: string): Promise { await filesTab.click() await column.locator('[data-files-entry="file"]').getByRole('button', { name, exact: true }).click() await expect.poll(async () => (await preview.getAttribute('data-textpreview-url'))?.endsWith(`/${name}`)).toBe(true) } /** Move into the bottom reveal zone and wait for the shared zoom control. */ async function revealDocumentZoom(page: Page, preview: Locator): Promise { const frame = preview.locator('[data-document-zoom-frame]') const bounds = await frame.boundingBox() if (bounds === null) throw new Error('document zoom frame has no bounds') await page.mouse.move(bounds.x + bounds.width / 2, bounds.y + bounds.height - 8) const controls = preview.locator('[data-document-zoom-controls]') await expect.poll(() => controls.getAttribute('data-document-zoom-visible')).toBe('true') return preview.getByRole('button', { name: 'Choose zoom', exact: true }) } /** Leave the bottom reveal zone and wait for the delayed dismissal. */ async function hideDocumentZoom(page: Page, preview: Locator): Promise { const frame = preview.locator('[data-document-zoom-frame]') const bounds = await frame.boundingBox() if (bounds === null) throw new Error('document zoom frame has no bounds') await page.mouse.move(bounds.x + 8, bounds.y + 8) await expect.poll(() => preview.locator('[data-document-zoom-controls]').getAttribute('data-document-zoom-visible')).toBeNull() } /** The canvas fills the space between the formula bar and sheet tabs without a statistics row. */ async function expectExcelLayout(excel: Locator): Promise { await expect.poll(() => excel.locator('.fortune-stat-area').isVisible()).toBe(false) expect(await excel.locator('.luckysheet-sheets-item-function:visible').count()).toBe(0) await expect.poll(() => excel.evaluate((node) => { const pane = node.getBoundingClientRect() const canvas = node.querySelector('canvas')!.getBoundingClientRect() const formula = node.querySelector('.fortune-workarea')!.getBoundingClientRect() const tabs = node.querySelector('.luckysheet-sheet-area')!.getBoundingClientRect() const scroller = node.querySelector('.fortune-sheettab-container')!.getBoundingClientRect() const controls = [...node.querySelectorAll('.fortune-sheettab-scroll, .fortune-zoom-button')] .map(control => control.getBoundingClientRect()) return Math.max(Math.abs(canvas.left - pane.left), Math.abs(canvas.width - pane.width), Math.abs(canvas.top - formula.bottom), Math.abs(canvas.bottom - tabs.top), Math.abs(tabs.bottom - pane.bottom), Math.abs(scroller.left - tabs.left), scroller.right - controls[0]!.left, ...controls.map((control, index) => control.right - (controls[index + 1]?.left ?? tabs.right))) })).toBeLessThanOrEqual(1) expect(await excel.locator('.fortune-sheettab-scroll, .fortune-zoom-button').evaluateAll(controls => controls.every((control) => { const rect = control.getBoundingClientRect() return control.contains(document.elementFromPoint(rect.x + rect.width / 2, rect.y + rect.height / 2)) }))).toBe(true) } it.skipIf(MODE === 'record').each(['en-US', 'zh-CN'])('fills the spreadsheet pane with read-only controls in %s', async (locale) => { const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false }) let browser: Browser | undefined try { browser = await chromium.launch() const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale, timezoneId: 'Asia/Shanghai' }) const consoleErrors = watchConsole(page) onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-resize-${locale}-${process.pid}`) }) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await (locale === 'zh-CN' ? connectFreshWorkspaceZh : connectFreshWorkspace)(page, scaffold.workspaceCwd) const settled = scaffold.whenTurnSettled() const input = page.locator('[data-composer-input]').first() await input.fill(PROMPT) await input.press('Enter') const sessionId = await settled const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd if (cwd === undefined) throw new Error('settled Session has no workspace cwd') const longCellText = 'Monthly export/import matrix and category comparison. 各商品类别分月出口/进口矩阵与结构对比图(公式汇总) '.repeat(3) await Promise.all([ writeFile(join(cwd, 'budget.xlsx'), await excelFixture()), writeFile(join(cwd, 'meeting.xlsx'), await meetingMinutesFixture()), writeFile(join(cwd, 'legacy.xls'), xlsFixture()), writeFile(join(cwd, 'values.csv'), `${longCellText},2\n3,4\n`), writeFile(join(cwd, 'values.tsv'), '1\t2\n3\t4\n'), ]) await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() const column = page.locator('[data-rightbar-col]') await page.locator('[data-sidebar-right-expand]').click() await column.locator('[data-sidebar-right-guide-entry="files"]').click() await column.locator('[data-files-state="tree"]').waitFor({ state: 'visible' }) await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'budget.xlsx', exact: true }).click() const excel = column.locator('[data-excel-preview]') await excel.locator('.luckysheet-sheets-item-name').getByText('公式与格式', { exact: true }).click() await excel.locator('.fortune-sheet-overlay').click({ position: { x: 140, y: 30 } }) const formula = excel.locator('.fortune-fx-input') await expect.poll(() => formula.innerText()).toBe('46281') await page.keyboard.press('Shift+ArrowLeft') const selection = excel.locator('.fortune-name-box') await expect.poll(() => selection.innerText()).toBe('A1:B1') await expectExcelLayout(excel) await page.context().grantPermissions(['clipboard-read', 'clipboard-write'], { origin: new URL(page.url()).origin }) await page.keyboard.press('ControlOrMeta+C') await expect.poll(() => page.evaluate(() => navigator.clipboard.readText())).toContain('42\t2026-09-16') const arrow = excel.locator('.luckysheet-sheets-item-function').first() await arrow.evaluate((node) => { (node as HTMLElement).focus() }) expect(await arrow.evaluate(node => document.activeElement === node)).toBe(false) const canvas = excel.locator('canvas').first() const originalCanvas = await canvas.elementHandle() const original = await canvas.boundingBox() if (original === null || originalCanvas === null) throw new Error('spreadsheet canvas is unavailable') const panel = page.locator('[data-sidebar-right-panel]') const panelBounds = await panel.boundingBox() if (panelBounds === null) throw new Error('sidebar panel is unavailable') const viewport = page.viewportSize() const layout = await page.addStyleTag({ content: `[data-sidebar-right-panel] { width: ${panelBounds.width + 160}px !important; }` }) await expect.poll(async () => Math.round((await canvas.boundingBox())!.width - original.width)).toBe(160) await expect.poll(() => formula.innerText()).toBe('46281') expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true) expect(page.viewportSize()).toEqual(viewport) await expectExcelLayout(excel) await successShot(page, `excel-resize-wide-${locale}`) await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' }) await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(360) await expectExcelLayout(excel) expect(await excel.locator('.fortune-sheettab-container').evaluate((node) => { const selected = node.querySelector('.luckysheet-sheets-item-active')!.getBoundingClientRect() return selected.right - node.getBoundingClientRect().right })).toBeLessThanOrEqual(1) await page.setViewportSize({ width: 1680, height: 600 }) await expect.poll(async () => Math.round(original.height - (await canvas.boundingBox())!.height)).toBe(400) await expectExcelLayout(excel) expect(await selection.innerText()).toBe('A1:B1') await successShot(page, `excel-resize-narrow-${locale}`) await page.setViewportSize(viewport!) await layout.evaluate((node) => { node.textContent = '' }) await expect.poll(async () => Math.round((await canvas.boundingBox())!.width)).toBe(Math.round(original.width)) expect(await formula.innerText()).toBe('46281') expect(await selection.innerText()).toBe('A1:B1') expect(await originalCanvas.evaluate(node => node.isConnected)).toBe(true) await expectExcelLayout(excel) await successShot(page, `excel-resize-restored-${locale}`) const zoom = excel.locator('.fortune-zoom-ratio-current') await excel.locator('.fortune-zoom-button').first().click() await expect.poll(() => zoom.innerText()).toBe('90%') await excel.locator('.fortune-zoom-button').last().click() await expect.poll(() => zoom.innerText()).toBe('100%') await zoom.click() await excel.locator('.fortune-zoom-ratio-item').getByText('150%', { exact: true }).click() await expect.poll(() => zoom.innerText()).toBe('150%') await zoom.click() await excel.locator('.fortune-zoom-ratio-item').getByText('100%', { exact: true }).click() const filesTab = column.locator('[data-dockkit-tab]').filter({ has: page.getByText(locale === 'zh-CN' ? '文件' : 'Files', { exact: true }) }) const preview = column.locator('[data-textpreview-url]') await openPreviewFile(column, filesTab, preview, 'meeting.xlsx') const tabScroller = excel.locator('.fortune-sheettab-container-c') const activeSheet = excel.locator('.luckysheet-sheets-item-active .luckysheet-sheets-item-name') await expect.poll(() => activeSheet.innerText()).toBe('会议信息') const meetingCanvas = await canvas.elementHandle() if (meetingCanvas === null) throw new Error('meeting spreadsheet canvas is unavailable') await expectExcelLayout(excel) // Arrow keys pick the cell from committed state, so a later render cannot re-resolve it // against the reflowed grid the way it re-resolves a pointer press. const overlay = excel.locator('.fortune-sheet-overlay') await overlay.focus() await expect.poll(() => overlay.evaluate(node => document.activeElement === node)).toBe(true) await page.keyboard.press('ArrowRight') await expect.poll(() => selection.innerText()).toBe('B1') await expect.poll(() => formula.innerText()).toBe('') await page.keyboard.press('ArrowLeft') await expect.poll(() => selection.innerText()).toBe('A1') await expect.poll(() => formula.innerText()).toBe('会议纪要') for (const width of [1000, 360, 1000, 360]) { await layout.evaluate((node, width) => { node.textContent = `[data-sidebar-right-panel] { width: ${width}px !important; }` }, width) await expect.poll(async () => Math.round((await panel.boundingBox())!.width)).toBe(width) await expectExcelLayout(excel) expect(await meetingCanvas.evaluate(node => node.isConnected)).toBe(true) await expect.poll(() => selection.innerText()).toBe('A1') await expect.poll(() => excel.locator('.fortune-sheettab-scroll').count()).toBe(width === 360 ? 2 : 0) } const gridOffset = await excel.locator('.luckysheet-scrollbar-x').evaluate(node => node.scrollLeft) await tabScroller.hover() await page.mouse.wheel(180, 0) await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBeGreaterThan(0) await page.mouse.wheel(-1000, 0) await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBe(0) expect(await activeSheet.innerText()).toBe('会议信息') expect(await selection.innerText()).toBe('A1') expect(await zoom.innerText()).toBe('100%') expect(await excel.locator('.luckysheet-scrollbar-x').evaluate(node => node.scrollLeft)).toBe(gridOffset) for (const direction of ['right', 'left'] as const) { const arrow = excel.locator(`#fortune-sheettab-${direction}scroll`) for (let step = 0; step < 8; step += 1) { const { offset, maximum } = await tabScroller.evaluate(node => ({ offset: node.scrollLeft, maximum: node.scrollWidth - node.clientWidth, })) const target = direction === 'right' ? Math.min(offset + 150, maximum) : Math.max(offset - 150, 0) if (Math.abs(target - offset) <= 1) break await arrow.click() await expect.poll(() => tabScroller.evaluate(node => node.scrollLeft)).toBeCloseTo(target, 0) } const sheetName = direction === 'right' ? '填写说明' : '会议信息' await excel.locator('.luckysheet-sheets-item-name').getByText(sheetName, { exact: true }).click() await expect.poll(() => activeSheet.innerText()).toBe(sheetName) await expectExcelLayout(excel) await successShot(page, `excel-navigation-${direction}-${locale}`) } expect(await tabScroller.evaluate(node => Math.abs( node.firstElementChild!.getBoundingClientRect().left - node.getBoundingClientRect().left, ))).toBeLessThanOrEqual(1) await excel.locator('.fortune-zoom-button').first().click() await expect.poll(() => zoom.innerText()).toBe('90%') await excel.locator('.fortune-zoom-button').last().click() await expect.poll(() => zoom.innerText()).toBe('100%') await zoom.click() await excel.locator('.fortune-zoom-ratio-item').getByText('150%', { exact: true }).click() await expect.poll(() => zoom.innerText()).toBe('150%') expect(await activeSheet.innerText()).toBe('会议信息') await expectExcelLayout(excel) await successShot(page, `excel-navigation-zoom-${locale}`) await layout.evaluate((node) => { node.textContent = '' }) await meetingCanvas.dispose() for (const colorScheme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme }) await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null) for (const name of ['budget.xlsx', 'meeting.xlsx', 'legacy.xls', 'values.csv', 'values.tsv']) { await openPreviewFile(column, filesTab, preview, name) await excel.locator('.fortune-sheet-overlay').waitFor() await expectExcelLayout(excel) expect(await selection.evaluate(node => ({ text: getComputedStyle(node).color, background: getComputedStyle(node).backgroundColor, }))).toEqual({ text: 'rgb(0, 0, 0)', background: 'rgb(255, 255, 255)' }) if (name === 'values.csv') { await excel.locator('.fortune-sheet-overlay').click({ position: { x: 70, y: 30 } }) await expect.poll(() => formula.textContent()).toBe(longCellText) await layout.evaluate((node) => { node.textContent = '[data-sidebar-right-panel] { width: 360px !important; }' }) await expectExcelLayout(excel) await expect.poll(() => formula.evaluate((node) => { const range = document.createRange() range.selectNodeContents(node) return range.getClientRects().length })).toBe(1) expect(await formula.evaluate(node => node.scrollHeight <= node.clientHeight)).toBe(true) expect(await formula.evaluate(node => node.scrollWidth > node.clientWidth)).toBe(true) await formula.hover() await page.mouse.wheel(400, 0) await expect.poll(() => formula.evaluate(node => node.scrollLeft)).toBeGreaterThan(0) await successShot(page, `excel-formula-single-line-${locale}-${colorScheme}`) await layout.evaluate((node) => { node.textContent = '' }) await expectExcelLayout(excel) } await successShot(page, `excel-controls-${name}-${locale}-${colorScheme}`) } } expect(consoleErrors.pageErrors).toEqual([]) await originalCanvas.dispose() } finally { try { await browser?.close() } finally { await scaffold.close() } } }) /** Pan with native pixel wheel input in every direction and along the sheet edges. */ async function expectExcelPanning(page: Page, excel: Locator): Promise { // Chromium's CDP wheel distances scale with the emulated device pixel ratio. const scale = await page.evaluate(() => window.devicePixelRatio) const wheel = async (x: number, y: number) => { await page.mouse.wheel(x * scale, y * scale) } const offset = async () => await excel.evaluate(node => ({ x: node.querySelector('.luckysheet-scrollbar-x')!.scrollLeft, y: node.querySelector('.luckysheet-scrollbar-y')!.scrollTop, })) const selection = await excel.locator('.fortune-name-box').innerText() await excel.evaluate((node) => { node.querySelector('.luckysheet-scrollbar-x')!.scrollLeft = 240 node.querySelector('.luckysheet-scrollbar-y')!.scrollTop = 240 }) await expect.poll(offset).toEqual({ x: 240, y: 240 }) await excel.locator('.fortune-sheet-overlay').hover({ position: { x: 260, y: 160 } }) for (const [dx, dy] of [[37, 19], [-13, 27], [-19, -11], [23, -17], [11, 0], [0, 13], [-9, 0], [0, -7]] as const) { const before = await offset() await wheel(dx, dy) await expect.poll(offset).toEqual({ x: before.x + dx, y: before.y + dy }) } expect(await excel.locator('.fortune-name-box').innerText()).toBe(selection) await wheel(-10000, -10000) await expect.poll(offset).toEqual({ x: 0, y: 0 }) await wheel(-20, 20) await expect.poll(offset).toEqual({ x: 0, y: 20 }) await wheel(20, -20) await expect.poll(offset).toEqual({ x: 20, y: 0 }) await wheel(-20, 0) await expect.poll(offset).toEqual({ x: 0, y: 0 }) } /** Sample the pixel band before the divider center, excluding the adjacent cell grid line. */ async function freezeDividerInk(excel: Locator, axis: 'x' | 'y'): Promise { return await excel.evaluate((node, direction) => { const canvas = node.querySelector('canvas')! const bounds = canvas.getBoundingClientRect() const scale = canvas.width / bounds.width const columnHeader = node.querySelector('.fortune-col-header')! const rowHeader = node.querySelector('.fortune-row-header')! const columnHandle = node.querySelector('.fortune-cols-freeze-handle')! const rowHandle = node.querySelector('.fortune-rows-freeze-handle')! // Canvas coordinates include the 1.5px omitted from FortuneSheet's header elements. const x = Number.parseFloat(rowHeader.style.width) + 1.5 + Number.parseFloat(columnHandle.style.left) - columnHeader.scrollLeft const y = Number.parseFloat(columnHeader.style.height) + 1.5 + Number.parseFloat(rowHandle.style.top) - rowHeader.scrollTop - 2 const context = canvas.getContext('2d')! let ink = 0 for (let offset = -Math.round(scale); offset < 0; offset += 1) { const px = direction === 'x' ? Math.floor(x * scale) + offset : Math.floor((bounds.width - 25) * scale) const py = direction === 'y' ? Math.floor(y * scale) + offset : Math.floor((y + 15) * scale) const pixel = context.getImageData(px, py, 1, 1).data ink += 255 - pixel[0]! } return ink / scale }, axis) } it.skipIf(MODE === 'record').each([1, 2])('keeps frozen headings without dividers at DPR %i', async (deviceScaleFactor) => { const scaffold = await launchWebScaffold({ replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false }) let browser: Browser | undefined try { browser = await chromium.launch() const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, deviceScaleFactor, locale: 'en-US' }) const consoleErrors = watchConsole(page) onTestFailed(async () => { await saveFailureShot(page, `screenshots/0908-document-preview/excel-freeze-${deviceScaleFactor}-${process.pid}`) }) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await connectFreshWorkspace(page, scaffold.workspaceCwd) const settled = scaffold.whenTurnSettled() await page.locator('[data-composer-input]').first().fill(PROMPT) await page.locator('[data-composer-input]').first().press('Enter') const cwd = scaffold.ctx.agents.get(await settled)?.session.header.cwd if (cwd === undefined) throw new Error('settled Session has no workspace cwd') await writeFile(join(cwd, 'freeze.xlsx'), await excelFreezeFixture()) await page.getByText('LIGHTHOUSE', { exact: true }).waitFor() const column = page.locator('[data-rightbar-col]') await page.locator('[data-sidebar-right-expand]').click() await column.locator('[data-sidebar-right-guide-entry="files"]').click() await column.locator('[data-files-entry="file"]').getByRole('button', { name: 'freeze.xlsx', exact: true }).click() const excel = column.locator('[data-excel-preview]') const selectTopLeft = async () => { await excel.locator('.fortune-sheet-overlay').click({ position: { x: 80, y: 35 } }) return await excel.locator('.fortune-fx-input').innerText() } for (const sheet of ['Both', 'Rows', 'Columns', 'None']) { await excel.locator('.luckysheet-sheets-item-name').getByText(sheet, { exact: true }).click() await expect.poll(selectTopLeft).toBe('R1C1') for (const handle of ['.fortune-cols-freeze-handle', '.fortune-rows-freeze-handle']) { expect(await excel.locator(handle).isVisible()).toBe(false) expect(await excel.locator(handle).evaluate(node => node.getClientRects().length)).toBe(0) } for (const ratio of ['100%', '150%']) { await excel.locator('.fortune-zoom-ratio-current').click() await excel.locator('.fortune-zoom-ratio-item').getByText(ratio, { exact: true }).click() await expect.poll(() => excel.locator('.fortune-zoom-ratio-current').innerText()).toBe(ratio) await excel.locator('.fortune-fx-input').click() for (const axis of ['x', 'y'] as const) { if (sheet === 'Both' || sheet === (axis === 'x' ? 'Columns' : 'Rows')) { await expect.poll(() => freezeDividerInk(excel, axis)).toBe(0) } } await expectExcelPanning(page, excel) await successShot(page, `excel-freeze-${sheet}-${ratio}-${deviceScaleFactor}`) } await excel.locator('.luckysheet-scrollbar-x').evaluate((node) => { node.scrollLeft = 240 }) await excel.locator('.luckysheet-scrollbar-y').evaluate((node) => { node.scrollTop = 240 }) await expect.poll(async () => { const value = await selectTopLeft() const match = /^R(\d+)C(\d+)$/.exec(value) return match && { rowFixed: match[1] === '1', columnFixed: match[2] === '1' } }).toEqual({ rowFixed: sheet === 'Both' || sheet === 'Rows', columnFixed: sheet === 'Both' || sheet === 'Columns' }) await successShot(page, `excel-freeze-scrolled-${sheet}-${deviceScaleFactor}`) } await excel.locator('.luckysheet-sheets-item-name').getByText('Both', { exact: true }).click() await expect.poll(selectTopLeft).toBe('R1C1') const canvas = await excel.locator('canvas').first().elementHandle() const selection = await excel.locator('.fortune-name-box').innerText() const layout = await page.addStyleTag({ content: '[data-sidebar-right-panel] { width: 360px !important; }' }) for (const colorScheme of ['light', 'dark'] as const) { await page.emulateMedia({ colorScheme }) await expect.poll(() => page.locator('body').getAttribute('data-ds-dark-theme')).toBe(colorScheme === 'dark' ? '' : null) await expectExcelLayout(excel) await successShot(page, `excel-freeze-narrow-${colorScheme}-${deviceScaleFactor}`) } await layout.evaluate(node => node.parentNode!.removeChild(node)) await expectExcelLayout(excel) expect(await canvas!.evaluate(node => node.isConnected)).toBe(true) expect(await excel.locator('.fortune-name-box').innerText()).toBe(selection) await canvas!.dispose() expect(consoleErrors.pageErrors).toEqual([]) } finally { try { await browser?.close() } finally { await scaffold.close() } } }) describe.skipIf(MODE === 'record')('web e2e: document preview through Files', () => { let scaffold: WebScaffold let browser: Browser let page: Page let tripwire: ReturnType let outsideRoot: string | undefined let nativeRoot: string | undefined let openLog = '' let launchLog = '' let appsCatalog = '' let linuxMimeDefault = '' let launchTarget = '' const opened = async (): Promise> => (await readFile(openLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { path: string; action: 'open' | 'reveal' | 'application' }) /** The application each `open -a` gesture named; the opened log keeps only the file path. */ const launched = async (): Promise> => (await readFile(launchLog, 'utf8')).split('\n').filter(Boolean).map(line => JSON.parse(line) as { app: string; path: string }) beforeAll(async () => { outsideRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-outside-')) if (STUB_OPENER) { // Exercise the built Host through its actual OS command, replacing only the desktop application. nativeRoot = await mkdtemp(join(tmpdir(), 'dsh-preview-native-')) openLog = join(nativeRoot, 'opened.jsonl') launchLog = join(nativeRoot, 'launched.jsonl') appsCatalog = join(nativeRoot, 'applications.json') await writeFile(openLog, '') await writeFile(launchLog, '') const command = process.platform === 'darwin' ? 'open' : 'xdg-open' await writeFile(join(nativeRoot, command), `#!/usr/bin/env node const fs = require('node:fs'); const path = process.argv[2] === '-a' ? process.argv[4] : process.argv[2] === '-R' ? process.argv[3] : process.argv[2]; const action = process.argv[2] === '-a' ? 'application' : process.argv[2] === '-R' || fs.statSync(path).isDirectory() ? 'reveal' : 'open'; fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path, action }) + '\\n'); if (process.argv[2] === '-a') fs.appendFileSync(${JSON.stringify(launchLog)}, JSON.stringify({ app: process.argv[3], path: process.argv[4] }) + '\\n'); `, { mode: 0o700 }) if (process.platform === 'darwin') { await writeFile(appsCatalog, JSON.stringify([ { id: '/Applications/Test Player.app', name: 'Test Player', default: true, icon: `data:image/png;base64,${TINY_PNG.toString('base64')}` }, { id: '/Applications/Other Player.app', name: 'Other Player', default: false, icon: null }, ])) await writeFile(join(nativeRoot, 'osascript'), `#!/usr/bin/env node\nprocess.stdout.write(require('node:fs').readFileSync(${JSON.stringify(appsCatalog)}, 'utf8'));\n`, { mode: 0o700 }) launchTarget = '/Applications/Test Player.app' } if (process.platform === 'linux') { const data = join(nativeRoot, 'data') await mkdir(join(data, 'applications'), { recursive: true }) const icon = join(nativeRoot, 'icon.png') await writeFile(icon, TINY_PNG) const testDesktop = join(data, 'applications', 'test.desktop') await writeFile(testDesktop, `[Desktop Entry]\nName=Test Player\nIcon=${icon}\n`) await writeFile(join(data, 'applications', 'other.desktop'), '[Desktop Entry]\nName=Other Player\n') // `gio mime` owns the OS default here, so the marker file is what flips a run to "no default". linuxMimeDefault = join(nativeRoot, 'mime-default') await writeFile(linuxMimeDefault, 'test.desktop\n') await writeFile(join(nativeRoot, 'gio'), `#!/usr/bin/env node const fs = require('node:fs'); const preferred = fs.readFileSync(${JSON.stringify(linuxMimeDefault)}, 'utf8').trim(); if (process.argv[2] === 'info') process.stdout.write('standard::content-type: video/mp4'); else if (process.argv[2] === 'mime') process.stdout.write((preferred.length > 0 ? 'Default application for video/mp4: ' + preferred + '\\n' : '') + 'Registered applications:\\n test.desktop\\n other.desktop\\n'); else if (process.argv[2] === 'launch') { fs.appendFileSync(${JSON.stringify(openLog)}, JSON.stringify({ path: process.argv[4], action: 'application' }) + '\\n'); fs.appendFileSync(${JSON.stringify(launchLog)}, JSON.stringify({ app: process.argv[3], path: process.argv[4] }) + '\\n'); } else process.exit(1); `, { mode: 0o700 }) launchTarget = testDesktop vi.stubEnv('XDG_DATA_HOME', data) vi.stubEnv('XDG_DATA_DIRS', '') } vi.stubEnv('PATH', `${nativeRoot}${delimiter}${process.env.PATH ?? ''}`) } // The Open In rows carry the default-application controls; the SSH marker // keeps the host's application catalog empty, so the Session-header split // button stays off every platform while the pinned desktop serves the file controls. scaffold = await launchWebScaffold({ developerTools: false, replayFixture: FIXTURE, paceMs: 5, compareReplaySession: false, extraOverlayPath: [PAGING_PATCH, fileURLToPath(new URL('./fixtures/native-open-on.patch.yml', import.meta.url))], openInAppEnvironment: createLaunchEnvironmentSnapshot([{ source: 'process', values: { SSH_CONNECTION: '10.0.0.2 55000 10.0.0.9 22' } }]), }) browser = await chromium.launch() page = await newEnglishPage(browser) tripwire = watchConsole(page) await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' }) await connectFreshWorkspace(page, scaffold.workspaceCwd) }) afterAll(async () => { try { await browser?.close() } finally { try { await scaffold?.close() } finally { vi.unstubAllEnvs() if (outsideRoot !== undefined) await rm(outsideRoot, { recursive: true, force: true }) if (nativeRoot !== undefined) await rm(nativeRoot, { recursive: true, force: true }) } } }) it('opens text, isolated HTML, width-fitted images, and rendered PDF from the Session workspace', async () => { onTestFailed(async () => { await mkdir(SHOT_DIR, { recursive: true }) await saveFailureShot(page, `screenshots/0908-document-preview/smoke-${process.pid}`) }) await page.locator('[data-slot="conversation.hero.agentPreset"]').getByRole('button', { name: 'Standard mode', exact: true }).click() await expect.poll(() => page.getByRole('menuitem').count()).toBe(2) expect(await page.getByRole('menuitem', { name: /^PTC mode|^Minimal mode/ }).count()).toBe(0) await page.keyboard.press('Escape') expect(scaffold.ctx.settings.describe().find(row => row.ns === 'ui-settings')?.value).toEqual({ enabled: false }) const settled = scaffold.whenTurnSettled() const input = page.locator('[data-composer-input]').first() await input.fill(PROMPT) await input.press('Enter') const sessionId = await settled await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) expect(await page.getByRole('tablist').count()).toBe(0) const cwd = scaffold.ctx.agents.get(sessionId)?.session.header.cwd if (cwd === undefined) throw new Error('settled Session has no workspace cwd') if (outsideRoot === undefined) throw new Error('outside fixture directory is unavailable') const outsideScript = join(outsideRoot, 'outside.js') const outsideReference = relative(cwd, outsideScript).replace(/\\/g, '/') let previewNetworkRequests = 0 await page.route('https://preview.invalid/developer-tools.png', async (route) => { previewNetworkRequests += 1 await route.fulfill({ status: 200, contentType: 'image/png', body: TINY_PNG }) }) const blockedRequests: string[] = [] await page.route('https://blocked-preview.invalid/**', async (route) => { blockedRequests.push(route.request().url()) await route.fulfill({ status: 200, contentType: 'text/html', body: 'ESCAPED' }) }) const markdownText = [ '# Markdown smoke', '', 'Rendered from the workspace.', '', ...Array.from({ length: (PAGE_LINES - 4) / 2 }, (_, index) => [`Paragraph ${index + 1}: ${'visible prefix '.repeat(20)}`, '']).flat(), '# Markdown tail', '', '![relative image](preview-images/local%20image.png)', '', `![absolute image](<${join(cwd, 'tiny.png').replaceAll('\\', '/')}>)`, '', '![reference image][local-image]', '', '[local-image]: preview-images/local%20image.png', ].join('\n') await mkdir(join(cwd, 'preview-images')) const codeLines = [ ...Array.from({ length: PAGE_LINES }, (_, index) => index === 0 ? 'const prefix = "CODE_PREFIX";' : `// prefix line ${index + 1}`), 'const tail = "CODE_TAIL";', ] await Promise.all([ writeFile(join(cwd, 'hostile.html'), `

Static adversarial preview

Plain link
SVG link x