diff --git a/apps/daemon/src/project-routes.ts b/apps/daemon/src/project-routes.ts index d095902521..8037eca5a9 100644 --- a/apps/daemon/src/project-routes.ts +++ b/apps/daemon/src/project-routes.ts @@ -533,6 +533,184 @@ const URL_PREVIEW_SELECTION_BRIDGE = ``; +const URL_PREVIEW_SNAPSHOT_BRIDGE = ``; + function previewBridgeTokens(value: unknown): string[] { if (Array.isArray(value)) return value.flatMap(previewBridgeTokens); if (typeof value !== 'string') return []; @@ -547,6 +725,10 @@ function wantsUrlPreviewSelectionBridge(value: unknown): boolean { return previewBridgeTokens(value).some((token) => token === 'selection' || token === 'comment' || token === 'comments' || token === 'annotation'); } +function wantsUrlPreviewSnapshotBridge(value: unknown): boolean { + return previewBridgeTokens(value).some((token) => token === 'snapshot' || token === 'image' || token === 'capture'); +} + function injectBeforeBodyClose(html: string, marker: string, injection: string): string { if (html.includes(marker)) return html; const bodyCloseIndex = html.search(/<\/body\s*>/i); @@ -556,10 +738,14 @@ function injectBeforeBodyClose(html: string, marker: string, injection: string): return `${html}${injection}`; } -function injectUrlPreviewBridge(html: string, bridge: 'scroll' | 'selection'): string { - return bridge === 'scroll' - ? injectBeforeBodyClose(html, 'data-od-url-scroll-bridge', URL_PREVIEW_SCROLL_BRIDGE) - : injectBeforeBodyClose(html, 'data-od-url-selection-bridge', URL_PREVIEW_SELECTION_BRIDGE); +function injectUrlPreviewBridge(html: string, bridge: 'scroll' | 'selection' | 'snapshot'): string { + if (bridge === 'scroll') { + return injectBeforeBodyClose(html, 'data-od-url-scroll-bridge', URL_PREVIEW_SCROLL_BRIDGE); + } + if (bridge === 'selection') { + return injectBeforeBodyClose(html, 'data-od-url-selection-bridge', URL_PREVIEW_SELECTION_BRIDGE); + } + return injectBeforeBodyClose(html, 'data-od-url-snapshot-bridge', URL_PREVIEW_SNAPSHOT_BRIDGE); } function normalizeChatSessionMode(value: unknown): ChatSessionMode { @@ -2040,7 +2226,8 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile (file) => { if ( (wantsUrlPreviewScrollBridge(req.query.odPreviewBridge) || - wantsUrlPreviewSelectionBridge(req.query.odPreviewBridge)) && + wantsUrlPreviewSelectionBridge(req.query.odPreviewBridge) || + wantsUrlPreviewSnapshotBridge(req.query.odPreviewBridge)) && /^text\/html(?:;|$)/i.test(file.mime) ) { let html = file.buffer.toString('utf8'); @@ -2050,6 +2237,9 @@ export function registerProjectFileRoutes(app: Express, ctx: RegisterProjectFile if (wantsUrlPreviewSelectionBridge(req.query.odPreviewBridge)) { html = injectUrlPreviewBridge(html, 'selection'); } + if (wantsUrlPreviewSnapshotBridge(req.query.odPreviewBridge)) { + html = injectUrlPreviewBridge(html, 'snapshot'); + } return html; } return file.buffer; diff --git a/apps/daemon/tests/project-file-range.test.ts b/apps/daemon/tests/project-file-range.test.ts index eae45459a7..4b7db043fc 100644 --- a/apps/daemon/tests/project-file-range.test.ts +++ b/apps/daemon/tests/project-file-range.test.ts @@ -174,6 +174,10 @@ describe('GET /api/projects/:id/raw/* range request route', () => { path.join(dir, 'selection-bridged.html'), Buffer.from('
Preview
'), ); + await writeFile( + path.join(dir, 'snapshot-bridged.html'), + Buffer.from('
Preview
'), + ); }); afterAll(() => new Promise((resolve) => server.close(() => resolve()))); @@ -266,14 +270,29 @@ describe('GET /api/projects/:id/raw/* range request route', () => { expect(html).not.toContain('data-od-url-scroll-bridge'); }); + it('injects the URL preview snapshot bridge only when requested', async () => { + const plain = await fetch(rawUrl('page.html')); + expect(await plain.text()).toBe(''); + + const bridged = await fetch(`${rawUrl('page.html')}?odPreviewBridge=snapshot`); + expect(bridged.status).toBe(200); + const html = await bridged.text(); + expect(html).toContain('data-od-url-snapshot-bridge'); + expect(html).toContain("type: 'od:snapshot:result'"); + expect(html).not.toContain('data-od-url-scroll-bridge'); + expect(html).not.toContain('data-od-url-selection-bridge'); + }); + it('injects scroll and selection URL preview bridges together', async () => { - const bridged = await fetch(`${rawUrl('body.html')}?odPreviewBridge=scroll&odPreviewBridge=selection`); + const bridged = await fetch(`${rawUrl('body.html')}?odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot`); expect(bridged.status).toBe(200); const html = await bridged.text(); expect(html).toContain('data-od-url-scroll-bridge'); expect(html).toContain('data-od-url-selection-bridge'); + expect(html).toContain('data-od-url-snapshot-bridge'); expect(html.indexOf('data-od-url-scroll-bridge')).toBeLessThan(html.indexOf('')); expect(html.indexOf('data-od-url-selection-bridge')).toBeLessThan(html.indexOf('')); + expect(html.indexOf('data-od-url-snapshot-bridge')).toBeLessThan(html.indexOf('')); }); it('does not inject the URL preview scroll bridge twice', async () => { @@ -290,6 +309,13 @@ describe('GET /api/projects/:id/raw/* range request route', () => { expect(html.match(/data-od-url-selection-bridge/g)?.length).toBe(1); }); + it('does not inject the URL preview snapshot bridge twice', async () => { + const bridged = await fetch(`${rawUrl('snapshot-bridged.html')}?odPreviewBridge=snapshot`); + expect(bridged.status).toBe(200); + const html = await bridged.text(); + expect(html.match(/data-od-url-snapshot-bridge/g)?.length).toBe(1); + }); + it('returns 404 for a missing file', async () => { const res = await fetch(rawUrl('missing.mp4')); expect(res.status).toBe(404); diff --git a/apps/web/src/components/FileViewer.tsx b/apps/web/src/components/FileViewer.tsx index f0d5707f7c..6dbdc81f0b 100644 --- a/apps/web/src/components/FileViewer.tsx +++ b/apps/web/src/components/FileViewer.tsx @@ -5185,7 +5185,7 @@ function HtmlViewer({ needsFocusGuard, }) && !manualEditRequiresSrcDoc; const basePreviewSrcUrl = useMemo( - () => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}&odPreviewBridge=scroll&odPreviewBridge=selection`, + () => `${projectRawUrl(projectId, file.name)}?v=${Math.round(file.mtime)}&r=${reloadKey}&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot`, [projectId, file.name, file.mtime, reloadKey], ); const [previewSrcUrl, setPreviewSrcUrl] = useState(basePreviewSrcUrl); @@ -7152,6 +7152,14 @@ function HtmlViewer({ return requestPreviewSnapshotWithRetry(activeIframe); } + const urlIframe = iframeRef.current ?? urlPreviewIframeRef.current; + if (urlIframe) { + await waitForIframeLoadOrTimeout(urlIframe, 250); + await waitForAnimationFrame(); + const urlSnapshot = await requestPreviewSnapshotWithRetry(urlIframe); + if (urlSnapshot) return urlSnapshot; + } + const srcDocIframe = srcDocPreviewIframeRef.current; if (!srcDocIframe) { const activeIframe = iframeRef.current; diff --git a/apps/web/tests/components/FileViewer.test.tsx b/apps/web/tests/components/FileViewer.test.tsx index cb8d9082dc..88c03aba00 100644 --- a/apps/web/tests/components/FileViewer.test.tsx +++ b/apps/web/tests/components/FileViewer.test.tsx @@ -626,7 +626,7 @@ describe('FileViewer SVG artifacts', () => { const { container } = render(); const firstFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; - expect(firstFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection'); + expect(firstFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot'); fireEvent.click(screen.getByRole('button', { name: 'Leave project' })); @@ -634,7 +634,7 @@ describe('FileViewer SVG artifacts', () => { expect(screen.getByTestId('home-view')).toBeTruthy(); const parkedFrame = container.querySelector('.iframe-keep-alive-pool iframe'); expect(parkedFrame).toBe(firstFrame); - expect(parkedFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection'); + expect(parkedFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot'); fireEvent.click(screen.getByRole('button', { name: 'Return project' })); @@ -781,7 +781,7 @@ describe('FileViewer SVG artifacts', () => { expect(markup).toContain('data-od-render-mode="url-load"'); expect(markup).toContain('data-od-render-mode="url-load" data-od-active="true"'); expect(markup).toContain('data-od-render-mode="srcdoc" data-od-active="false"'); - expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection"'); + expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot"'); expect(markup).toContain('sandbox="allow-scripts allow-downloads"'); }); @@ -811,13 +811,13 @@ describe('FileViewer SVG artifacts', () => { ); const frame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; - expect(frame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection'); + expect(frame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot'); fireEvent.click(screen.getByRole('button', { name: /reload preview/i })); const reloadedFrame = screen.getByTestId('artifact-preview-frame') as HTMLIFrameElement; expect(reloadedFrame).toBe(frame); - expect(reloadedFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=1&odPreviewBridge=scroll&odPreviewBridge=selection'); + expect(reloadedFrame.getAttribute('src')).toBe('/api/projects/project-1/raw/page.html?v=1710000000&r=1&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot'); }); it('remounts the srcDoc HTML preview when reload is requested', () => { @@ -1167,7 +1167,7 @@ describe('FileViewer SVG artifacts', () => { const { container } = render(); const getFrame = () => container.querySelector('[data-testid="artifact-preview-frame"]'); const initialFrame = getFrame(); - expect(initialFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/first.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection'); + expect(initialFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/first.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot'); const observationsBeforeSwitch = observedCommittedSrcs.length; fireEvent.click(screen.getByRole('button', { name: 'Switch file' })); @@ -1175,9 +1175,9 @@ describe('FileViewer SVG artifacts', () => { const nextFrame = getFrame(); expect(nextFrame).toBeTruthy(); expect(observedCommittedSrcs[observationsBeforeSwitch]).toBe( - '/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection', + '/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot', ); - expect(nextFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection'); + expect(nextFrame?.getAttribute('src')).toBe('/api/projects/project-1/raw/second.html?v=1710000000&r=0&odPreviewBridge=scroll&odPreviewBridge=selection&odPreviewBridge=snapshot'); }); it('allows downloads in the in-tab HTML presentation iframe', async () => { diff --git a/apps/web/tests/components/file-viewer-image-export.test.tsx b/apps/web/tests/components/file-viewer-image-export.test.tsx index a244d9fb57..0615b840ec 100644 --- a/apps/web/tests/components/file-viewer-image-export.test.tsx +++ b/apps/web/tests/components/file-viewer-image-export.test.tsx @@ -68,7 +68,7 @@ function renderHtmlPreview() { const srcDocFrame = container.querySelector('iframe[data-od-render-mode="srcdoc"]'); expect(srcDocFrame).toBeTruthy(); fireEvent.load(srcDocFrame as HTMLIFrameElement); - return { ...view, srcDocFrame: srcDocFrame as HTMLIFrameElement }; + return { ...view, activeFrame, srcDocFrame: srcDocFrame as HTMLIFrameElement }; } function openImageExportDialog() { @@ -86,7 +86,7 @@ async function waitForSaveButton() { describe('FileViewer image export', () => { afterEach(() => { cleanup(); - vi.clearAllMocks(); + vi.resetAllMocks(); }); it('lets users choose an image format before saving URL-loaded HTML previews', async () => { @@ -107,12 +107,12 @@ describe('FileViewer image export', () => { save: saveImageBlobMock, }); - const { srcDocFrame } = renderHtmlPreview(); + const { activeFrame } = renderHtmlPreview(); openImageExportDialog(); expect(screen.getByRole('radio', { name: 'PNG' })).toBeTruthy(); await waitFor(() => { - expect(requestPreviewSnapshotMock).toHaveBeenCalledWith(srcDocFrame, 1500); + expect(requestPreviewSnapshotMock).toHaveBeenCalledWith(activeFrame, 1500); expect(imageDataUrlToBlobMock).toHaveBeenCalledWith('data:image/png;base64,ok', 'png'); }); await waitForSaveButton(); @@ -123,7 +123,7 @@ describe('FileViewer image export', () => { }); fireEvent.click(await waitForSaveButton()); - fireEvent.load(srcDocFrame as HTMLIFrameElement); + fireEvent.load(activeFrame as HTMLIFrameElement); await waitFor(() => { expect(prepareImageExportTargetMock).toHaveBeenCalledWith('workspace', 'jpeg', { useNativePicker: false }); @@ -135,24 +135,52 @@ describe('FileViewer image export', () => { it('retries the srcDoc snapshot bridge before giving up on URL-loaded previews', async () => { const pngBlob = new Blob(['png'], { type: 'image/png' }); - requestPreviewSnapshotMock - .mockResolvedValueOnce(null) - .mockResolvedValueOnce({ + let srcDocAttempts = 0; + requestPreviewSnapshotMock.mockImplementation(async (iframe: HTMLIFrameElement) => { + if (iframe.getAttribute('data-od-render-mode') === 'url-load') return null; + srcDocAttempts += 1; + if (srcDocAttempts === 1) return null; + return { dataUrl: 'data:image/png;base64,recovered', w: 800, h: 600, - }); + }; + }); imageDataUrlToBlobMock.mockResolvedValueOnce(pngBlob); const { srcDocFrame } = renderHtmlPreview(); openImageExportDialog(); await waitFor(() => { - expect(requestPreviewSnapshotMock).toHaveBeenCalledTimes(2); - expect(requestPreviewSnapshotMock).toHaveBeenNthCalledWith(1, srcDocFrame, 1500); - expect(requestPreviewSnapshotMock).toHaveBeenNthCalledWith(2, srcDocFrame, 3000); + expect(requestPreviewSnapshotMock).toHaveBeenCalledWith(srcDocFrame, 1500); + expect(requestPreviewSnapshotMock).toHaveBeenCalledWith(srcDocFrame, 3000); expect(imageDataUrlToBlobMock).toHaveBeenCalledWith('data:image/png;base64,recovered', 'png'); + }, { timeout: 4000 }); + }); + + it('captures the visible URL-loaded preview before falling back to the hidden srcDoc transport', async () => { + const pngBlob = new Blob(['png'], { type: 'image/png' }); + requestPreviewSnapshotMock.mockImplementation(async (iframe: HTMLIFrameElement) => { + if (iframe.getAttribute('data-od-render-mode') === 'url-load') { + return { + dataUrl: 'data:image/png;base64,visible', + w: 800, + h: 600, + }; + } + return null; }); + imageDataUrlToBlobMock.mockResolvedValueOnce(pngBlob); + + const { activeFrame, srcDocFrame } = renderHtmlPreview(); + openImageExportDialog(); + + await waitFor(() => { + expect(requestPreviewSnapshotMock).toHaveBeenCalledWith(activeFrame, 1500); + expect(imageDataUrlToBlobMock).toHaveBeenCalledWith('data:image/png;base64,visible', 'png'); + }); + expect(requestPreviewSnapshotMock).not.toHaveBeenCalledWith(srcDocFrame, 1500); + expect(screen.queryByRole('alert')).toBeNull(); }); it('uses the prepared PNG data URL for fallback downloads', async () => { @@ -196,7 +224,7 @@ describe('FileViewer image export', () => { expect(screen.getByRole('alert').textContent).toBe( "Image capture failed. Please try again or use your browser's screenshot tool.", ); - }); + }, { timeout: 4000 }); expect((screen.getByRole('button', { name: /^save$/i }) as HTMLButtonElement).disabled).toBe(true); expect(prepareImageExportTargetMock).not.toHaveBeenCalled(); expect(imageDataUrlToBlobMock).not.toHaveBeenCalled(); @@ -223,7 +251,7 @@ describe('FileViewer image export', () => { expect(screen.getByRole('alert').textContent).toBe( "Image capture failed. Please try again or use your browser's screenshot tool.", ); - }); + }, { timeout: 4000 }); expect((screen.getByRole('button', { name: /^save$/i }) as HTMLButtonElement).disabled).toBe(true); expect(imageDataUrlToBlobMock).toHaveBeenCalledWith('data:image/png;base64,ok', 'png'); expect(prepareImageExportTargetMock).not.toHaveBeenCalled();