import { renderToStaticMarkup } from 'react-dom/server'; import { describe, expect, it } from 'vitest'; import { FileViewer, LiveArtifactRefreshHistoryPanel, SvgViewer, } from '../../src/components/FileViewer'; import type { LiveArtifact, ProjectFile } from '../../src/types'; function baseFile(overrides: Partial): ProjectFile { return { name: 'asset.png', path: 'asset.png', type: 'file', size: 1024, mtime: 1710000000, kind: 'image', mime: 'image/png', ...overrides, }; } describe('FileViewer SVG artifacts', () => { it('routes SVG artifacts to the SVG viewer instead of the generic image viewer', () => { const file = baseFile({ name: 'diagram.svg', path: 'diagram.svg', mime: 'image/svg+xml', artifactManifest: { version: 1, kind: 'svg', title: 'Diagram', entry: 'diagram.svg', renderer: 'svg', exports: ['svg'], }, }); const markup = renderToStaticMarkup(); expect(markup).toContain('class="viewer svg-viewer"'); expect(markup).not.toContain('class="viewer image-viewer"'); expect(markup).toContain('Preview'); expect(markup).toContain('Source'); expect(markup).toContain('src="/api/projects/project-1/raw/diagram.svg?v=1710000000&r=0"'); }); it('keeps normal image artifacts on the existing image viewer path', () => { const file = baseFile({ name: 'photo.png', path: 'photo.png' }); const markup = renderToStaticMarkup(); expect(markup).toContain('class="viewer image-viewer"'); expect(markup).not.toContain('class="viewer svg-viewer"'); expect(markup).not.toContain('class="viewer-tabs"'); }); it('marks preview and source modes through the SVG viewer toggle controls', () => { const file = baseFile({ name: 'diagram.svg', path: 'diagram.svg', mime: 'image/svg+xml' }); const previewMarkup = renderToStaticMarkup( , ); const sourceMarkup = renderToStaticMarkup( , ); expect(previewMarkup).toContain('class="viewer-tab active" aria-pressed="true">Preview'); expect(previewMarkup).toContain('aria-pressed="false">Source'); expect(previewMarkup).toContain('Preview'); expect(sourceMarkup).toContain('class="viewer-tab active" aria-pressed="true">Source'); expect(sourceMarkup).toContain('class="viewer-source"'); expect(sourceMarkup).not.toContain(' { const file = baseFile({ name: 'page.html', path: 'page.html', mime: 'text/html', kind: 'html', artifactManifest: { version: 1, kind: 'html', title: 'Page', entry: 'page.html', renderer: 'html', exports: ['html'], }, }); const markup = renderToStaticMarkup( , ); expect(markup).toContain('data-testid="artifact-preview-frame"'); expect(markup).toContain('data-od-render-mode="url-load"'); expect(markup).toContain('src="/api/projects/project-1/raw/page.html?v=1710000000&r=0"'); expect(markup).not.toContain('data-od-render-mode="srcdoc"'); }); it('keeps decks on the srcDoc path so the deck postMessage bridge can run', () => { const file = baseFile({ name: 'deck.html', path: 'deck.html', mime: 'text/html', kind: 'html', artifactManifest: { version: 1, kind: 'deck', title: 'Deck', entry: 'deck.html', renderer: 'deck-html', exports: ['html'], }, }); const markup = renderToStaticMarkup(
one
'} />, ); expect(markup).toContain('data-testid="artifact-preview-frame"'); expect(markup).toContain('data-od-render-mode="srcdoc"'); expect(markup).not.toContain('data-od-render-mode="url-load"'); }); it('falls back to srcDoc when the HTML body looks deck-shaped even without an isDeck hint', () => { const file = baseFile({ name: 'inferred.html', path: 'inferred.html', mime: 'text/html', kind: 'html', artifactManifest: { version: 1, kind: 'html', title: 'Inferred', entry: 'inferred.html', renderer: 'html', exports: ['html'], }, }); const markup = renderToStaticMarkup(
one
two
'} />, ); expect(markup).toContain('data-od-render-mode="srcdoc"'); expect(markup).not.toContain('data-od-render-mode="url-load"'); }); it('renders unsafe SVG source as escaped text instead of executable markup', () => { const file = baseFile({ name: 'unsafe.svg', path: 'unsafe.svg', mime: 'image/svg+xml' }); const unsafeSource = [ 'Logo', '<script>alert(3)</script>', ].join('\n'); const markup = renderToStaticMarkup( , ); expect(markup).toContain('<svg onload="alert(1)">'); expect(markup).toContain('<script>alert(2)</script>'); expect(markup).toContain('<![CDATA[<script>alert(3)</script>]]>'); expect(markup).not.toContain(''); expect(markup).not.toContain(''); expect(markup).not.toContain('dangerouslySetInnerHTML'); }); }); function baseLiveArtifact(overrides: Partial<LiveArtifact> = {}): LiveArtifact { const artifact: LiveArtifact = { schemaVersion: 1, id: 'la_1', projectId: 'proj_1', title: 'Launch Metrics', slug: 'launch-metrics', status: 'active', pinned: false, preview: { type: 'html', entry: 'index.html' }, refreshStatus: 'idle', createdAt: '2026-04-29T12:00:00.000Z', updatedAt: '2026-04-29T12:00:00.000Z', document: { format: 'html_template_v1', templatePath: 'template.html', generatedPreviewPath: 'index.html', dataPath: 'data.json', dataJson: { title: 'Launch Metrics' }, }, }; return { ...artifact, ...overrides, document: overrides.document ?? artifact.document }; } describe('LiveArtifactRefreshHistoryPanel', () => { it('renders a human-readable status instead of raw JSON when no history exists', () => { const markup = renderToStaticMarkup( <LiveArtifactRefreshHistoryPanel liveArtifact={baseLiveArtifact({ refreshStatus: 'never' })} fallbackRefreshStatus="never" isRunning={false} sessionEvents={[]} />, ); // Status badge with tone, not JSON expect(markup).toContain('live-artifact-refresh-panel'); expect(markup).toContain('data-testid="live-artifact-refresh-status-badge"'); expect(markup).toContain('Not refreshable'); expect(markup).toContain('Last refreshed'); expect(markup).toContain('Never'); expect(markup).toContain('No refresh activity yet in this session'); // Raw JSON is available but tucked inside a collapsed <details>, not exposed as the primary view. expect(markup).toContain('<details'); expect(markup).toContain('Advanced debug metadata'); const detailsIndex = markup.indexOf('<details'); const rawJsonIndex = markup.search(/<pre class="viewer-source">\s*\{/); expect(detailsIndex).toBeGreaterThanOrEqual(0); expect(rawJsonIndex).toBeGreaterThan(detailsIndex); }); it('surfaces running state and a session timeline with duration + source counts', () => { const now = Date.now(); const markup = renderToStaticMarkup( <LiveArtifactRefreshHistoryPanel liveArtifact={baseLiveArtifact({ refreshStatus: 'succeeded', lastRefreshedAt: new Date(now - 45_000).toISOString(), })} fallbackRefreshStatus="succeeded" isRunning sessionEvents={[ { id: 1, phase: 'started', at: now - 5_000 }, { id: 2, phase: 'succeeded', at: now - 1_200, durationMs: 3_800, refreshedSourceCount: 2, }, ]} />, ); // isRunning wins over persisted `succeeded` expect(markup).toContain('Refreshing'); // Both timeline rows are present expect(markup).toContain('Started'); expect(markup).toContain('Succeeded'); // Source count + duration are humanized (3.8s), not raw ms expect(markup).toContain('2 sources updated'); expect(markup).toContain('3.8s'); }); });