Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 45 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
# Repository Guidelines

## Project Structure & Module Organization
Core CLI code lives in `src/` (commands, API client, services, auth, server, TDD, and shared utils).
UI for local/static reports lives in `src/reporter/` (Vite + React).
Tests live in `tests/` with suites grouped by domain (`tests/commands`, `tests/server`, `tests/services`, etc.).
Type definition tests live in `test-d/`.
Framework integrations and SDK clients live in `clients/` (`storybook`, `static-site`, `vitest`, `ember`, `ruby`, `swift`).
Reference docs are in `docs/`, examples in `examples/`, and built artifacts output to `dist/`.

## Build, Test, and Development Commands
- `npm run build`: clean and compile CLI + reporter bundles into `dist/`.
- `npm test`: run Node test runner suites with coverage enabled.
- `npm run test:watch`: watch mode for fast local iteration.
- `npm run test:reporter`: run Playwright reporter workflow tests.
- `npm run test:types`: validate published type definitions with `tsd`.
- `npm run lint` / `npm run format:check`: enforce Biome lint/format rules.
- `npm run fix`: run formatter + safe lint fixes.
- `npm run cli -- <args>`: run local CLI entrypoint (example: `npm run cli -- status`).

## Coding Style & Naming Conventions
Use ESM JavaScript with top-level imports. Prefer functional modules and explicit inputs/outputs over class-heavy designs.
Project convention: prefer `let` over `const`.
Biome is the source of truth: 2-space indent, single quotes, semicolons, trailing commas (`es5`), 80-char line width.
File names are lowercase with hyphens where needed (example: `config-service.js`); tests use `*.test.js`.

## Testing Guidelines
Primary frameworks: Node’s built-in test runner (`node --test`), Playwright (reporter E2E), and `tsd` (types).
Write tests around user outcomes and observable behavior; avoid mocking internal modules.
Mock only external boundaries (network/time/randomness).
No arbitrary sleeps; wait on concrete state/events.
There is no strict coverage percentage gate today, but changed behavior should include focused tests.

## Commit & Pull Request Guidelines
Follow existing history style: gitmoji-prefixed, action-oriented commit subjects (examples: `✨`, `🐛`, `🔧`, `🔖`, `⚡️`).
Keep subjects concise; include issue/PR refs when relevant (example: `(#217)`).
PRs should include:
- Why the change is needed.
- A clear summary of all meaningful diff areas.
- A test plan with exact commands run.
- Screenshots or terminal output for UI/reporting changes when helpful.

