diff --git a/clients/ember/package-lock.json b/clients/ember/package-lock.json index 794645cf..5ec1281e 100644 --- a/clients/ember/package-lock.json +++ b/clients/ember/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vizzly-testing/ember", - "version": "0.1.0", + "version": "0.0.1-beta.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vizzly-testing/ember", - "version": "0.1.0", + "version": "0.0.1-beta.1", "license": "MIT", "dependencies": { "playwright-core": "^1.49.0" diff --git a/clients/ember/src/test-support/index.js b/clients/ember/src/test-support/index.js index d5a4d086..556bd8b3 100644 --- a/clients/ember/src/test-support/index.js +++ b/clients/ember/src/test-support/index.js @@ -247,6 +247,14 @@ export async function vizzlySnapshot(name, options = {}) { if (!response.ok) { let errorText = await response.text(); + + // Check if this is a "no server" error - gracefully skip instead of failing + // This allows tests to pass when Vizzly isn't running (like Percy behavior) + if (errorText.includes('No Vizzly server found')) { + console.warn('[vizzly] Vizzly server not running. Skipping visual snapshot.'); + return { skipped: true, reason: 'no-server' }; + } + throw new Error(`Vizzly snapshot failed: ${errorText}`); } diff --git a/package.json b/package.json index 6963c473..3e394505 100644 --- a/package.json +++ b/package.json @@ -58,11 +58,12 @@ ], "scripts": { "start": "node src/index.js", - "build": "npm run clean && npm run compile && npm run build:reporter && npm run copy-types", + "build": "npm run clean && npm run compile && npm run build:reporter && npm run build:reporter-ssr && npm run copy-types", "clean": "rimraf dist", "compile": "babel src --out-dir dist --ignore '**/*.test.js'", "copy-types": "mkdir -p dist/types && cp src/types/*.d.ts dist/types/", "build:reporter": "cd src/reporter && vite build", + "build:reporter-ssr": "cd src/reporter && vite build --config vite.ssr.config.js", "dev:reporter": "cd src/reporter && vite --config vite.dev.config.js", "test:types": "tsd", "prepublishOnly": "npm run build", diff --git a/src/cli.js b/src/cli.js index f4847531..991ac65a 100644 --- a/src/cli.js +++ b/src/cli.js @@ -30,6 +30,11 @@ import { validateWhoamiOptions, whoamiCommand } from './commands/whoami.js'; import { createPluginServices } from './plugin-api.js'; import { loadPlugins } from './plugin-loader.js'; import { createServices } from './services/index.js'; +import { + generateStaticReport, + getReportFileUrl, +} from './services/static-report-generator.js'; +import { openBrowser } from './utils/browser.js'; import { colors } from './utils/colors.js'; import { loadConfig } from './utils/config-loader.js'; import { getContext } from './utils/context.js'; @@ -411,6 +416,7 @@ tddCmd '--set-baseline', 'Accept current screenshots as new baseline (overwrites existing)' ) + .option('--no-open', 'Skip opening report in browser') .action(async (command, options) => { const globalOptions = program.opts(); @@ -449,23 +455,27 @@ tddCmd process.once('SIGINT', sigintHandler); process.once('SIGTERM', sigtermHandler); - // If there are comparisons, keep server running for review + // If there are comparisons, generate static report const hasComparisons = result?.comparisons?.length > 0; if (hasComparisons) { - output.print( - ` ${colors.brand.textTertiary('→')} Press ${colors.white('Enter')} to stop server` - ); - output.blank(); - - // Wait for user to press Enter - await new Promise(resolve => { - process.stdin.setRawMode?.(false); - process.stdin.resume(); - process.stdin.once('data', () => { - process.stdin.pause(); - resolve(); - }); - }); + // Note: Tests have completed at this point, so report-data.json is stable. + // The report reflects the final state of all comparisons. + const reportResult = await generateStaticReport(process.cwd()); + + if (reportResult.success) { + const reportUrl = getReportFileUrl(reportResult.reportPath); + output.print( + ` ${colors.brand.textTertiary('→')} Report: ${colors.blue(reportUrl)}` + ); + output.blank(); + + // Open report in browser unless --no-open + if (options.open !== false) { + await openBrowser(reportUrl); + } + } else { + output.warn(`Failed to generate static report: ${reportResult.error}`); + } } // Remove signal handlers before normal cleanup to prevent double cleanup diff --git a/src/reporter/src/api/client.js b/src/reporter/src/api/client.js index 44f51402..db9f0bff 100644 --- a/src/reporter/src/api/client.js +++ b/src/reporter/src/api/client.js @@ -9,14 +9,6 @@ * - api.auth.* - Authentication */ -/** - * Check if we're in static mode (data embedded in HTML) - * Static mode is used for self-contained HTML reports - */ -export function isStaticMode() { - return typeof window !== 'undefined' && window.VIZZLY_STATIC_MODE === true; -} - /** * Make a JSON API request * @param {string} url - Request URL @@ -53,10 +45,6 @@ export const tdd = { * @returns {Promise} */ async getReportData() { - // In static mode, return embedded data directly without fetching - if (isStaticMode() && window.VIZZLY_REPORTER_DATA) { - return window.VIZZLY_REPORTER_DATA; - } return fetchJson('/api/report-data'); }, diff --git a/src/reporter/src/components/static-report-view.jsx b/src/reporter/src/components/static-report-view.jsx new file mode 100644 index 00000000..133a478e --- /dev/null +++ b/src/reporter/src/components/static-report-view.jsx @@ -0,0 +1,687 @@ +/** + * Static Report View + * + * A polished, hook-free component for SSR static report generation. + * Uses native HTML5
/ for expand/collapse without JavaScript. + * Designed for quick visual scanning with grouped status sections. + */ + +// Status configuration +const statusConfig = { + failed: { + label: 'Changed', + borderClass: 'border-l-red-500', + badgeClass: 'bg-red-500/15 text-red-400 ring-red-500/30', + dotClass: 'bg-red-500', + sectionTitle: 'Visual Changes', + sectionIcon: '◐', + }, + new: { + label: 'New', + borderClass: 'border-l-blue-500', + badgeClass: 'bg-blue-500/15 text-blue-400 ring-blue-500/30', + dotClass: 'bg-blue-500', + sectionTitle: 'New Screenshots', + sectionIcon: '+', + }, + passed: { + label: 'Passed', + borderClass: 'border-l-emerald-500', + badgeClass: 'bg-emerald-500/15 text-emerald-400 ring-emerald-500/30', + dotClass: 'bg-emerald-500', + sectionTitle: 'Passed', + sectionIcon: '✓', + }, + error: { + label: 'Error', + borderClass: 'border-l-orange-500', + badgeClass: 'bg-orange-500/15 text-orange-400 ring-orange-500/30', + dotClass: 'bg-orange-500', + sectionTitle: 'Errors', + sectionIcon: '!', + }, +}; + +function StatusBadge({ status }) { + let config = statusConfig[status] || statusConfig.error; + return ( + + + {config.label} + + ); +} + +function DiffBadge({ percentage }) { + if (percentage === undefined || percentage === null || percentage === 0) + return null; + return ( + + {percentage.toFixed(2)}% + + ); +} + +function MetaInfo({ properties }) { + if (!properties) return null; + let { viewport_width, viewport_height, browser } = properties; + + return ( +
+ {viewport_width && viewport_height && ( + + {viewport_width}×{viewport_height} + + )} + {browser && ( + <> + · + {browser} + + )} +
+ ); +} + +/** + * Comparison item for failed/changed screenshots + * Expandable with side-by-side comparison view + */ +function FailedComparison({ comparison, isEven }) { + let { name, status, current, baseline, diff, diffPercentage, properties } = + comparison; + let config = statusConfig[status] || statusConfig.failed; + + return ( +
+ + {/* Thumbnail */} +
+ {(current || baseline) && ( + + )} + {diff && ( +
+ +
+ )} +
+ + {/* Info */} +
+
+ {name} + +
+ +
+ + {/* Expand indicator */} +
+ +
+ + Details + + +
+
+
+ + {/* Expanded content: side-by-side comparison */} +
+
+ {/* Baseline */} + {baseline && ( +
+
+ + Baseline + + Expected +
+ + {`${name} + +
+ )} + + {/* Current */} + {current && ( +
+
+ + Current + + Actual +
+ + {`${name} + +
+ )} +
+ + {/* Diff image */} + {diff && ( +
+
+ + Difference + + {diffPercentage > 0 && ( + + {diffPercentage.toFixed(2)}% of pixels changed + + )} +
+ + {`${name} + +
+ )} +
+
+ ); +} + +/** + * Comparison item for new screenshots + * Expandable to show the new screenshot + */ +function NewComparison({ comparison, isEven }) { + let { name, current, baseline, properties } = comparison; + let imageSrc = current || baseline; + + return ( +
+ + {/* Thumbnail */} +
+ {imageSrc && ( + + )} +
+ + {/* Info */} +
+ {name} + +
+ + {/* Expand indicator */} +
+ +
+ + Preview + + +
+
+
+ + {/* Expanded: show the new screenshot */} +
+
+ + New Screenshot + + No baseline to compare +
+ {imageSrc && ( + + {name} + + )} +
+
+ ); +} + +/** + * Compact passed item - no expand needed + */ +function PassedComparison({ comparison }) { + let { name, properties } = comparison; + + return ( +
+ {/* Checkmark */} +
+ +
+ + {/* Name */} + {name} + + {/* Meta */} + +
+ ); +} + +/** + * Section header for grouping + */ +function SectionHeader({ title, count, icon, colorClass }) { + if (count === 0) return null; + + return ( +
+ {icon} +

+ {title} +

+ ({count}) +
+ ); +} + +/** + * Summary stats bar + */ +function SummaryBar({ stats }) { + let hasIssues = stats.failed > 0 || stats.new > 0; + + return ( +
+ {/* Status indicator */} +
+ {hasIssues ? ( + <> +
+ +
+
+
Review Required
+
+ {stats.failed + (stats.new || 0)} items need attention +
+
+ + ) : ( + <> +
+ +
+
+
All Tests Passed
+
+ No visual changes detected +
+
+ + )} +
+ + {/* Stats */} +
+
+
+ {stats.total} +
+
+ Total +
+
+
+
+ {stats.passed} +
+
+ Passed +
+
+ {stats.failed > 0 && ( +
+
+ {stats.failed} +
+
+ Changed +
+
+ )} + {(stats.new || 0) > 0 && ( +
+
+ {stats.new} +
+
+ New +
+
+ )} +
+
+ ); +} + +function formatTimestamp(timestamp) { + if (!timestamp) return null; + let date = new Date(timestamp); + return date.toLocaleDateString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} + +function Header({ timestamp }) { + return ( +
+
+ {/* Logo */} +
+
+ +
+
+
+ Vizzly +
+
Visual Test Report
+
+
+ + {/* Timestamp */} + {timestamp && ( +
+ {formatTimestamp(timestamp)} +
+ )} +
+
+ ); +} + +export default function StaticReportView({ reportData }) { + if (!reportData || !reportData.comparisons) { + return ( +
+
+
No Report Data
+

+ No comparison data available for this report. +

+
+
+ ); + } + + let { comparisons } = reportData; + + // Calculate stats + let stats = { + total: comparisons.length, + passed: comparisons.filter(c => c.status === 'passed').length, + failed: comparisons.filter(c => c.status === 'failed').length, + new: comparisons.filter(c => c.status === 'new').length, + error: comparisons.filter(c => c.status === 'error').length, + }; + + // Group comparisons by status + let failed = comparisons.filter(c => c.status === 'failed'); + let newItems = comparisons.filter(c => c.status === 'new'); + let passed = comparisons.filter(c => c.status === 'passed'); + let errors = comparisons.filter(c => c.status === 'error'); + + return ( +
+
+ +
+ {/* Summary */} + + + {/* Failed/Changed Section */} + {failed.length > 0 && ( +
+ +
+ {failed.map((comparison, index) => ( + + ))} +
+
+ )} + + {/* New Section */} + {newItems.length > 0 && ( +
+ +
+ {newItems.map((comparison, index) => ( + + ))} +
+
+ )} + + {/* Errors Section */} + {errors.length > 0 && ( +
+ +
+ {errors.map((comparison, index) => ( + + ))} +
+
+ )} + + {/* Passed Section */} + {passed.length > 0 && ( +
+
+ +
+ +

+ Passed +

+ + ({passed.length}) + + +
+
+
+ {passed.map((comparison, index) => ( + + ))} +
+
+
+ )} + + {/* Footer */} +
+

+ Visual regression report generated by{' '} + + Vizzly + +

+
+
+
+ ); +} diff --git a/src/reporter/src/components/views/comparison-detail-view.jsx b/src/reporter/src/components/views/comparison-detail-view.jsx index 398aa9a3..5c7efeb6 100644 --- a/src/reporter/src/components/views/comparison-detail-view.jsx +++ b/src/reporter/src/components/views/comparison-detail-view.jsx @@ -13,13 +13,13 @@ import FullscreenViewer from '../comparison/fullscreen-viewer.jsx'; * The route parameter :id determines which comparison to show */ export default function ComparisonDetailView() { - const [, setLocation] = useLocation(); - const [, params] = useRoute('/comparison/:id'); + let [, setLocation] = useLocation(); + let [, params] = useRoute('/comparison/:id'); - const { data: reportData } = useReportData(); - const acceptMutation = useAcceptBaseline(); - const rejectMutation = useRejectBaseline(); - const deleteMutation = useDeleteComparison(); + let { data: reportData } = useReportData(); + let acceptMutation = useAcceptBaseline(); + let rejectMutation = useRejectBaseline(); + let deleteMutation = useDeleteComparison(); // Memoize comparisons array to prevent dependency warnings const comparisons = useMemo( diff --git a/src/reporter/src/hooks/queries/use-tdd-queries.js b/src/reporter/src/hooks/queries/use-tdd-queries.js index 87b736f6..39324c6b 100644 --- a/src/reporter/src/hooks/queries/use-tdd-queries.js +++ b/src/reporter/src/hooks/queries/use-tdd-queries.js @@ -1,38 +1,23 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; -import { useMemo } from 'react'; -import { isStaticMode, tdd } from '../../api/client.js'; +import { tdd } from '../../api/client.js'; import { queryKeys } from '../../lib/query-keys.js'; import { SSE_STATE, useReportDataSSE } from '../use-sse.js'; export function useReportData(options = {}) { - // Check if we're in static mode with embedded data - // Memoize to ensure consistency within the hook's lifecycle - const staticMode = useMemo(() => isStaticMode(), []); - const staticData = useMemo( - () => (staticMode ? window.VIZZLY_REPORTER_DATA : undefined), - [staticMode] - ); - - // Use SSE for real-time updates (handles static mode internally) - const { state: sseState } = useReportDataSSE({ - enabled: options.polling !== false && !staticMode, + // Use SSE for real-time updates + let { state: sseState } = useReportDataSSE({ + enabled: options.polling !== false, }); // SSE is connected - it updates the cache directly, no polling needed // Fall back to polling only when SSE is not connected - const sseConnected = sseState === SSE_STATE.CONNECTED; + let sseConnected = sseState === SSE_STATE.CONNECTED; return useQuery({ queryKey: queryKeys.reportData(), queryFn: tdd.getReportData, - // In static mode, provide initial data and disable refetching - initialData: staticData, - // Only poll as fallback when SSE is not connected and not in static mode - refetchInterval: - !staticMode && options.polling !== false && !sseConnected ? 2000 : false, - // Don't refetch in static mode - refetchOnMount: !staticMode, - refetchOnWindowFocus: !staticMode, + // Only poll as fallback when SSE is not connected + refetchInterval: options.polling !== false && !sseConnected ? 2000 : false, ...options, }); } diff --git a/src/reporter/src/hooks/use-sse.js b/src/reporter/src/hooks/use-sse.js index aa17b7ee..5f4d90ee 100644 --- a/src/reporter/src/hooks/use-sse.js +++ b/src/reporter/src/hooks/use-sse.js @@ -1,12 +1,11 @@ import { useQueryClient } from '@tanstack/react-query'; import { useEffect, useRef, useState } from 'react'; -import { isStaticMode } from '../api/client.js'; import { queryKeys } from '../lib/query-keys.js'; /** * SSE connection states */ -export const SSE_STATE = { +export let SSE_STATE = { CONNECTING: 'connecting', CONNECTED: 'connected', DISCONNECTED: 'disconnected', @@ -20,8 +19,7 @@ export const SSE_STATE = { * @returns {{ state: string, error: Error|null }} */ export function useReportDataSSE(options = {}) { - // Compute enabled state once, accounting for static mode - const shouldEnable = !isStaticMode() && (options.enabled ?? true); + let shouldEnable = options.enabled ?? true; const queryClient = useQueryClient(); const eventSourceRef = useRef(null); diff --git a/src/reporter/src/index.html b/src/reporter/src/index.html index 7188ca70..af4cbfd6 100644 --- a/src/reporter/src/index.html +++ b/src/reporter/src/index.html @@ -3,41 +3,10 @@ - Vizzly TDD Reporter - Dev + Vizzly TDD Reporter
- diff --git a/src/reporter/src/main.jsx b/src/reporter/src/main.jsx index dae8fcb9..d61f5508 100644 --- a/src/reporter/src/main.jsx +++ b/src/reporter/src/main.jsx @@ -6,7 +6,7 @@ import { ToastProvider } from './components/ui/toast.jsx'; import { queryClient } from './lib/query-client.js'; import './reporter.css'; -const initializeReporter = () => { +let initializeReporter = () => { let root = document.getElementById('vizzly-reporter-root'); if (!root) { @@ -15,14 +15,11 @@ const initializeReporter = () => { document.body.appendChild(root); } - // Get initial data from window or fetch from API - const initialData = window.VIZZLY_REPORTER_DATA || null; - ReactDOM.createRoot(root).render( - + diff --git a/src/reporter/src/ssr-entry.jsx b/src/reporter/src/ssr-entry.jsx new file mode 100644 index 00000000..2159f8e7 --- /dev/null +++ b/src/reporter/src/ssr-entry.jsx @@ -0,0 +1,18 @@ +/** + * SSR Entry Point + * + * This module exports a function to render the static report to HTML string. + * It's built by Vite as a Node-compatible module for use in the CLI. + */ + +import { renderToString } from 'react-dom/server'; +import StaticReportView from './components/static-report-view.jsx'; + +/** + * Render the static report to an HTML string + * @param {Object} reportData - The report data from report-data.json + * @returns {string} The rendered HTML + */ +export function renderStaticReport(reportData) { + return renderToString(); +} diff --git a/src/reporter/vite.ssr.config.js b/src/reporter/vite.ssr.config.js new file mode 100644 index 00000000..a5d40b95 --- /dev/null +++ b/src/reporter/vite.ssr.config.js @@ -0,0 +1,33 @@ +import { resolve } from 'node:path'; +import react from '@vitejs/plugin-react'; +import { defineConfig } from 'vite'; + +/** + * Vite config for SSR build of the static report renderer. + * Outputs a Node-compatible ES module that can render React to HTML strings. + */ +export default defineConfig({ + plugins: [react()], + css: { + postcss: '../../postcss.config.js', + }, + resolve: { + preserveSymlinks: false, + dedupe: ['react', 'react-dom'], + }, + build: { + ssr: true, + outDir: '../../dist/reporter-ssr', + emptyOutDir: true, + rollupOptions: { + input: resolve(__dirname, 'src/ssr-entry.jsx'), + output: { + format: 'esm', + entryFileNames: 'ssr-entry.js', + }, + }, + }, + define: { + 'process.env.NODE_ENV': '"production"', + }, +}); diff --git a/src/services/static-report-generator.js b/src/services/static-report-generator.js new file mode 100644 index 00000000..fc22c646 --- /dev/null +++ b/src/services/static-report-generator.js @@ -0,0 +1,228 @@ +/** + * Static Report Generator + * + * Generates a self-contained HTML report from TDD test results using SSR. + * The report is pre-rendered HTML with inlined CSS - no JavaScript required. + */ + +import { + copyFileSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +let __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * Get the path to the dist/reporter directory (for CSS) + */ +function getReporterDistPath() { + // Try production path first (when running from dist/) + let distPath = join(__dirname, '..', 'reporter'); + if (existsSync(join(distPath, 'reporter-bundle.css'))) { + return distPath; + } + + // Fall back to development path (when running from src/) + distPath = join(__dirname, '..', '..', 'dist', 'reporter'); + if (existsSync(join(distPath, 'reporter-bundle.css'))) { + return distPath; + } + + throw new Error( + 'Reporter bundle not found. Run "npm run build:reporter" first.' + ); +} + +/** + * Get the path to the SSR module + */ +function getSSRModulePath() { + // Try production path first (when running from dist/) + let ssrPath = join(__dirname, '..', 'reporter-ssr', 'ssr-entry.js'); + if (existsSync(ssrPath)) { + return ssrPath; + } + + // Fall back to development path (when running from src/) + ssrPath = join(__dirname, '..', '..', 'dist', 'reporter-ssr', 'ssr-entry.js'); + if (existsSync(ssrPath)) { + return ssrPath; + } + + throw new Error( + 'SSR module not found. Run "npm run build:reporter-ssr" first.' + ); +} + +/** + * Recursively copy a directory + */ +function copyDirectory(src, dest) { + if (!existsSync(src)) return; + + mkdirSync(dest, { recursive: true }); + + let entries = readdirSync(src); + for (let entry of entries) { + let srcPath = join(src, entry); + let destPath = join(dest, entry); + let stat = statSync(srcPath); + + if (stat.isDirectory()) { + copyDirectory(srcPath, destPath); + } else { + copyFileSync(srcPath, destPath); + } + } +} + +/** + * Transform image URLs from server paths to relative file paths + * /images/baselines/foo.png -> ./images/baselines/foo.png + */ +function transformImageUrls(reportData) { + let transformed = JSON.parse(JSON.stringify(reportData)); + + function transformUrl(url) { + if (!url || typeof url !== 'string') return url; + if (url.startsWith('/images/')) { + return `.${url}`; + } + return url; + } + + function transformComparison(comparison) { + return { + ...comparison, + baseline: transformUrl(comparison.baseline), + current: transformUrl(comparison.current), + diff: transformUrl(comparison.diff), + }; + } + + if (transformed.comparisons) { + transformed.comparisons = transformed.comparisons.map(transformComparison); + } + + if (transformed.groups) { + transformed.groups = transformed.groups.map(group => ({ + ...group, + comparisons: group.comparisons?.map(transformComparison) || [], + })); + } + + return transformed; +} + +/** + * Generate the static HTML document with SSR-rendered content + */ +function generateHtml(renderedContent, css) { + return ` + + + + + Vizzly Visual Test Report + + + + ${renderedContent} + +`; +} + +/** + * Generate a static report from the current TDD results + * + * @param {string} workingDir - The project working directory + * @param {Object} options - Generation options + * @param {string} [options.outputDir] - Output directory (default: .vizzly/report) + * @returns {Promise<{success: boolean, reportPath: string, error?: string}>} + */ +export async function generateStaticReport(workingDir, options = {}) { + let outputDir = options.outputDir || join(workingDir, '.vizzly', 'report'); + let vizzlyDir = join(workingDir, '.vizzly'); + + try { + // Read report data + let reportDataPath = join(vizzlyDir, 'report-data.json'); + if (!existsSync(reportDataPath)) { + return { + success: false, + reportPath: null, + error: 'No report data found. Run tests first.', + }; + } + + let reportData = JSON.parse(readFileSync(reportDataPath, 'utf8')); + + // Read baseline metadata if available + let metadataPath = join(vizzlyDir, 'baselines', 'metadata.json'); + if (existsSync(metadataPath)) { + try { + reportData.baseline = JSON.parse(readFileSync(metadataPath, 'utf8')); + } catch { + // Ignore metadata read errors + } + } + + // Transform image URLs to relative paths + let transformedData = transformImageUrls(reportData); + + // Load and use the SSR module + let ssrModulePath = getSSRModulePath(); + let { renderStaticReport } = await import(ssrModulePath); + let renderedContent = renderStaticReport(transformedData); + + // Get CSS + let reporterDistPath = getReporterDistPath(); + let css = readFileSync( + join(reporterDistPath, 'reporter-bundle.css'), + 'utf8' + ); + + // Create output directory + mkdirSync(outputDir, { recursive: true }); + + // Copy image directories + let imageDirs = ['baselines', 'current', 'diffs']; + for (let dir of imageDirs) { + let srcDir = join(vizzlyDir, dir); + let destDir = join(outputDir, 'images', dir); + copyDirectory(srcDir, destDir); + } + + // Generate and write HTML + let html = generateHtml(renderedContent, css); + let htmlPath = join(outputDir, 'index.html'); + writeFileSync(htmlPath, html, 'utf8'); + + return { + success: true, + reportPath: htmlPath, + }; + } catch (error) { + return { + success: false, + reportPath: null, + error: error.message, + }; + } +} + +/** + * Get the file:// URL for a report path + */ +export function getReportFileUrl(reportPath) { + return `file://${reportPath}`; +} diff --git a/src/utils/browser.js b/src/utils/browser.js index 28806412..df9825ad 100644 --- a/src/utils/browser.js +++ b/src/utils/browser.js @@ -5,23 +5,44 @@ import { execFile } from 'node:child_process'; import { platform } from 'node:os'; +/** + * Validate URL is safe to open (prevent command injection) + * Only allows http://, https://, and file:// URLs + * @param {string} url - URL to validate + * @returns {boolean} True if safe + */ +function isValidUrl(url) { + if (typeof url !== 'string' || url.length === 0) { + return false; + } + + // Only allow safe URL schemes + let validSchemes = ['http://', 'https://', 'file://']; + return validSchemes.some(scheme => url.startsWith(scheme)); +} + /** * Open a URL in the default browser - * @param {string} url - URL to open + * @param {string} url - URL to open (must be http://, https://, or file://) * @returns {Promise} True if successful */ export async function openBrowser(url) { + // Validate URL to prevent command injection + if (!isValidUrl(url)) { + return false; + } + return new Promise(resolve => { let command; let args; - const os = platform(); + let currentPlatform = platform(); - switch (os) { + switch (currentPlatform) { case 'darwin': // macOS command = 'open'; args = [url]; break; - case 'win32': // Windows + case 'win32': // Windows - use start command with validated URL command = 'cmd.exe'; args = ['/c', 'start', '""', url]; break; diff --git a/tests/services/static-report-generator.test.js b/tests/services/static-report-generator.test.js new file mode 100644 index 00000000..be994091 --- /dev/null +++ b/tests/services/static-report-generator.test.js @@ -0,0 +1,291 @@ +import assert from 'node:assert'; +import { + existsSync, + mkdirSync, + readFileSync, + rmSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { afterEach, beforeEach, describe, it } from 'node:test'; +import { fileURLToPath } from 'node:url'; + +let __dirname = dirname(fileURLToPath(import.meta.url)); + +/** + * Check if the SSR build artifacts exist (required for full tests) + */ +function ssrBuildExists() { + let ssrPath = join( + __dirname, + '..', + '..', + 'dist', + 'reporter-ssr', + 'ssr-entry.js' + ); + return existsSync(ssrPath); +} + +/** + * Create a unique temporary directory for each test + */ +function createTempDir() { + let dir = join( + tmpdir(), + `vizzly-test-${Date.now()}-${Math.random().toString(36).slice(2)}` + ); + mkdirSync(dir, { recursive: true }); + return dir; +} + +/** + * Clean up temp directory + */ +function cleanupTempDir(dir) { + try { + if (existsSync(dir)) { + rmSync(dir, { recursive: true, force: true }); + } + } catch { + // Ignore cleanup errors + } +} + +/** + * Create mock .vizzly directory structure with test data + */ +function setupMockVizzlyDir(workingDir, options = {}) { + let vizzlyDir = join(workingDir, '.vizzly'); + mkdirSync(vizzlyDir, { recursive: true }); + + // Create report-data.json + let reportData = options.reportData || { + comparisons: [ + { + id: 'test-1', + name: 'test-screenshot', + status: 'failed', + baseline: '/images/baselines/test.png', + current: '/images/current/test.png', + diff: '/images/diffs/test.png', + }, + ], + timestamp: Date.now(), + }; + writeFileSync( + join(vizzlyDir, 'report-data.json'), + JSON.stringify(reportData) + ); + + // Create image directories with test images + let imageData = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==', + 'base64' + ); + + mkdirSync(join(vizzlyDir, 'baselines'), { recursive: true }); + mkdirSync(join(vizzlyDir, 'current'), { recursive: true }); + mkdirSync(join(vizzlyDir, 'diffs'), { recursive: true }); + + writeFileSync(join(vizzlyDir, 'baselines', 'test.png'), imageData); + writeFileSync(join(vizzlyDir, 'current', 'test.png'), imageData); + writeFileSync(join(vizzlyDir, 'diffs', 'test.png'), imageData); + + return vizzlyDir; +} + +describe('services/static-report-generator', () => { + let tempDir; + + beforeEach(() => { + tempDir = createTempDir(); + }); + + afterEach(() => { + cleanupTempDir(tempDir); + }); + + describe('generateStaticReport', () => { + it('should return error when report data is missing', async () => { + let { generateStaticReport } = await import( + '../../src/services/static-report-generator.js' + ); + + let result = await generateStaticReport(tempDir); + + assert.strictEqual(result.success, false); + assert.strictEqual(result.reportPath, null); + assert.ok(result.error.includes('No report data found')); + }); + + it('should generate HTML report with correct structure', async t => { + // Skip if SSR build artifacts don't exist (CI runs tests before build) + if (!ssrBuildExists()) { + t.skip('SSR build not available - run npm run build first'); + return; + } + + setupMockVizzlyDir(tempDir); + + let { generateStaticReport } = await import( + '../../src/services/static-report-generator.js' + ); + + let result = await generateStaticReport(tempDir); + + assert.strictEqual(result.success, true); + assert.ok(result.reportPath.endsWith('index.html')); + assert.ok(existsSync(result.reportPath)); + + let html = readFileSync(result.reportPath, 'utf8'); + assert.ok(html.includes('')); + assert.ok(html.includes('Vizzly Visual Test Report')); + assert.ok(html.includes('