diff --git a/package-lock.json b/package-lock.json index 82df511..5821b81 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@vizzly-testing/cli", - "version": "0.29.2", + "version": "0.29.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@vizzly-testing/cli", - "version": "0.29.2", + "version": "0.29.3", "license": "MIT", "dependencies": { "@vizzly-testing/honeydiff": "^0.10.0", @@ -37,6 +37,7 @@ "@vitejs/plugin-react": "^5.0.3", "@vizzly-testing/observatory": "^0.3.3", "autoprefixer": "^10.4.21", + "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-transform-remove-console": "^6.9.4", "postcss": "^8.5.6", "react": "^19.1.1", @@ -3792,6 +3793,16 @@ "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" } }, + "node_modules/babel-plugin-react-compiler": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", + "integrity": "sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.26.0" + } + }, "node_modules/babel-plugin-transform-remove-console": { "version": "6.9.4", "resolved": "https://registry.npmjs.org/babel-plugin-transform-remove-console/-/babel-plugin-transform-remove-console-6.9.4.tgz", diff --git a/package.json b/package.json index 09d9f30..7b8bc2d 100644 --- a/package.json +++ b/package.json @@ -122,6 +122,7 @@ "@vitejs/plugin-react": "^5.0.3", "@vizzly-testing/observatory": "^0.3.3", "autoprefixer": "^10.4.21", + "babel-plugin-react-compiler": "^1.0.0", "babel-plugin-transform-remove-console": "^6.9.4", "postcss": "^8.5.6", "react": "^19.1.1", diff --git a/src/reporter/src/components/comparison/screenshot-list.jsx b/src/reporter/src/components/comparison/screenshot-list.jsx index 064c3df..6435df5 100644 --- a/src/reporter/src/components/comparison/screenshot-list.jsx +++ b/src/reporter/src/components/comparison/screenshot-list.jsx @@ -10,6 +10,10 @@ import { useMemo } from 'react'; import { Badge, Button } from '../design-system/index.js'; import SmartImage from '../ui/smart-image.jsx'; +function getComparisonId(comparison) { + return comparison.id || comparison.signature || comparison.name; +} + /** * Group comparisons by name and calculate aggregate stats */ @@ -366,6 +370,8 @@ export default function ScreenshotList({ onSelectComparison, onAcceptComparison, onRejectComparison, + onAcceptGroup, + onRejectGroup, loadingStates = {}, }) { let groups = useMemo(() => { @@ -382,16 +388,30 @@ export default function ScreenshotList({ } let handleAcceptGroup = group => { + let ids = group.comparisons.map(getComparisonId).filter(Boolean); + + if (onAcceptGroup) { + onAcceptGroup(ids); + return; + } + if (!onAcceptComparison) return; - for (let comp of group.comparisons) { - onAcceptComparison(comp.id || comp.signature || comp.name); + for (let id of ids) { + onAcceptComparison(id); } }; let handleRejectGroup = group => { + let ids = group.comparisons.map(getComparisonId).filter(Boolean); + + if (onRejectGroup) { + onRejectGroup(ids); + return; + } + if (!onRejectComparison) return; - for (let comp of group.comparisons) { - onRejectComparison(comp.id || comp.signature || comp.name); + for (let id of ids) { + onRejectComparison(id); } }; diff --git a/src/reporter/src/components/views/builds-view.jsx b/src/reporter/src/components/views/builds-view.jsx index dd48b4e..bc9e317 100644 --- a/src/reporter/src/components/views/builds-view.jsx +++ b/src/reporter/src/components/views/builds-view.jsx @@ -393,15 +393,14 @@ export default function BuildsView() { // Use TanStack Query for data const { data: authData, isLoading: authLoading } = useAuthStatus(); + const authenticated = authData?.authenticated; const { data: projectsData, isLoading: projectsLoading, refetch, - } = useProjects(); + } = useProjects({ enabled: authenticated === true }); const downloadMutation = useDownloadBaselines(); - const authenticated = authData?.authenticated; - // Group projects by organization const projectsByOrg = useMemo(() => { const projects = projectsData?.projects || []; diff --git a/src/reporter/src/components/views/comparisons-view.jsx b/src/reporter/src/components/views/comparisons-view.jsx index 6143068..c2d43f0 100644 --- a/src/reporter/src/components/views/comparisons-view.jsx +++ b/src/reporter/src/components/views/comparisons-view.jsx @@ -9,8 +9,8 @@ import { useCallback, useState } from 'react'; import { useLocation } from 'wouter'; import { useAcceptAllBaselines, - useAcceptBaseline, - useRejectBaseline, + useAcceptBaselinesBatch, + useRejectBaselinesBatch, useReportData, } from '../../hooks/queries/use-tdd-queries.js'; import useComparisonFilters from '../../hooks/use-comparison-filters.js'; @@ -23,6 +23,18 @@ import DashboardFilters from '../dashboard/dashboard-filters.jsx'; import { Button, Card, CardBody, EmptyState } from '../design-system/index.js'; import { useToast } from '../ui/toast.jsx'; +function asIdList(value) { + if (Array.isArray(value)) { + return value.filter(Boolean); + } + + if (value) { + return [value]; + } + + return []; +} + /** * Action banner for accepting changes */ @@ -122,8 +134,8 @@ export default function ComparisonsView() { // Use TanStack Query for data let { data: reportData, isLoading, refetch } = useReportData(); let acceptAllMutation = useAcceptAllBaselines(); - let acceptMutation = useAcceptBaseline(); - let rejectMutation = useRejectBaseline(); + let acceptBatchMutation = useAcceptBaselinesBatch(); + let rejectBatchMutation = useRejectBaselinesBatch(); let { filteredComparisons, @@ -152,38 +164,93 @@ export default function ComparisonsView() { [setLocation] ); - // Accept a single comparison - let handleAcceptComparison = useCallback( - id => { - setLoadingStates(prev => ({ ...prev, [id]: 'accepting' })); - acceptMutation.mutate(id, { + let updateLoadingStateForIds = useCallback((ids, state) => { + setLoadingStates(prev => { + let next = { ...prev }; + + for (let id of ids) { + if (!id) continue; + + if (state) { + next[id] = state; + } else { + delete next[id]; + } + } + + return next; + }); + }, []); + + let handleAcceptGroup = useCallback( + ids => { + if (!ids || ids.length === 0) return; + + updateLoadingStateForIds(ids, 'accepting'); + acceptBatchMutation.mutate(ids, { onSuccess: () => { - setLoadingStates(prev => ({ ...prev, [id]: 'accepted' })); + updateLoadingStateForIds(ids, 'accepted'); }, onError: err => { - setLoadingStates(prev => ({ ...prev, [id]: undefined })); + let succeededIds = asIdList(err?.succeededIds); + let failedIds = asIdList(err?.failedIds); + + if (succeededIds.length > 0) { + updateLoadingStateForIds(succeededIds, 'accepted'); + } + + if (failedIds.length > 0) { + updateLoadingStateForIds(failedIds, null); + } else { + updateLoadingStateForIds(ids, null); + } + addToast(`Failed to accept: ${err.message}`, 'error'); }, }); }, - [acceptMutation, addToast] + [acceptBatchMutation, addToast, updateLoadingStateForIds] ); - // Reject a single comparison - let handleRejectComparison = useCallback( - id => { - setLoadingStates(prev => ({ ...prev, [id]: 'rejecting' })); - rejectMutation.mutate(id, { + let handleRejectGroup = useCallback( + ids => { + if (!ids || ids.length === 0) return; + + updateLoadingStateForIds(ids, 'rejecting'); + rejectBatchMutation.mutate(ids, { onSuccess: () => { - setLoadingStates(prev => ({ ...prev, [id]: 'rejected' })); + updateLoadingStateForIds(ids, 'rejected'); }, onError: err => { - setLoadingStates(prev => ({ ...prev, [id]: undefined })); + let succeededIds = asIdList(err?.succeededIds); + let failedIds = asIdList(err?.failedIds); + + if (succeededIds.length > 0) { + updateLoadingStateForIds(succeededIds, 'rejected'); + } + + if (failedIds.length > 0) { + updateLoadingStateForIds(failedIds, null); + } else { + updateLoadingStateForIds(ids, null); + } + addToast(`Failed to reject: ${err.message}`, 'error'); }, }); }, - [rejectMutation, addToast] + [rejectBatchMutation, addToast, updateLoadingStateForIds] + ); + + // Single-item handlers for fallback paths + let handleAcceptComparison = useCallback( + id => handleAcceptGroup(id ? [id] : []), + [handleAcceptGroup] + ); + + let handleRejectComparison = useCallback( + id => handleRejectGroup(id ? [id] : []), + [handleRejectGroup] ); let handleAcceptAll = useCallback(async () => { @@ -227,7 +294,6 @@ export default function ComparisonsView() { let newCount = reportData?.comparisons?.filter(c => isNewComparisonStatus(c.status)) .length || 0; - let _totalToAccept = failedCount + newCount; if (hasNoComparisons) { return ( @@ -302,6 +368,8 @@ export default function ComparisonsView() { onSelectComparison={handleSelectComparison} onAcceptComparison={handleAcceptComparison} onRejectComparison={handleRejectComparison} + onAcceptGroup={handleAcceptGroup} + onRejectGroup={handleRejectGroup} loadingStates={loadingStates} /> )} diff --git a/src/reporter/src/components/views/settings-view.jsx b/src/reporter/src/components/views/settings-view.jsx index a3b1da1..92a1399 100644 --- a/src/reporter/src/components/views/settings-view.jsx +++ b/src/reporter/src/components/views/settings-view.jsx @@ -1,4 +1,4 @@ -import { useCallback, useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { useConfig, useUpdateProjectConfig, @@ -18,15 +18,54 @@ import { useToast } from '../ui/toast.jsx'; function getInitialFormData(config) { return { - threshold: config?.comparison?.threshold ?? 2.0, - port: config?.server?.port ?? 47392, - timeout: config?.server?.timeout ?? 30000, + threshold: String(config?.comparison?.threshold ?? 2.0), + port: String(config?.server?.port ?? 47392), + timeout: String(config?.server?.timeout ?? 30000), buildName: config?.build?.name ?? 'Build {timestamp}', environment: config?.build?.environment ?? 'test', openReport: config?.tdd?.openReport ?? false, }; } +function parseNumberInput(value) { + if (value === null || value === undefined) return null; + if (String(value).trim() === '') return null; + + let parsed = Number(value); + return Number.isFinite(parsed) ? parsed : null; +} + +function parseIntegerInput(value) { + let parsed = parseNumberInput(value); + return Number.isInteger(parsed) ? parsed : null; +} + +function getValidationErrors(formData) { + let errors = { + threshold: null, + port: null, + timeout: null, + }; + + let threshold = parseNumberInput(formData.threshold); + if (threshold === null || threshold < 0) { + errors.threshold = 'Threshold must be a number greater than or equal to 0.'; + } + + let port = parseIntegerInput(formData.port); + if (port === null || port < 1 || port > 65535) { + errors.port = 'Port must be a whole number between 1 and 65535.'; + } + + let timeout = parseIntegerInput(formData.timeout); + if (timeout === null || timeout < 0) { + errors.timeout = + 'Timeout must be a whole number greater than or equal to 0.'; + } + + return errors; +} + function SourceBadge({ source }) { let variants = { default: 'default', @@ -70,6 +109,11 @@ function SettingsForm({ config, sources, onSave, isSaving }) { let initialFormData = getInitialFormData(config); let [formData, setFormData] = useState(initialFormData); let [hasChanges, setHasChanges] = useState(false); + let validationErrors = useMemo( + () => getValidationErrors(formData), + [formData] + ); + let hasValidationErrors = Object.values(validationErrors).some(Boolean); let handleFieldChange = useCallback((name, value) => { setFormData(prev => ({ ...prev, [name]: value })); @@ -82,13 +126,21 @@ function SettingsForm({ config, sources, onSave, isSaving }) { }, [config]); let handleSave = useCallback(() => { + let threshold = parseNumberInput(formData.threshold); + let port = parseIntegerInput(formData.port); + let timeout = parseIntegerInput(formData.timeout); + + if (threshold === null || port === null || timeout === null) { + return; + } + let updates = { comparison: { - threshold: formData.threshold, + threshold, }, server: { - port: formData.port, - timeout: formData.timeout, + port, + timeout, }, build: { name: formData.buildName, @@ -117,13 +169,16 @@ function SettingsForm({ config, sources, onSave, isSaving }) { label="Threshold" type="number" value={formData.threshold} - onChange={e => - handleFieldChange('threshold', parseFloat(e.target.value)) - } + onChange={e => handleFieldChange('threshold', e.target.value)} hint="CIEDE2000 Delta E. 0 = exact, 2 = recommended" step="0.1" min="0" /> + {validationErrors.threshold && ( +

+ {validationErrors.threshold} +

+ )} @@ -140,20 +195,26 @@ function SettingsForm({ config, sources, onSave, isSaving }) { label="Port" type="number" value={formData.port} - onChange={e => - handleFieldChange('port', parseInt(e.target.value, 10)) - } + onChange={e => handleFieldChange('port', e.target.value)} hint="Default: 47392" /> + {validationErrors.port && ( +

+ {validationErrors.port} +

+ )} - handleFieldChange('timeout', parseInt(e.target.value, 10)) - } + onChange={e => handleFieldChange('timeout', e.target.value)} hint="Request timeout in milliseconds" /> + {validationErrors.timeout && ( +

+ {validationErrors.timeout} +

+ )} @@ -217,7 +278,11 @@ function SettingsForm({ config, sources, onSave, isSaving }) {
- {hasChanges ? ( + {hasValidationErrors ? ( + + Fix validation errors before saving + + ) : hasChanges ? ( You have unsaved changes ) : ( Settings saved to project config @@ -235,7 +300,7 @@ function SettingsForm({ config, sources, onSave, isSaving }) { variant="primary" onClick={handleSave} loading={isSaving} - disabled={!hasChanges} + disabled={!hasChanges || hasValidationErrors} > Save Changes diff --git a/src/reporter/src/hooks/queries/batch-mutation-utils.js b/src/reporter/src/hooks/queries/batch-mutation-utils.js new file mode 100644 index 0000000..6f1d69b --- /dev/null +++ b/src/reporter/src/hooks/queries/batch-mutation-utils.js @@ -0,0 +1,138 @@ +function getComparisonIdentifiers(comparison) { + return [comparison?.id, comparison?.signature, comparison?.name].filter( + Boolean + ); +} + +function comparisonMatchesIdSet(comparison, idSet) { + let identifiers = getComparisonIdentifiers(comparison); + return identifiers.some(identifier => idSet.has(identifier)); +} + +function createComparisonLookup(comparisons = []) { + let lookup = new Map(); + + for (let comparison of comparisons) { + let identifiers = getComparisonIdentifiers(comparison); + for (let identifier of identifiers) { + if (!lookup.has(identifier)) { + lookup.set(identifier, comparison); + } + } + } + + return lookup; +} + +function createBatchMutationError(actionVerb, succeededIds, failedIds, errors) { + let total = succeededIds.length + failedIds.length; + let message = + succeededIds.length > 0 + ? `Some baselines failed to ${actionVerb} (${failedIds.length}/${total}).` + : `Failed to ${actionVerb} ${failedIds.length} baseline${failedIds.length === 1 ? '' : 's'}.`; + + let error = new Error(message); + error.name = 'BatchMutationError'; + error.action = actionVerb; + error.succeededIds = succeededIds; + error.failedIds = failedIds; + error.errors = errors; + return error; +} + +export function asIdList(value) { + if (Array.isArray(value)) { + return value.filter(Boolean); + } + + if (value) { + return [value]; + } + + return []; +} + +export function updateComparisonsUserAction(old, ids, userAction) { + if (!old?.comparisons || ids.length === 0) { + return old; + } + + let idSet = new Set(ids); + return { + ...old, + comparisons: old.comparisons.map(comparison => { + let matches = comparisonMatchesIdSet(comparison, idSet); + return matches ? { ...comparison, userAction } : comparison; + }), + }; +} + +export function restoreComparisonsFromPrevious(current, previous, ids) { + if (!current?.comparisons || !previous?.comparisons || ids.length === 0) { + return current; + } + + let idSet = new Set(ids); + let previousLookup = createComparisonLookup(previous.comparisons); + + return { + ...current, + comparisons: current.comparisons.map(comparison => { + if (!comparisonMatchesIdSet(comparison, idSet)) { + return comparison; + } + + let identifiers = getComparisonIdentifiers(comparison); + for (let identifier of identifiers) { + let previousComparison = previousLookup.get(identifier); + if (previousComparison) { + return previousComparison; + } + } + + return comparison; + }), + }; +} + +export async function runBatchMutation(ids, mutationFn, actionVerb) { + let idList = asIdList(ids); + if (idList.length === 0) { + return { + succeededIds: [], + failedIds: [], + errors: [], + }; + } + + let results = await Promise.allSettled(idList.map(id => mutationFn(id))); + let succeededIds = []; + let failedIds = []; + let errors = []; + + for (let index = 0; index < results.length; index++) { + let result = results[index]; + let id = idList[index]; + + if (result.status === 'fulfilled') { + succeededIds.push(id); + continue; + } + + failedIds.push(id); + errors.push({ + id, + error: result.reason, + }); + } + + if (failedIds.length > 0) { + throw createBatchMutationError(actionVerb, succeededIds, failedIds, errors); + } + + return { + succeededIds, + failedIds, + errors, + }; +} diff --git a/src/reporter/src/hooks/queries/use-tdd-queries.js b/src/reporter/src/hooks/queries/use-tdd-queries.js index fcf1f4d..a34e4c9 100644 --- a/src/reporter/src/hooks/queries/use-tdd-queries.js +++ b/src/reporter/src/hooks/queries/use-tdd-queries.js @@ -2,6 +2,31 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { tdd } from '../../api/client.js'; import { queryKeys } from '../../lib/query-keys.js'; import { SSE_STATE, useSSEState } from '../use-sse.js'; +import { + asIdList, + restoreComparisonsFromPrevious, + runBatchMutation, + updateComparisonsUserAction, +} from './batch-mutation-utils.js'; + +function rollbackBatchMutationError(queryClient, context, error) { + if (!context?.previousData) { + return; + } + + let failedIds = asIdList(error?.failedIds); + let succeededIds = asIdList(error?.succeededIds); + let hasPartialSuccess = failedIds.length > 0 && succeededIds.length > 0; + + if (hasPartialSuccess) { + queryClient.setQueryData(queryKeys.reportData(), old => + restoreComparisonsFromPrevious(old, context.previousData, failedIds) + ); + return; + } + + queryClient.setQueryData(queryKeys.reportData(), context.previousData); +} export function useComparison(id, options = {}) { return useQuery({ @@ -33,25 +58,20 @@ export function useReportData(options = {}) { } export function useAcceptBaseline() { - const queryClient = useQueryClient(); + let queryClient = useQueryClient(); return useMutation({ mutationFn: id => tdd.acceptBaseline(id), onMutate: async id => { + let ids = asIdList(id); + // Cancel any outgoing refetches await queryClient.cancelQueries({ queryKey: queryKeys.reportData() }); + // Optimistically update the comparison - const previousData = queryClient.getQueryData(queryKeys.reportData()); - queryClient.setQueryData(queryKeys.reportData(), old => { - if (!old?.comparisons) return old; - return { - ...old, - comparisons: old.comparisons.map(c => - c.id === id || c.signature === id || c.name === id - ? { ...c, userAction: 'accepted' } - : c - ), - }; - }); + let previousData = queryClient.getQueryData(queryKeys.reportData()); + queryClient.setQueryData(queryKeys.reportData(), old => + updateComparisonsUserAction(old, ids, 'accepted') + ); return { previousData }; }, onError: (_err, _id, context) => { @@ -67,7 +87,7 @@ export function useAcceptBaseline() { } export function useAcceptAllBaselines() { - const queryClient = useQueryClient(); + let queryClient = useQueryClient(); return useMutation({ mutationFn: () => tdd.acceptAllBaselines(), onSuccess: () => { @@ -77,7 +97,7 @@ export function useAcceptAllBaselines() { } export function useResetBaselines() { - const queryClient = useQueryClient(); + let queryClient = useQueryClient(); return useMutation({ mutationFn: () => tdd.resetBaselines(), onSuccess: () => { @@ -87,25 +107,20 @@ export function useResetBaselines() { } export function useRejectBaseline() { - const queryClient = useQueryClient(); + let queryClient = useQueryClient(); return useMutation({ mutationFn: id => tdd.rejectBaseline(id), onMutate: async id => { + let ids = asIdList(id); + // Cancel any outgoing refetches await queryClient.cancelQueries({ queryKey: queryKeys.reportData() }); + // Optimistically update the comparison - const previousData = queryClient.getQueryData(queryKeys.reportData()); - queryClient.setQueryData(queryKeys.reportData(), old => { - if (!old?.comparisons) return old; - return { - ...old, - comparisons: old.comparisons.map(c => - c.id === id || c.signature === id || c.name === id - ? { ...c, userAction: 'rejected' } - : c - ), - }; - }); + let previousData = queryClient.getQueryData(queryKeys.reportData()); + queryClient.setQueryData(queryKeys.reportData(), old => + updateComparisonsUserAction(old, ids, 'rejected') + ); return { previousData }; }, onError: (_err, _id, context) => { @@ -120,8 +135,60 @@ export function useRejectBaseline() { }); } +export function useAcceptBaselinesBatch() { + let queryClient = useQueryClient(); + return useMutation({ + mutationFn: ids => + runBatchMutation(ids, id => tdd.acceptBaseline(id), 'accept'), + onMutate: async ids => { + let idList = asIdList(ids); + + await queryClient.cancelQueries({ queryKey: queryKeys.reportData() }); + + let previousData = queryClient.getQueryData(queryKeys.reportData()); + queryClient.setQueryData(queryKeys.reportData(), old => + updateComparisonsUserAction(old, idList, 'accepted') + ); + + return { previousData }; + }, + onError: (error, _ids, context) => { + rollbackBatchMutationError(queryClient, context, error); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.tdd }); + }, + }); +} + +export function useRejectBaselinesBatch() { + let queryClient = useQueryClient(); + return useMutation({ + mutationFn: ids => + runBatchMutation(ids, id => tdd.rejectBaseline(id), 'reject'), + onMutate: async ids => { + let idList = asIdList(ids); + + await queryClient.cancelQueries({ queryKey: queryKeys.reportData() }); + + let previousData = queryClient.getQueryData(queryKeys.reportData()); + queryClient.setQueryData(queryKeys.reportData(), old => + updateComparisonsUserAction(old, idList, 'rejected') + ); + + return { previousData }; + }, + onError: (error, _ids, context) => { + rollbackBatchMutationError(queryClient, context, error); + }, + onSettled: () => { + queryClient.invalidateQueries({ queryKey: queryKeys.tdd }); + }, + }); +} + export function useDeleteComparison() { - const queryClient = useQueryClient(); + let queryClient = useQueryClient(); return useMutation({ mutationFn: id => tdd.deleteComparison(id), onSuccess: () => { diff --git a/src/reporter/vite.config.js b/src/reporter/vite.config.js index 1420827..0c07dba 100644 --- a/src/reporter/vite.config.js +++ b/src/reporter/vite.config.js @@ -3,7 +3,13 @@ import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [react()], + plugins: [ + react({ + babel: { + plugins: ['babel-plugin-react-compiler'], + }, + }), + ], css: { postcss: '../../postcss.config.js', }, diff --git a/src/reporter/vite.dev.config.js b/src/reporter/vite.dev.config.js index 219205f..e3f3912 100644 --- a/src/reporter/vite.dev.config.js +++ b/src/reporter/vite.dev.config.js @@ -2,7 +2,13 @@ import react from '@vitejs/plugin-react'; import { defineConfig } from 'vite'; export default defineConfig({ - plugins: [react()], + plugins: [ + react({ + babel: { + plugins: ['babel-plugin-react-compiler'], + }, + }), + ], root: './src', css: { postcss: '../../postcss.config.js', diff --git a/src/reporter/vite.ssr.config.js b/src/reporter/vite.ssr.config.js index a5d40b9..fb8e878 100644 --- a/src/reporter/vite.ssr.config.js +++ b/src/reporter/vite.ssr.config.js @@ -7,7 +7,13 @@ import { defineConfig } from 'vite'; * Outputs a Node-compatible ES module that can render React to HTML strings. */ export default defineConfig({ - plugins: [react()], + plugins: [ + react({ + babel: { + plugins: ['babel-plugin-react-compiler'], + }, + }), + ], css: { postcss: '../../postcss.config.js', }, diff --git a/tests/reporter/specs/builds-workflow.spec.js b/tests/reporter/specs/builds-workflow.spec.js new file mode 100644 index 0000000..0f851ac --- /dev/null +++ b/tests/reporter/specs/builds-workflow.spec.js @@ -0,0 +1,75 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { expect, test } from '@playwright/test'; +import { createReporterTestServer } from '../test-helper.js'; + +let __filename = fileURLToPath(import.meta.url); +let __dirname = dirname(__filename); +let fixturesDir = join(__dirname, '..', 'fixtures'); + +test.describe('Builds Workflow', () => { + let fixtureData; + let port = 3474; + + test.beforeAll(() => { + fixtureData = JSON.parse( + readFileSync(join(fixturesDir, 'empty-state.json'), 'utf8') + ); + }); + + test('signed-out users do not fetch projects', async ({ page }) => { + let server = createReporterTestServer(fixtureData, port, { + authenticated: false, + }); + await server.start(); + + try { + await page.goto(`http://localhost:${port}/builds`); + await expect( + page.getByRole('heading', { name: /sign in required/i }) + ).toBeVisible(); + + let requestLogResponse = await page.request.get( + `http://localhost:${port}/__test__/requests` + ); + let requestLog = await requestLogResponse.json(); + let projectRequests = requestLog.requests.filter( + request => request.path === '/api/projects' + ); + + expect(projectRequests.length).toBe(0); + } finally { + await server.stop(); + } + }); + + test('signed-in users fetch projects', async ({ page }) => { + let server = createReporterTestServer(fixtureData, port, { + authenticated: true, + }); + await server.start(); + + try { + await page.goto(`http://localhost:${port}/builds`); + await expect( + page.getByRole('heading', { name: /remote builds/i }) + ).toBeVisible(); + await expect( + page.getByRole('heading', { name: /no projects found/i }) + ).toBeVisible(); + + let requestLogResponse = await page.request.get( + `http://localhost:${port}/__test__/requests` + ); + let requestLog = await requestLogResponse.json(); + let projectRequests = requestLog.requests.filter( + request => request.path === '/api/projects' + ); + + expect(projectRequests.length).toBeGreaterThan(0); + } finally { + await server.stop(); + } + }); +}); diff --git a/tests/reporter/specs/settings-workflow.spec.js b/tests/reporter/specs/settings-workflow.spec.js index 439806b..d4fe654 100644 --- a/tests/reporter/specs/settings-workflow.spec.js +++ b/tests/reporter/specs/settings-workflow.spec.js @@ -117,4 +117,25 @@ test.describe('Settings Workflow', () => { // Verify reset button is disabled again await expect(resetButton).toBeDisabled(); }); + + test('save is disabled while numeric fields are invalid', async ({ + page, + }) => { + await page.goto(`http://localhost:${port}/settings`); + + let thresholdInput = page.getByRole('spinbutton').first(); + let saveButton = page.getByRole('button', { name: 'Save Changes' }); + + await thresholdInput.fill(''); + await expect( + page.getByText('Threshold must be a number greater than or equal to 0.') + ).toBeVisible(); + await expect(saveButton).toBeDisabled(); + + await thresholdInput.fill('1.5'); + await expect( + page.getByText('Threshold must be a number greater than or equal to 0.') + ).not.toBeVisible(); + await expect(saveButton).toBeEnabled(); + }); }); diff --git a/tests/reporter/test-helper.js b/tests/reporter/test-helper.js index e4e7356..5a1239e 100644 --- a/tests/reporter/test-helper.js +++ b/tests/reporter/test-helper.js @@ -31,6 +31,7 @@ export function createReporterTestServer( // Track mutations for test verification let mutations = []; + let requests = []; // Mutable copy of fixture data for state changes let currentData = JSON.parse(JSON.stringify(fixtureData)); @@ -55,6 +56,11 @@ export function createReporterTestServer( } const parsedUrl = new URL(req.url, `http://${req.headers.host}`); + requests.push({ + method: req.method, + path: parsedUrl.pathname, + timestamp: Date.now(), + }); // Serve main dashboard for all HTML routes (client-side routing) if ( @@ -63,6 +69,7 @@ export function createReporterTestServer( parsedUrl.pathname === '/dashboard' || parsedUrl.pathname === '/stats' || parsedUrl.pathname === '/settings' || + parsedUrl.pathname === '/builds' || parsedUrl.pathname === '/projects' || parsedUrl.pathname.startsWith('/comparison/')) ) { @@ -237,9 +244,18 @@ export function createReporterTestServer( return; } + // Test helper: get captured request log + if (req.method === 'GET' && parsedUrl.pathname === '/__test__/requests') { + res.setHeader('Content-Type', 'application/json'); + res.statusCode = 200; + res.end(JSON.stringify({ requests })); + return; + } + // Test helper: reset state if (req.method === 'POST' && parsedUrl.pathname === '/__test__/reset') { mutations = []; + requests = []; currentData = JSON.parse(JSON.stringify(fixtureData)); res.setHeader('Content-Type', 'application/json'); res.statusCode = 200; diff --git a/tests/unit/reporter-batch-mutation-utils.test.js b/tests/unit/reporter-batch-mutation-utils.test.js new file mode 100644 index 0000000..5c4c66f --- /dev/null +++ b/tests/unit/reporter-batch-mutation-utils.test.js @@ -0,0 +1,87 @@ +import assert from 'node:assert'; +import { describe, it } from 'node:test'; +import { + asIdList, + restoreComparisonsFromPrevious, + runBatchMutation, + updateComparisonsUserAction, +} from '../../src/reporter/src/hooks/queries/batch-mutation-utils.js'; + +describe('reporter batch mutation utils', () => { + it('normalizes ids into a list', () => { + assert.deepStrictEqual(asIdList('abc'), ['abc']); + assert.deepStrictEqual(asIdList(['a', null, '', 'b']), ['a', 'b']); + assert.deepStrictEqual(asIdList(undefined), []); + }); + + it('applies user action to matching comparisons', () => { + let data = { + comparisons: [ + { id: 'a', name: 'alpha', userAction: null }, + { id: 'b', name: 'beta', userAction: null }, + ], + }; + + let updated = updateComparisonsUserAction(data, ['beta'], 'accepted'); + + assert.strictEqual(updated.comparisons[0].userAction, null); + assert.strictEqual(updated.comparisons[1].userAction, 'accepted'); + }); + + it('restores only failed ids from previous data', () => { + let previous = { + comparisons: [ + { id: 'a', name: 'alpha', userAction: null }, + { id: 'b', name: 'beta', userAction: null }, + ], + }; + let optimistic = { + comparisons: [ + { id: 'a', name: 'alpha', userAction: 'accepted' }, + { id: 'b', name: 'beta', userAction: 'accepted' }, + ], + }; + + let restored = restoreComparisonsFromPrevious(optimistic, previous, ['b']); + + assert.strictEqual(restored.comparisons[0].userAction, 'accepted'); + assert.strictEqual(restored.comparisons[1].userAction, null); + }); + + it('returns success metadata when all mutations succeed', async () => { + let result = await runBatchMutation( + ['a', 'b'], + async id => ({ ok: true, id }), + 'accept' + ); + + assert.deepStrictEqual(result.succeededIds, ['a', 'b']); + assert.deepStrictEqual(result.failedIds, []); + assert.deepStrictEqual(result.errors, []); + }); + + it('throws rich error metadata on partial failure', async () => { + await assert.rejects( + runBatchMutation( + ['a', 'b', 'c'], + async id => { + if (id === 'b') { + throw new Error('boom'); + } + return { ok: true, id }; + }, + 'accept' + ), + error => { + assert.strictEqual(error.name, 'BatchMutationError'); + assert.strictEqual(error.action, 'accept'); + assert.deepStrictEqual(error.succeededIds, ['a', 'c']); + assert.deepStrictEqual(error.failedIds, ['b']); + assert.strictEqual(error.errors.length, 1); + assert.strictEqual(error.errors[0].id, 'b'); + assert.match(error.message, /failed to accept/i); + return true; + } + ); + }); +});