## Security & Configuration Tips
Use Node.js `>=22`. Keep secrets (for example `VIZZLY_TOKEN`) out of git.
For local development, isolate CLI state with `VIZZLY_HOME` (for example `~/.vizzly.dev`) to avoid mutating real user config.
17 changes: 13 additions & 4 deletions src/reporter/src/components/app-router.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,19 @@ function ErrorState({ error, onRetry }) {

export default function AppRouter() {
const [location, setLocation] = useLocation();
const { data: reportData, isLoading, error, refetch } = useReportData();

// Settings and Builds can load independently without report-data polling/fetching
const isManagementRoute = location === '/settings' || location === '/builds';

const {
data: reportData,
isLoading,
error,
refetch,
} = useReportData({
enabled: !isManagementRoute,
polling: !isManagementRoute,
});

// Check if we're on a comparison detail route (fullscreen)
const isComparisonRoute = location.startsWith('/comparison/');
Expand All @@ -64,9 +76,6 @@ export default function AppRouter() {
else setLocation('/');
};

// Settings, Projects, and Builds don't need screenshot data - always allow access
const isManagementRoute = location === '/settings' || location === '/builds';

// Loading state (but not for management routes)
if (isLoading && !reportData && !isManagementRoute) {
return (
Expand Down
10 changes: 8 additions & 2 deletions src/reporter/src/components/comparison/fullscreen-viewer.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ function FullscreenViewerInner({
let [showQueue, setShowQueue] = useState(true);
let [showInspector, setShowInspector] = useState(false);
let [queueFilter, setQueueFilter] = useState('needs-review');
let [_showBaseline, setShowBaseline] = useState(true);
let [showBaseline, setShowBaseline] = useState(true);
let [showRegions, setShowRegions] = useState(false);

let { zoom, setZoom } = useZoom('fit');
Expand Down Expand Up @@ -369,6 +369,10 @@ function FullscreenViewerInner({

// Scroll active queue item into view
useEffect(() => {
if (!showQueue) return;
if (currentFilteredIndex < 0) return;
if (currentFilteredIndex >= filteredQueueItems.length) return;

let item = activeQueueItemRef.current;
if (!item) return;

Expand All @@ -389,7 +393,7 @@ function FullscreenViewerInner({
behavior: 'smooth',
});
}
}, []);
}, [currentFilteredIndex, filteredQueueItems.length, showQueue]);

if (!comparison) {
return (
Expand Down Expand Up @@ -680,6 +684,8 @@ function FullscreenViewerInner({
viewMode={viewMode === VIEW_MODES.ONION ? 'onion-skin' : viewMode}
showDiffOverlay={showDiffOverlay}
onDiffToggle={() => setShowDiffOverlay(prev => !prev)}
showBaseline={showBaseline}
onToggleBaseline={() => setShowBaseline(prev => !prev)}
onionSkinPosition={onionSkinPosition}
onOnionSkinChange={setOnionSkinPosition}
zoom={zoom}
Expand Down
36 changes: 34 additions & 2 deletions src/reporter/src/components/comparison/screenshot-display.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import {
HotSpotOverlay,
OnionSkinMode,
OverlayMode,
ToggleView,
ToggleMode,
} from '@vizzly-testing/observatory';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';

Expand All @@ -16,6 +16,8 @@ export function ScreenshotDisplay({
comparison,
viewMode = 'overlay',
showDiffOverlay = true,
showBaseline = true,
onToggleBaseline,
onionSkinPosition = 50,
onOnionSkinChange,
onDiffToggle,
Expand All @@ -29,6 +31,10 @@ export function ScreenshotDisplay({
}) {
const [imageErrors, setImageErrors] = useState(new Set());
const [imageLoadStates, setImageLoadStates] = useState(new Map());
const [baselineImageLoaded, setBaselineImageLoaded] = useState(false);
const [currentImageLoaded, setCurrentImageLoaded] = useState(false);
const [baselineImageError, setBaselineImageError] = useState(false);
const [currentImageError, setCurrentImageError] = useState(false);
const [fitScale, setFitScale] = useState(1);
const [naturalImageSize, setNaturalImageSize] = useState({
width: 0,
Expand Down Expand Up @@ -135,6 +141,22 @@ export function ScreenshotDisplay({
};
}, [comparison]);

// Reset controlled toggle state when navigating between screenshots
useEffect(() => {
if (!screenshot?.id) {
setBaselineImageLoaded(false);
setCurrentImageLoaded(false);
setBaselineImageError(false);
setCurrentImageError(false);
return;
}

setBaselineImageLoaded(false);
setCurrentImageLoaded(false);
setBaselineImageError(false);
setCurrentImageError(false);
}, [screenshot?.id]);

// Render new screenshot - just show current image
if (
!comparison ||
Expand Down Expand Up @@ -276,13 +298,23 @@ export function ScreenshotDisplay({
>
{/* Render appropriate comparison mode */}
{viewMode === 'toggle' && imageUrls.baseline ? (
<ToggleView
<ToggleMode
baselineImageUrl={imageUrls.baseline}
currentImageUrl={imageUrls.current}
baselineImageLoaded={baselineImageLoaded}
setBaselineImageLoaded={setBaselineImageLoaded}
currentImageLoaded={currentImageLoaded}
setCurrentImageLoaded={setCurrentImageLoaded}
baselineImageError={baselineImageError}
setBaselineImageError={setBaselineImageError}
currentImageError={currentImageError}
setCurrentImageError={setCurrentImageError}
screenshot={screenshot}
onImageError={handleImageError}
onImageLoad={handleImageLoad}
imageErrors={imageErrors}
showBaseline={showBaseline}
onToggle={onToggleBaseline}
/>
) : viewMode === 'onion-skin' && imageUrls.baseline ? (
<OnionSkinMode
Expand Down
6 changes: 4 additions & 2 deletions src/reporter/src/components/static-report-view.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
* Designed for quick visual scanning with grouped status sections.
*/

import { isNewComparisonStatus } from '../utils/status-utils.js';

// Status configuration
const statusConfig = {
failed: {
Expand Down Expand Up @@ -541,13 +543,13 @@ export default function StaticReportView({ reportData }) {
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,
new: comparisons.filter(c => isNewComparisonStatus(c.status)).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 newItems = comparisons.filter(c => isNewComparisonStatus(c.status));
let passed = comparisons.filter(c => c.status === 'passed');
let errors = comparisons.filter(c => c.status === 'error');

Expand Down
31 changes: 14 additions & 17 deletions src/reporter/src/components/ui/smart-image.jsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
import {
ArrowPathIcon,
ExclamationTriangleIcon,
PhotoIcon,
} from '@heroicons/react/24/outline';
import useImageLoader from '../../hooks/use-image-loader.js';
import { useEffect, useState } from 'react';

export default function SmartImage({ src, alt, className, style, onClick }) {
const status = useImageLoader(src);
let [status, setStatus] = useState(src ? 'loading' : 'missing');

useEffect(() => {
if (!src) {
setStatus('missing');
return;
}
setStatus('loading');
}, [src]);

if (status === 'missing') {
return (
Expand All @@ -22,20 +29,6 @@ export default function SmartImage({ src, alt, className, style, onClick }) {
);
}

if (status === 'loading') {
return (
<div
className="flex items-center justify-center bg-gray-700 border border-gray-600 rounded min-h-[200px]"
style={style}
>
<div className="text-gray-400 text-center">
<ArrowPathIcon className="w-8 h-8 mx-auto mb-3 animate-spin" />
<div className="text-sm">Loading {alt.toLowerCase()}...</div>
</div>
</div>
);
}

if (status === 'error') {
return (
<div
Expand Down Expand Up @@ -69,6 +62,10 @@ export default function SmartImage({ src, alt, className, style, onClick }) {
alt={alt}
className={className}
style={style}
loading="lazy"
decoding="async"
onLoad={() => setStatus('loaded')}
onError={() => setStatus('error')}
onClick={onClick}
onKeyDown={handleKeyDown}
role={onClick ? 'button' : undefined}
Expand Down
11 changes: 8 additions & 3 deletions src/reporter/src/components/views/comparisons-view.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,10 @@ import {
useReportData,
} from '../../hooks/queries/use-tdd-queries.js';
import useComparisonFilters from '../../hooks/use-comparison-filters.js';
import {
isNewComparisonStatus,
needsReviewComparisonStatus,
} from '../../utils/status-utils.js';
import ScreenshotList from '../comparison/screenshot-list.jsx';
import DashboardFilters from '../dashboard/dashboard-filters.jsx';
import { Button, Card, CardBody, EmptyState } from '../design-system/index.js';
Expand Down Expand Up @@ -213,15 +217,16 @@ export default function ComparisonsView() {
selectedViewport !== 'all';

// Check if there are changes to accept (failed or new comparisons)
let hasChangesToAccept = reportData?.comparisons?.some(
c => c.status === 'failed' || c.status === 'new'
let hasChangesToAccept = reportData?.comparisons?.some(c =>
needsReviewComparisonStatus(c.status)
);

// Count failed and new comparisons for the button label
let failedCount =
reportData?.comparisons?.filter(c => c.status === 'failed').length || 0;
let newCount =
reportData?.comparisons?.filter(c => c.status === 'new').length || 0;
reportData?.comparisons?.filter(c => isNewComparisonStatus(c.status))
.length || 0;
let _totalToAccept = failedCount + newCount;

if (hasNoComparisons) {
Expand Down
11 changes: 8 additions & 3 deletions src/reporter/src/components/views/stats-view.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ import {
useReportData,
useResetBaselines,
} from '../../hooks/queries/use-tdd-queries.js';
import {
isNewComparisonStatus,
needsReviewComparisonStatus,
} from '../../utils/status-utils.js';
import {
Badge,
Button,
Expand Down Expand Up @@ -65,12 +69,13 @@ export default function StatsView() {
const total = comparisons?.length || 0;
const passed = comparisons?.filter(c => c.status === 'passed').length || 0;
const failed = comparisons?.filter(c => c.status === 'failed').length || 0;
const newCount = comparisons?.filter(c => c.status === 'new').length || 0;
const newCount =
comparisons?.filter(c => isNewComparisonStatus(c.status)).length || 0;
const passRate = total > 0 ? Math.round((passed / total) * 100) : 0;

// Check if there are any changes to accept
const hasChanges = comparisons?.some(
c => c.status === 'failed' || c.status === 'new'
const hasChanges = comparisons?.some(c =>
needsReviewComparisonStatus(c.status)
);

const handleAcceptAll = useCallback(async () => {
Expand Down
6 changes: 4 additions & 2 deletions src/reporter/src/hooks/queries/use-tdd-queries.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ export function useComparison(id, options = {}) {
}

export function useReportData(options = {}) {
let { polling = true, ...queryOptions } = options;

// Read SSE state from the singleton provider
let { state: sseState } = useSSEState();

Expand All @@ -25,8 +27,8 @@ export function useReportData(options = {}) {
queryKey: queryKeys.reportData(),
queryFn: tdd.getReportData,
// Only poll as fallback when SSE is not connected
refetchInterval: options.polling !== false && !sseConnected ? 2000 : false,
...options,
refetchInterval: polling !== false && !sseConnected ? 2000 : false,
...queryOptions,
});
}

Expand Down
3 changes: 2 additions & 1 deletion src/reporter/src/hooks/use-comparison-filters.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
sortComparisons,
} from '../utils/comparison-helpers.js';
import { FILTER_TYPES, SORT_TYPES } from '../utils/constants.js';
import { isNewComparisonStatus } from '../utils/status-utils.js';

// Read URL params
const getInitialState = () => {
Expand Down Expand Up @@ -130,7 +131,7 @@ export default function useComparisonFilters(comparisons = []) {
all: comparisons.length,
failed: comparisons.filter(c => c.status === 'failed').length,
passed: comparisons.filter(c => c.status === 'passed').length,
new: comparisons.filter(c => c.status === 'new').length,
new: comparisons.filter(c => isNewComparisonStatus(c.status)).length,
rejected: comparisons.filter(c => c.status === 'rejected').length,
},
};
Expand Down
25 changes: 0 additions & 25 deletions src/reporter/src/hooks/use-image-loader.js

This file was deleted.

Loading