Test Results
A test run summarized rather than dumped (UC-CODE-02 AC-2: passed/failed/skipped counts and a failing test that expands to its message) — port-at-parity per the PRD verdict, composed from RNR badge, collapsible, and text. Upstream part set at parity (Header/Summary/Duration/Progress/Content + TestSuite/Name/Stats/Content + Test/Status/Name/Duration + TestError/Message/Stack): the passed badge always renders, failed/skipped only when non-zero, suites default COLLAPSED per the KB, per-test durations are ALWAYS milliseconds (upstream never seconds them — two formats, not one smart function), and the status hues compress onto lib/status with distinct icons and count words so color is never the sole channel. TestResultsProgress composes RNR Progress over the pass fraction per the data-schema contract ("no chart"), keeping both text labels byte-for-byte; rows compose the item atom; an empty content falls back to the empty atom.
- v0.2.1
- component
- nativewind
- uniwind
- runs in Expo Go
Install
npx @react-native-reusables/cli@latest add https://rnraielements.com/r/nativewind/test-results.jsonnpx @react-native-reusables/cli@latest add https://rnraielements.com/r/uniwind/test-results.jsonDependencies
react-native-reanimated
https://reactnativereusables.com/r/nativewind/badge.json |
https://reactnativereusables.com/r/nativewind/collapsible.json |
https://reactnativereusables.com/r/nativewind/icon.json |
https://reactnativereusables.com/r/nativewind/progress.json |
https://reactnativereusables.com/r/nativewind/text.json |
https://rnraielements.com/r/nativewind/empty.json |
https://rnraielements.com/r/nativewind/item.json |
https://rnraielements.com/r/nativewind/mono.json |
https://rnraielements.com/r/nativewind/status.json |
https://reactnativereusables.com/r/uniwind/badge.json |
https://reactnativereusables.com/r/uniwind/collapsible.json |
https://reactnativereusables.com/r/uniwind/icon.json |
https://reactnativereusables.com/r/uniwind/progress.json |
https://reactnativereusables.com/r/uniwind/text.json |
https://rnraielements.com/r/uniwind/empty.json |
https://rnraielements.com/r/uniwind/item.json |
https://rnraielements.com/r/uniwind/mono.json |
https://rnraielements.com/r/uniwind/status.json |
AI SDK peers:ai@^7.0.89@ai-sdk/react@^4.0.92
Source
import { Badge } from '@/registry/nativewind/components/ui/badge';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/registry/nativewind/components/ui/collapsible';
import { Empty, EmptyIcon, EmptyTitle } from '@/registry/nativewind/components/ui/empty';
import { Icon } from '@/registry/nativewind/components/ui/icon';
import { Item } from '@/registry/nativewind/components/ui/item';
import { Text } from '@/registry/nativewind/components/ui/text';
import { monoStyle } from '@/registry/nativewind/lib/mono';
import { cn } from '@/registry/nativewind/lib/utils';
import {
ChevronRightIcon,
CircleCheckBigIcon,
CircleDotIcon,
CircleIcon,
CircleXIcon,
FlaskConicalIcon,
type LucideIcon,
} from 'lucide-react-native';
import * as React from 'react';
import { Platform, ScrollView, View, type ViewProps } from 'react-native';
import Animated, {
ReduceMotion,
useAnimatedStyle,
useSharedValue,
withRepeat,
withTiming,
} from 'react-native-reanimated';
import { useReducedMotion } from 'react-native-reanimated';
import {
formatDuration,
formatTestDuration,
passedPercent,
progressLabel,
progressPercentLabel,
testStatusMeta,
type TestStatusIconName,
type TestStatusType,
type TestSummary,
} from './test-results.logic';
/**
* TestResults — a test run summarized rather than dumped (UC-CODE-02 AC-2: passed /
* failed / skipped counts, and a failing test EXPANDS to its message; AC-4: error
* stacks copy from the run surfaces they belong to).
*
* THE PRD VERDICT IS PORT-AT-PARITY: "Pass, fail, and skip counts with expandable
* per-test rows; composes from RNR badge, collapsible, and text." The upstream part
* set, verified from source and ported part for part: TestResults root (summary?),
* TestResultsHeader, TestResultsSummary (passed badge ALWAYS, failed/skipped badges
* only when > 0), TestResultsDuration, TestResultsProgress ("8/10 tests passed" +
* "80%"), TestResultsContent, TestSuite (collapsible card; closed by default — the
* KB's "collapsed until the trigger is clicked"), TestSuiteName, TestSuiteStats
* (counts render only when > 0), TestSuiteContent, Test (row; default composition
* status + name + duration), TestStatus, TestName, TestDuration ("42ms" — upstream
* never seconds a per-test duration), TestError, TestErrorMessage, TestErrorStack.
*
* THE PASS/FAIL BAR (remediation row 3): the original port substituted RNR's Progress
* for the web's two-segment bar (the PRD's "Progress bar via RNR Progress, no chart"
* binding); the approved remediation reverses that substitution — TestResultsProgress
* now renders the web's exact bar structure: an `h-2 overflow-hidden rounded-full
* bg-muted` container with `bg-green-500` / `bg-red-500` percentage-width segments
* (web test-results.tsx:179-185), and BOTH text labels stay byte-for-byte. Color is
* still never the sole channel: the numbers and the failed badge carry the failed
* share too, exactly as the web carried it.
*
* DECLARED ADAPTATIONS, on the record:
* - the web's `divide-y` wrappers become explicit border-b classes on rows (RN has
* no divide utilities); the last row's hairline sits above the section border —
* visually one rule;
* - the running status icon pulses on tool.tsx's exact mechanism (opacity 1→0.5,
* 1000ms, reversed, ReduceMotion.System) in place of the web's `animate-pulse`,
* which has no native compiler target;
* - the red-50 error wash compresses to `bg-destructive/10` — an existing role with
* an opacity modifier, the contract's sanctioned form of a tint;
* - Test rows compose the `item` atom (the house list row) per the build plan;
* - TestResultsContent with no suites falls back to the `empty` atom — the
* inventory's "fallback surface for … test-results", file-tree precedent.
*/
type TestResultsContextValue = { summary?: TestSummary };
const TestResultsContext = React.createContext<TestResultsContextValue | null>(null);
function useTestResults() {
const ctx = React.useContext(TestResultsContext);
if (!ctx) throw new Error('TestResults components must be used within TestResults');
return ctx;
}
export type TestResultsProps = Omit<ViewProps, 'children'> & {
/** The run's counts and duration. The default composition renders nothing without it. */
summary?: TestSummary;
children?: React.ReactNode;
};
function TestResults({ summary, className, children, ...props }: TestResultsProps) {
const contextValue = React.useMemo<TestResultsContextValue>(() => ({ summary }), [summary]);
return (
<TestResultsContext.Provider value={contextValue}>
<View className={cn('overflow-hidden rounded-lg border border-border bg-background', className)} {...props}>
{children ?? (
summary ? (
<TestResultsHeader>
<TestResultsSummary />
<TestResultsDuration />
</TestResultsHeader>
) : null
)}
</View>
</TestResultsContext.Provider>
);
}
function TestResultsHeader({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) {
return (
<View className={cn('flex-row items-center justify-between gap-3 border-b border-border px-4 py-3', className)} {...props}>
{children}
</View>
);
}
/**
* The counts, as badges whose WORD carries the state (the compressed tones ride
* along): passed always renders, failed and skipped only when non-zero — the
* upstream rendering rule, verbatim.
*/
function TestResultsSummary({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) {
const { summary } = useTestResults();
if (!summary) return null;
return (
<View className={cn('flex-row flex-wrap items-center gap-3', className)} {...props}>
{children ?? (
<>
<SummaryBadge status="passed" count={summary.passed} />
{summary.failed > 0 ? <SummaryBadge status="failed" count={summary.failed} /> : null}
{summary.skipped > 0 ? <SummaryBadge status="skipped" count={summary.skipped} /> : null}
</>
)}
</View>
);
}
function SummaryBadge({ status, count }: { status: TestStatusType; count: number }) {
const meta = testStatusMeta(status);
return (
<Badge variant="secondary" className="gap-1" accessibilityLabel={`${count} ${status}`}>
<Icon as={STATUS_ICONS[meta.iconName]} size={12} className={cn(meta.className)} />
<Text className={cn('text-xs', meta.className)}>
{count} {status}
</Text>
</Badge>
);
}
/** The run's wall-clock time, from context. No duration renders nothing. The web
* renders it as plain `text-sm` sans (web test-results.tsx:75) — the port's mono-xs
* treatment was an invention the remediation drops (row 3). */
function TestResultsDuration({ children, className }: { children?: string; className?: string }) {
const { summary } = useTestResults();
if (!summary?.duration) return null;
return (
<Text className={cn('shrink-0 text-sm text-muted-foreground', className)}>
{children ?? formatDuration(summary.duration)}
</Text>
);
}
/**
* The failed share, computed exactly like the shared passedPercent helper — same
* whole-number percent shape, same divide-by-zero clamp (total 0 → 0, never NaN).
* Local to this file: the bar is the only consumer.
*/
function failedPercent(summary: Pick<TestSummary, 'failed' | 'total'>): number {
if (summary.total <= 0) return 0;
return (summary.failed / summary.total) * 100;
}
/**
* The web's two-segment pass/fail bar (remediation row 3) + the two text labels,
* upstream bytes ("8/10 tests passed" / "80%"). One flex-row container, web-exact
* (web test-results.tsx:179): the row layout stretches the segment children to the
* container's full height — nested heightless wrappers collapse to 0 under Yoga.
* Explicit percentage widths — no flex-1 splitting, so a 0% segment is truly
* invisible. Exposed as a progressbar. No summary renders nothing.
*/
function TestResultsProgress({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) {
const { summary } = useTestResults();
if (!summary) return null;
return (
<View className={cn('gap-2', className)} {...props}>
{children ?? (
<>
<View
className="h-2 flex-row overflow-hidden rounded-full bg-muted"
accessibilityRole="progressbar"
accessibilityLabel={progressLabel(summary)}
accessibilityValue={{ min: 0, max: 100, now: passedPercent(summary) }}
>
<View className="bg-green-500" style={{ width: `${passedPercent(summary)}%` }} />
<View className="bg-red-500" style={{ width: `${failedPercent(summary)}%` }} />
</View>
<View className="flex-row items-center justify-between">
<Text className="text-xs text-muted-foreground">{progressLabel(summary)}</Text>
<Text style={monoStyle} className="text-xs text-muted-foreground">
{progressPercentLabel(summary)}
</Text>
</View>
</>
)}
</View>
);
}
/** The suite list. No suites composed → the empty atom, not a blank card. */
function TestResultsContent({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) {
return (
<View className={cn('gap-2 p-4', className)} {...props}>
{children ?? <TestResultsFallbackEmpty />}
</View>
);
}
function TestResultsFallbackEmpty() {
return (
<Empty className="gap-1 p-4">
<EmptyIcon as={FlaskConicalIcon} />
<EmptyTitle className="text-sm font-normal text-muted-foreground">No tests</EmptyTitle>
</Empty>
);
}
type TestSuiteContextValue = { name: string; status: TestStatusType; open: boolean };
const TestSuiteContext = React.createContext<TestSuiteContextValue | null>(null);
function useTestSuite() {
const ctx = React.useContext(TestSuiteContext);
if (!ctx) throw new Error('TestSuite components must be used within TestSuite');
return ctx;
}
export type TestSuiteProps = Omit<ViewProps, 'children'> & {
name: string;
status: TestStatusType;
/** The KB's documented default: collapsed until the trigger is clicked. */
defaultOpen?: boolean;
onOpenChange?: (open: boolean) => void;
children?: React.ReactNode;
};
function TestSuite({ name, status, defaultOpen = false, onOpenChange, className, children, ...props }: TestSuiteProps) {
const [open, setOpen] = React.useState(defaultOpen);
function handleOpenChange(next: boolean) {
setOpen(next);
onOpenChange?.(next);
}
const contextValue = React.useMemo<TestSuiteContextValue>(
() => ({ name, status, open }),
[name, status, open],
);
return (
<TestSuiteContext.Provider value={contextValue}>
<Collapsible open={open} onOpenChange={handleOpenChange}>
<View className={cn('overflow-hidden rounded-lg border border-border', className)} {...props}>
{children}
</View>
</Collapsible>
</TestSuiteContext.Provider>
);
}
/** The suite trigger: right chevron (house rotation), the status mark, the name. */
function TestSuiteName({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) {
const { name, status, open } = useTestSuite();
return (
<CollapsibleTrigger
className={cn(
'flex-row items-center gap-2 px-4 py-3 text-left active:bg-muted/50',
Platform.select({ web: 'transition-colors hover:bg-muted/50' }),
className,
)}
{...props}
>
<SectionChevron open={open} />
{/* Suite-scoped status — NOT <TestStatus /> (Test context; would throw here).
Rendered from the suite context exactly as SummaryBadge/StatText do. */}
{(() => {
const meta = testStatusMeta(status);
return (
<Icon as={STATUS_ICONS[meta.iconName]} size={14} className={cn(meta.className)} />
);
})()}
{children ? (
<>
<Text numberOfLines={1} className="min-w-0 flex-1 text-left text-sm font-medium text-foreground">
{name}
</Text>
{children}
</>
) : (
<Text numberOfLines={1} className="min-w-0 flex-1 text-left text-sm font-medium text-foreground">
{name}
</Text>
)}
</CollapsibleTrigger>
);
}
function SectionChevron({ open }: { open: boolean }) {
const rotation = useSharedValue(open ? 1 : 0);
React.useEffect(() => {
rotation.value = withTiming(open ? 1 : 0, {
duration: open ? 250 : 200,
reduceMotion: ReduceMotion.System,
});
}, [open, rotation]);
const style = useAnimatedStyle(() => ({
transform: [{ rotate: `${rotation.value * 90}deg` }],
}));
return (
<Animated.View style={style}>
<Icon as={ChevronRightIcon} size={16} className="shrink-0 text-muted-foreground" />
</Animated.View>
);
}
type TestSuiteStatsProps = ViewProps & {
passed?: number;
failed?: number;
skipped?: number;
children?: React.ReactNode;
};
/** The per-suite counts; each renders ONLY when non-zero, upstream rule verbatim. */
function TestSuiteStats({ passed = 0, failed = 0, skipped = 0, className, children, ...props }: TestSuiteStatsProps) {
return (
<View className={cn('ml-auto flex-row shrink-0 items-center gap-2', className)} {...props}>
{children ?? (
<>
{passed > 0 ? <StatText count={passed} status="passed" /> : null}
{failed > 0 ? <StatText count={failed} status="failed" /> : null}
{skipped > 0 ? <StatText count={skipped} status="skipped" /> : null}
</>
)}
</View>
);
}
function StatText({ count, status }: { count: number; status: TestStatusType }) {
const meta = testStatusMeta(status);
return (
<Text accessibilityLabel={`${count} ${status}`} className={cn('text-xs', meta.className)}>
{count} {status}
</Text>
);
}
function TestSuiteContent({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) {
return (
<CollapsibleContent className={cn('border-t border-border', className)} {...props}>
<View>{children}</View>
</CollapsibleContent>
);
}
type TestContextValue = { name: string; status: TestStatusType; duration?: number };
const TestContext = React.createContext<TestContextValue | null>(null);
function useTest() {
const ctx = React.useContext(TestContext);
if (!ctx) throw new Error('Test components must be used within Test');
return ctx;
}
export type TestProps = Omit<ViewProps, 'children'> & {
name: string;
status: TestStatusType;
duration?: number;
children?: React.ReactNode;
};
/**
* The test row, composed on the item atom (the house list row). A failing test's
* message/stack compose TestError as a SIBLING block beneath this row inside
* TestSuiteContent — the KB's "nested under TestResults" composition, kept out of the
* row itself so the row stays one tappable-height line.
*/
function Test({ name, status, duration, className, children, ...props }: TestProps) {
const contextValue = React.useMemo<TestContextValue>(
() => ({ name, status, duration }),
[name, status, duration],
);
return (
<TestContext.Provider value={contextValue}>
<Item className={cn('border-b border-border px-4 py-2', className)} {...props}>
{children ?? (
<>
<TestStatus />
<TestName />
{duration !== undefined ? <TestDuration /> : null}
</>
)}
</Item>
</TestContext.Provider>
);
}
/** The status mark. Running pulses (system-reduced); the others stand still. */
function TestStatus({ className }: { className?: string }) {
const { status } = useTest();
const meta = testStatusMeta(status);
const reduced = useReducedMotion();
const looping = status === 'running';
const opacity = useSharedValue(1);
React.useEffect(() => {
if (!looping || reduced) {
opacity.value = 1;
return;
}
opacity.value = withRepeat(
withTiming(0.5, { duration: 1000, reduceMotion: ReduceMotion.System }),
-1,
true,
);
}, [looping, reduced, opacity]);
const style = useAnimatedStyle(() => ({ opacity: opacity.value }));
return (
<Animated.View style={style} className={cn('shrink-0', className)}>
<Icon as={STATUS_ICONS[meta.iconName]} size={16} className={cn(meta.className)} />
</Animated.View>
);
}
function TestName({ children, className }: { children?: string; className?: string }) {
const { name } = useTest();
return (
<Text numberOfLines={1} className={cn('min-w-0 flex-1 text-sm text-foreground', className)}>
{children ?? name}
</Text>
);
}
/** ALWAYS milliseconds — the upstream TestDuration format, never seconds. */
function TestDuration({ children, className }: { children?: string; className?: string }) {
const { duration } = useTest();
if (duration === undefined) return null;
return (
<Text style={monoStyle} className={cn('shrink-0 text-xs text-muted-foreground', className)}>
{children ?? formatTestDuration(duration)}
</Text>
);
}
/** The red-50 wash compressed to the sanctioned bg-destructive/10 token tint. */
function TestError({ className, children, ...props }: ViewProps & { children?: React.ReactNode }) {
return (
<View className={cn('m-4 mt-2 rounded-md bg-destructive/10 p-3', className)} {...props}>
{children}
</View>
);
}
function TestErrorMessage({ children, className }: { children?: string; className?: string }) {
return (
<Text selectable className={cn('text-sm font-medium text-destructive', className)}>
{children}
</Text>
);
}
/**
* The failure stack: house mono (lib/mono), destructive, SELECTABLE, and horizontally
* scrollable — dense output scrolls rather than wraps (UC-CODE-02's constitution).
*/
function TestErrorStack({ children, className }: { children: string; className?: string }) {
return (
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
contentContainerClassName="min-w-full"
className={cn('mt-2', className)}
>
<Text selectable style={monoStyle} className="text-xs text-destructive">
{children}
</Text>
</ScrollView>
);
}
/** icon name → Lucide component, typed exhaustively (tool.logic precedent). */
const STATUS_ICONS: Record<TestStatusIconName, LucideIcon> = {
'circle-check-big': CircleCheckBigIcon,
'circle-x': CircleXIcon,
circle: CircleIcon,
'circle-dot': CircleDotIcon,
};
export {
Test,
TestDuration,
TestError,
TestErrorMessage,
TestErrorStack,
TestName,
TestResults,
TestResultsContent,
TestResultsDuration,
TestResultsHeader,
TestResultsProgress,
TestResultsSummary,
TestStatus,
TestSuite,
TestSuiteContent,
TestSuiteName,
TestSuiteStats,
useTest,
useTestResults,
useTestSuite,
};
/**
* TestResults — pure logic. Zero react-native imports, so the Vitest tier owns it
* (see the header of lib/reasoning-lifecycle.ts for why component files themselves
* cannot load under Node).
*
* THE STATUS COMPRESSION, DECLARED. The web paints the four test outcomes with FOUR
* escape-hatch hues (passed green-600, failed red-600, skipped yellow-600, running
* blue-600). The port maps them onto the house StatusTone vocabulary exactly the way
* terminal.logic's ANSI table does — passed→success, failed→error, skipped→denied
* (yellow compresses onto orange, the sanctioned denied pole), running→running — and
* leans on the rule that carries the whole design: color is never the sole channel
* (WCAG 1.4.1). Every status also renders a DISTINCT icon and the count WORDS
* ("2 failed"), so the compressed map loses no information.
*
* THE TWO DURATION FORMATS ARE UPSTREAM BYTES, NOT ONE SMART FUNCTION:
* - formatDuration (TestResultsDuration): < 1000ms → "500ms", at or above → "3.50s"
* - formatTestDuration (TestDuration): ALWAYS "42ms" — the web never seconds a
* per-test duration, and merging the two would invent behavior upstream lacks.
*
* The progress math guards total = 0 (a suite that has not started): the web renders
* a NaN percent there; the port clamps to 0% and renders "0/0 tests passed" instead —
* declared divergence, on the record.
*/
import { statusColor, type StatusTone } from '@/registry/nativewind/lib/status';
/** The four test outcomes — the data-schema contract's union, verbatim. */
export type TestStatusType = 'passed' | 'failed' | 'skipped' | 'running';
/** Every status, spelled out — exhaustiveness for the meta table. */
export const TEST_STATUS_KEYS = [
'passed',
'failed',
'skipped',
'running',
] as const satisfies readonly TestStatusType[];
/** Lucide icon NAME (kebab-case); the component resolves the component (tool precedent). */
export type TestStatusIconName = 'circle-check-big' | 'circle-x' | 'circle' | 'circle-dot';
export type TestStatusMeta = {
tone: StatusTone;
iconName: TestStatusIconName;
/** Precomposed text class from the shared statusColor map. */
className: string;
};
/** status → tone + icon, one record, exhaustive by type. */
export const TEST_STATUS_META: Record<TestStatusType, TestStatusMeta> = {
passed: { tone: 'success', iconName: 'circle-check-big', className: statusColor.success },
failed: { tone: 'error', iconName: 'circle-x', className: statusColor.error },
skipped: { tone: 'denied', iconName: 'circle', className: statusColor.denied },
running: { tone: 'running', iconName: 'circle-dot', className: statusColor.running },
};
export function testStatusMeta(status: TestStatusType): TestStatusMeta {
return TEST_STATUS_META[status];
}
/** The run summary the root takes — the upstream TestResultsSummary interface. */
export type TestSummary = {
passed: number;
failed: number;
skipped: number;
total: number;
duration?: number;
};
/** TestResultsDuration's format: milliseconds under one second, seconds at/above. */
export function formatDuration(ms: number): string {
if (ms < 1000) {
return `${ms}ms`;
}
return `${(ms / 1000).toFixed(2)}s`;
}
/** TestDuration's format: ALWAYS milliseconds, upstream bytes. */
export function formatTestDuration(ms: number): string {
return `${ms}ms`;
}
/** The pass fraction as a whole-number percent; total 0 clamps to 0 (never NaN). */
export function passedPercent(summary: Pick<TestSummary, 'passed' | 'total'>): number {
if (summary.total <= 0) return 0;
return (summary.passed / summary.total) * 100;
}
/** TestResultsProgress's left label — "8/10 tests passed", upstream bytes. */
export function progressLabel(summary: Pick<TestSummary, 'passed' | 'total'>): string {
return `${summary.passed}/${summary.total} tests passed`;
}
/** TestResultsProgress's right label — the percent, one decimal place dropped. */
export function progressPercentLabel(summary: Pick<TestSummary, 'passed' | 'total'>): string {
return `${passedPercent(summary).toFixed(0)}%`;
}
Props
| Name | Type | Default | Description |
|---|---|---|---|
| summary | TestSummary | undefined | — | The run's counts and duration. The default composition renders nothing without it. |
| children | React.ReactNode | — | — |
Theming
Themed via bg-muted, bg-destructive, border-border, bg-background, text-muted-foreground, text-foreground, text-destructive.
Usage
() => (
<TestResults summary={{ passed: 565, failed: 0, skipped: 0, total: 565, duration: 602 }}>
<TestResultsHeader />
<TestResultsContent>
<TestSuite name="tests/build-registry.test.ts">
<TestSuiteContent>
<Test name="pins every self-referencing URL to the release tag">
<TestName />
<TestDuration />
</Test>
</TestSuiteContent>
</TestSuite>
</TestResultsContent>
</TestResults>
)