diff --git a/examples/0.84/App.tsx b/examples/0.84/App.tsx index 59e3ecb7..737eb86c 100644 --- a/examples/0.84/App.tsx +++ b/examples/0.84/App.tsx @@ -1,109 +1,9 @@ -import { Button } from '@react-navigation/elements'; -import { - NavigationContainer, - NavigationContainerRef, - ParamListBase, - useNavigation, -} from '@react-navigation/native'; -import { createNativeStackNavigator } from '@react-navigation/native-stack'; -import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin'; -import { useRef } from 'react'; -import { View, Text, StyleSheet, Dimensions } from 'react-native'; -import Animated, { type CSSAnimationKeyframes } from 'react-native-reanimated'; - -import Logo from './logo.svg'; - -const windowWidth = Dimensions.get('window').width; -const logoSize = windowWidth * 0.4; - -const breathe: CSSAnimationKeyframes = { - to: { - transform: [{ scale: 1.1 }], - }, -}; - -function HomeScreen() { - const navigation = useNavigation(); - - return ( - - - - - - Rollipop - {import.meta.env.ROLLIPOP_DESCRIPTION} - - - - - - ); -} - -function GetStarted() { - return ( - - - Hello, world! - - - ); -} - -const RootStack = createNativeStackNavigator(); +import { AppNavigator } from './src/navigation/AppNavigator'; export function App() { - const navigationRef = useRef>(null!); - - useReactNavigationDevTools({ ref: navigationRef }); - - return ( - - - - - - - ); + return ; } -const styles = StyleSheet.create({ - container: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - backgroundColor: 'white', - gap: 12, - paddingBottom: 36, - }, - descriptionContainer: { - alignItems: 'center', - justifyContent: 'center', - gap: 8, - }, - buttonContainer: { - paddingVertical: 16, - }, - title: { - fontSize: 24, - fontWeight: 'bold', - color: '#222', - }, - description: { - fontSize: 16, - color: '#666', - }, -}); - if (import.meta.hot) { import.meta.hot.on('custom-server-event', (data) => { console.log('Received custom server event:', data); diff --git a/examples/0.84/__tests__/App.spec.tsx b/examples/0.84/__tests__/App.spec.tsx index ef47bea4..846837d0 100644 --- a/examples/0.84/__tests__/App.spec.tsx +++ b/examples/0.84/__tests__/App.spec.tsx @@ -15,7 +15,9 @@ import { App } from '../App'; describe('rollipop/jest-preset — RN integration', () => { it('renders correctly', async () => { const instance = render(); + expect(instance.getByText('Rollipop')).toBeTruthy(); expect(instance.getByText('Modern build toolkit for React Native')).toBeTruthy(); + expect(instance.getByText('Get Started')).toBeTruthy(); }); it('exposes Platform.OS and Platform.constants via jest-preset NativeModules', () => { diff --git a/examples/0.84/__tests__/navigation.spec.tsx b/examples/0.84/__tests__/navigation.spec.tsx index b5bedfbf..9cf0d214 100644 --- a/examples/0.84/__tests__/navigation.spec.tsx +++ b/examples/0.84/__tests__/navigation.spec.tsx @@ -2,8 +2,9 @@ import { fireEvent, render, screen } from '@testing-library/react-native'; import React from 'react'; import { App } from '../App'; +import { runtimeChecks } from '../src/utils/runtimeChecks'; -test('pressing "Get Started" navigates to the GetStarted screen', async () => { +test('pressing "Get Started" navigates to the details screen', async () => { render(); // Home screen is shown initially with the "Get Started" button. @@ -11,6 +12,22 @@ test('pressing "Get Started" navigates to the GetStarted screen', async () => { fireEvent.press(screen.getByText('Get Started')); - // After navigation, the GetStarted screen renders its title. - expect(await screen.findByText('Hello, world!')).toBeTruthy(); + // After navigation, the details screen renders its title. + expect(await screen.findByText('Test cases')).toBeTruthy(); +}); + +test('running the test suites reports all runtime checks as passed', async () => { + render(); + + fireEvent.press(screen.getByText('Get Started')); + expect(await screen.findByText('Test cases')).toBeTruthy(); + + fireEvent.press(screen.getByText('Open Test Suites')); + expect(await screen.findByText('Runtime checks')).toBeTruthy(); + + fireEvent.press(screen.getByText('Run all checks')); + + expect( + await screen.findByText(`${runtimeChecks.length}/${runtimeChecks.length} checks passed`), + ).toBeTruthy(); }); diff --git a/examples/0.84/src/components/AppButton.tsx b/examples/0.84/src/components/AppButton.tsx new file mode 100644 index 00000000..b1e2ce4d --- /dev/null +++ b/examples/0.84/src/components/AppButton.tsx @@ -0,0 +1,79 @@ +import type { GestureResponderEvent } from 'react-native'; +import { Pressable, StyleSheet, Text } from 'react-native'; + +import { colors, spacing } from '../theme'; + +type ButtonVariant = 'primary' | 'secondary' | 'ghost'; + +type AppButtonProps = { + label: string; + onPress: (event: GestureResponderEvent) => void; + disabled?: boolean; + testID?: string; + variant?: ButtonVariant; +}; + +export function AppButton({ + label, + onPress, + disabled = false, + testID, + variant = 'primary', +}: AppButtonProps) { + return ( + [ + styles.button, + styles[variant], + pressed && !disabled && styles.pressed, + disabled && styles.disabled, + ]} + testID={testID} + > + {label} + + ); +} + +const styles = StyleSheet.create({ + button: { + minHeight: 56, + borderRadius: 999, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: spacing.xl, + paddingVertical: spacing.lg, + }, + disabled: { + opacity: 0.48, + }, + ghost: { + backgroundColor: 'transparent', + }, + ghostLabel: { + color: colors.muted, + }, + label: { + fontSize: 16, + fontWeight: '700', + }, + pressed: { + opacity: 0.82, + }, + primary: { + backgroundColor: colors.primary, + }, + primaryLabel: { + color: colors.surface, + }, + secondary: { + backgroundColor: colors.primarySoft, + borderColor: colors.border, + }, + secondaryLabel: { + color: colors.primaryDark, + }, +}); diff --git a/examples/0.84/src/components/Screen.tsx b/examples/0.84/src/components/Screen.tsx new file mode 100644 index 00000000..9035c1cd --- /dev/null +++ b/examples/0.84/src/components/Screen.tsx @@ -0,0 +1,57 @@ +import type { PropsWithChildren, ReactNode } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; +import { ScrollView, StyleSheet, View } from 'react-native'; + +import { colors, spacing } from '../theme'; + +type ScreenProps = PropsWithChildren<{ + contentContainerStyle?: StyleProp; + footer?: ReactNode; + scrollEnabled?: boolean; + testID?: string; +}>; + +export function Screen({ + children, + contentContainerStyle, + footer, + scrollEnabled = true, + testID, +}: ScreenProps) { + return ( + + + {children} + + {footer ? {footer} : null} + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: colors.background, + }, + content: { + flexGrow: 1, + gap: spacing.xl, + paddingHorizontal: spacing.xl, + paddingTop: spacing.xl, + paddingBottom: spacing.xxl, + }, + footer: { + backgroundColor: colors.background, + paddingHorizontal: spacing.xl, + paddingTop: spacing.md, + paddingBottom: spacing.xl, + }, + scroll: { + flex: 1, + }, +}); diff --git a/examples/0.84/src/components/Section.tsx b/examples/0.84/src/components/Section.tsx new file mode 100644 index 00000000..62fd73a0 --- /dev/null +++ b/examples/0.84/src/components/Section.tsx @@ -0,0 +1,55 @@ +import type { PropsWithChildren, ReactNode } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { colors, spacing } from '../theme'; + +type SectionProps = PropsWithChildren<{ + action?: ReactNode; + eyebrow?: string; + title: string; +}>; + +export function Section({ action, children, eyebrow, title }: SectionProps) { + return ( + + + + {eyebrow ? {eyebrow} : null} + {title} + + {action} + + {children} + + ); +} + +const styles = StyleSheet.create({ + body: { + gap: spacing.md, + }, + eyebrow: { + color: colors.primaryDark, + fontSize: 12, + fontWeight: '700', + textTransform: 'uppercase', + }, + header: { + alignItems: 'flex-start', + flexDirection: 'row', + gap: spacing.md, + justifyContent: 'space-between', + }, + section: { + gap: spacing.md, + }, + title: { + color: colors.ink, + fontSize: 20, + fontWeight: '800', + }, + titleGroup: { + flex: 1, + gap: spacing.xs, + }, +}); diff --git a/examples/0.84/src/components/StatusPill.tsx b/examples/0.84/src/components/StatusPill.tsx new file mode 100644 index 00000000..4dbdac00 --- /dev/null +++ b/examples/0.84/src/components/StatusPill.tsx @@ -0,0 +1,62 @@ +import { StyleSheet, Text, View } from 'react-native'; + +import { colors, radius, spacing } from '../theme'; + +export type CheckStatus = 'failed' | 'idle' | 'passed' | 'running'; + +type StatusPillProps = { + status: CheckStatus; +}; + +const labels: Record = { + failed: 'Fail', + idle: 'Idle', + passed: 'Pass', + running: 'Run', +}; + +export function StatusPill({ status }: StatusPillProps) { + return ( + + {labels[status]} + + ); +} + +const styles = StyleSheet.create({ + failed: { + backgroundColor: '#FEF3F2', + }, + failedLabel: { + color: colors.red, + }, + idle: { + backgroundColor: colors.surfaceAlt, + }, + idleLabel: { + color: colors.primaryDark, + }, + label: { + fontSize: 12, + fontWeight: '800', + }, + passed: { + backgroundColor: '#ECFDF3', + }, + passedLabel: { + color: colors.green, + }, + pill: { + alignItems: 'center', + borderRadius: radius.sm, + minWidth: 54, + paddingHorizontal: spacing.sm, + paddingVertical: spacing.xs, + }, + running: { + backgroundColor: '#FFFAEB', + }, + runningLabel: { + color: colors.amber, + }, +}); diff --git a/examples/0.84/src/navigation/AppNavigator.tsx b/examples/0.84/src/navigation/AppNavigator.tsx new file mode 100644 index 00000000..927a3f7f --- /dev/null +++ b/examples/0.84/src/navigation/AppNavigator.tsx @@ -0,0 +1,30 @@ +import { NavigationContainer, type NavigationContainerRef } from '@react-navigation/native'; +import { createNativeStackNavigator } from '@react-navigation/native-stack'; +import { useReactNavigationDevTools } from '@rozenite/react-navigation-plugin'; +import { useRef } from 'react'; + +import { DetailsScreen } from '../screens/DetailsScreen'; +import { HomeScreen } from '../screens/HomeScreen'; +import type { RootStackParamList } from './types'; + +const RootStack = createNativeStackNavigator(); + +export function AppNavigator() { + const navigationRef = useRef>(null!); + + useReactNavigationDevTools({ ref: navigationRef }); + + return ( + + + + + + + ); +} diff --git a/examples/0.84/src/navigation/types.ts b/examples/0.84/src/navigation/types.ts new file mode 100644 index 00000000..735ad453 --- /dev/null +++ b/examples/0.84/src/navigation/types.ts @@ -0,0 +1,5 @@ +export type RootStackParamList = { + home: undefined; + details: undefined; + test_suites: undefined; +}; diff --git a/examples/0.84/src/screens/DetailsScreen.tsx b/examples/0.84/src/screens/DetailsScreen.tsx new file mode 100644 index 00000000..79a83ba6 --- /dev/null +++ b/examples/0.84/src/screens/DetailsScreen.tsx @@ -0,0 +1,174 @@ +import { useCallback, useMemo, useState } from 'react'; +import { StyleSheet, Text, View } from 'react-native'; + +import { AppButton } from '../components/AppButton'; +import { Screen } from '../components/Screen'; +import { Section } from '../components/Section'; +import { type CheckStatus, StatusPill } from '../components/StatusPill'; +import { colors, radius, spacing } from '../theme'; +import { + runRuntimeChecks, + runtimeChecks, + summarizeReports, + type RuntimeCheckDefinition, + type RuntimeCheckReport, +} from '../utils/runtimeChecks'; + +type RuntimeCheckRow = RuntimeCheckDefinition & { + durationMs?: number; + message?: string; + status: CheckStatus; +}; + +function toIdleRows(): RuntimeCheckRow[] { + return runtimeChecks.map((check) => ({ + ...check, + status: 'idle', + })); +} + +function toReportRow(report: RuntimeCheckReport): RuntimeCheckRow { + return { + ...report, + status: report.status, + }; +} + +export function DetailsScreen() { + const [rows, setRows] = useState(toIdleRows); + const [running, setRunning] = useState(false); + + const summary = useMemo(() => { + const reports = rows.filter((row): row is RuntimeCheckReport => { + return row.status === 'passed' || row.status === 'failed'; + }); + + return summarizeReports(reports); + }, [rows]); + + const handleRunAll = useCallback(async () => { + setRunning(true); + setRows((currentRows) => + currentRows.map((row) => ({ + ...row, + message: undefined, + status: 'running', + })), + ); + + const reports = await runRuntimeChecks(); + + setRows(reports.map(toReportRow)); + setRunning(false); + }, []); + + const summaryText = + summary.total === 0 + ? `${runtimeChecks.length} checks ready` + : `${summary.passed}/${summary.total} checks passed`; + + return ( + + } + testID="test-suites-screen" + > + + Test suites + Runtime checks + {summaryText} + + +
+ {rows.map((row) => ( + + + {row.group} + {row.title} + {row.expectation} + {row.message ? {row.message} : null} + + + + {typeof row.durationMs === 'number' ? ( + {row.durationMs}ms + ) : null} + + + ))} +
+
+ ); +} + +const styles = StyleSheet.create({ + checkCopy: { + flex: 1, + gap: spacing.xs, + }, + checkExpectation: { + color: colors.muted, + fontSize: 13, + lineHeight: 18, + }, + checkGroup: { + color: colors.subtle, + fontSize: 11, + fontWeight: '700', + textTransform: 'uppercase', + }, + checkMessage: { + color: colors.ink, + fontSize: 13, + fontWeight: '700', + }, + checkRow: { + alignItems: 'flex-start', + backgroundColor: colors.surface, + borderColor: colors.border, + borderRadius: radius.md, + borderWidth: StyleSheet.hairlineWidth, + flexDirection: 'row', + gap: spacing.md, + padding: spacing.lg, + }, + checkStatus: { + alignItems: 'flex-end', + gap: spacing.xs, + }, + checkTitle: { + color: colors.ink, + fontSize: 16, + fontWeight: '700', + }, + description: { + color: colors.muted, + fontSize: 16, + lineHeight: 23, + }, + duration: { + color: colors.subtle, + fontSize: 11, + fontWeight: '700', + }, + header: { + gap: spacing.sm, + }, + kicker: { + color: colors.primaryDark, + fontSize: 12, + fontWeight: '800', + textTransform: 'uppercase', + }, + title: { + color: colors.ink, + fontSize: 32, + fontWeight: '800', + }, +}); diff --git a/examples/0.84/src/screens/HomeScreen.tsx b/examples/0.84/src/screens/HomeScreen.tsx new file mode 100644 index 00000000..9ea5ad4b --- /dev/null +++ b/examples/0.84/src/screens/HomeScreen.tsx @@ -0,0 +1,83 @@ +import { useNavigation } from '@react-navigation/native'; +import type { NativeStackNavigationProp } from '@react-navigation/native-stack'; +import { StyleSheet, Text, useWindowDimensions, View } from 'react-native'; +import Animated, { type CSSAnimationKeyframes } from 'react-native-reanimated'; + +import Logo from '../../logo.svg'; +import { AppButton } from '../components/AppButton'; +import { Screen } from '../components/Screen'; +import type { RootStackParamList } from '../navigation/types'; +import { colors, spacing } from '../theme'; +import { appDescription } from '../utils/env'; + +const breathe: CSSAnimationKeyframes = { + to: { + transform: [{ scale: 1.06 }], + }, +}; + +type HomeNavigation = NativeStackNavigationProp; + +export function HomeScreen() { + const navigation = useNavigation(); + const { width } = useWindowDimensions(); + const logoSize = Math.min(180, Math.max(124, width * 0.42)); + + return ( + navigation.navigate('details')} + testID="get-started-button" + /> + } + scrollEnabled={false} + testID="home-screen" + > + + + + + + Rollipop + {appDescription} + + + + ); +} + +const styles = StyleSheet.create({ + content: { + justifyContent: 'center', + }, + description: { + color: colors.muted, + fontSize: 16, + lineHeight: 22, + textAlign: 'center', + }, + hero: { + alignItems: 'center', + gap: spacing.lg, + }, + heroCopy: { + alignItems: 'center', + gap: spacing.sm, + }, + title: { + color: colors.primary, + fontSize: 40, + fontWeight: '700', + }, +}); diff --git a/examples/0.84/src/theme.ts b/examples/0.84/src/theme.ts new file mode 100644 index 00000000..c4190ccd --- /dev/null +++ b/examples/0.84/src/theme.ts @@ -0,0 +1,30 @@ +export const colors = { + background: '#FFFFFF', + surface: '#FFFFFF', + surfaceAlt: '#F1F8FE', + ink: '#111827', + muted: '#5F6673', + subtle: '#8A94A6', + border: '#E6EAF0', + primary: '#42A5F5', + primaryDark: '#1E88E5', + primarySoft: '#EAF5FE', + amber: '#B7791F', + green: '#15803D', + red: '#B42318', + violet: '#7C3AED', +} as const; + +export const spacing = { + xs: 4, + sm: 8, + md: 12, + lg: 16, + xl: 24, + xxl: 32, +} as const; + +export const radius = { + sm: 6, + md: 8, +} as const; diff --git a/examples/0.84/src/utils/env.ts b/examples/0.84/src/utils/env.ts new file mode 100644 index 00000000..8d29b6ac --- /dev/null +++ b/examples/0.84/src/utils/env.ts @@ -0,0 +1,3 @@ +const fallbackDescription = 'Modern build toolkit for React Native'; + +export const appDescription = import.meta.env.ROLLIPOP_DESCRIPTION?.trim() || fallbackDescription; diff --git a/examples/0.84/src/utils/runtimeChecks.ts b/examples/0.84/src/utils/runtimeChecks.ts new file mode 100644 index 00000000..a5466d5c --- /dev/null +++ b/examples/0.84/src/utils/runtimeChecks.ts @@ -0,0 +1,170 @@ +import { AppState, Dimensions, Platform, StyleSheet } from 'react-native'; + +import Logo from '../../logo.svg'; +import { appDescription } from './env'; + +export type RuntimeCheckGroup = 'Bundler transforms' | 'React Native runtime'; + +export type RuntimeCheckResult = { + message: string; + passed: boolean; +}; + +export type RuntimeCheckDefinition = { + expectation: string; + group: RuntimeCheckGroup; + id: string; + run: () => Promise | RuntimeCheckResult; + title: string; +}; + +export type RuntimeCheckReport = RuntimeCheckDefinition & + RuntimeCheckResult & { + durationMs: number; + status: 'failed' | 'passed'; + }; + +function result(passed: boolean, message: string): RuntimeCheckResult { + return { message, passed }; +} + +export const runtimeChecks: RuntimeCheckDefinition[] = [ + { + expectation: 'ROLLIPOP_DESCRIPTION is available through import.meta.env.', + group: 'Bundler transforms', + id: 'env-description', + run: () => { + const description = appDescription.trim(); + return result(description.length > 0, `Loaded "${description || 'empty'}"`); + }, + title: 'Env replacement', + }, + { + expectation: 'The SVG plugin turns logo.svg into a renderable module.', + group: 'Bundler transforms', + id: 'svg-transform', + run: () => { + const moduleType = typeof Logo; + return result( + moduleType === 'function' || moduleType === 'object' || moduleType === 'string', + `logo.svg resolved as ${moduleType}`, + ); + }, + title: 'SVG import', + }, + { + expectation: 'Promise boundaries resolve in the bundled runtime.', + group: 'Bundler transforms', + id: 'async-boundary', + run: async () => { + const value = await Promise.resolve(['roll', 'i', 'pop'].join('')); + return result(value === 'rollipop', `Resolved async value "${value}"`); + }, + title: 'Async execution', + }, + { + expectation: 'The dev runtime exposes import.meta.hot when HMR is enabled.', + group: 'Bundler transforms', + id: 'hmr-runtime', + run: () => { + const hot = import.meta.hot; + return result( + hot == null || typeof hot === 'object', + hot ? 'import.meta.hot is available' : 'HMR is disabled for this runtime', + ); + }, + title: 'HMR boundary', + }, + { + expectation: 'Platform.select returns a native platform branch.', + group: 'React Native runtime', + id: 'platform-select', + run: () => { + const branch = Platform.select({ + android: 'android', + default: 'default', + ios: 'ios', + }); + return result(typeof branch === 'string', `Selected ${branch}`); + }, + title: 'Platform selection', + }, + { + expectation: 'Dimensions reports a non-zero window size.', + group: 'React Native runtime', + id: 'dimensions', + run: () => { + const window = Dimensions.get('window'); + return result( + window.width > 0 && window.height > 0, + `${Math.round(window.width)} x ${Math.round(window.height)}`, + ); + }, + title: 'Device dimensions', + }, + { + expectation: 'StyleSheet.flatten preserves the latest style values.', + group: 'React Native runtime', + id: 'stylesheet-flatten', + run: () => { + const flattened = StyleSheet.flatten([{ padding: 4 }, { opacity: 0.8, padding: 12 }]); + return result( + flattened?.padding === 12 && flattened.opacity === 0.8, + `padding=${flattened?.padding}, opacity=${flattened?.opacity}`, + ); + }, + title: 'StyleSheet flatten', + }, + { + expectation: 'AppState subscriptions return a removable listener handle.', + group: 'React Native runtime', + id: 'app-state', + run: () => { + const subscription = AppState.addEventListener('change', () => {}); + const removable = typeof subscription.remove === 'function'; + subscription.remove(); + return result(removable, removable ? 'remove() is available' : 'remove() missing'); + }, + title: 'AppState listener', + }, +]; + +export async function runRuntimeChecks( + definitions: RuntimeCheckDefinition[] = runtimeChecks, +): Promise { + const reports: RuntimeCheckReport[] = []; + + for (const definition of definitions) { + const startedAt = Date.now(); + + try { + const checkResult = await definition.run(); + reports.push({ + ...definition, + ...checkResult, + durationMs: Date.now() - startedAt, + status: checkResult.passed ? 'passed' : 'failed', + }); + } catch (error) { + reports.push({ + ...definition, + durationMs: Date.now() - startedAt, + message: error instanceof Error ? error.message : 'Unknown error', + passed: false, + status: 'failed', + }); + } + } + + return reports; +} + +export function summarizeReports(reports: RuntimeCheckReport[]) { + const passed = reports.filter((report) => report.status === 'passed').length; + + return { + failed: reports.length - passed, + passed, + total: reports.length, + }; +} diff --git a/examples/0.84/tsconfig.json b/examples/0.84/tsconfig.json index 0f66443b..e3811c47 100644 --- a/examples/0.84/tsconfig.json +++ b/examples/0.84/tsconfig.json @@ -3,6 +3,13 @@ "compilerOptions": { "types": ["jest", "rollipop/client"] }, - "include": ["**/*.ts", "**/*.tsx"], + "include": [ + "*.ts", + "*.tsx", + "__tests__/**/*.ts", + "__tests__/**/*.tsx", + "src/**/*.ts", + "src/**/*.tsx" + ], "exclude": ["**/node_modules"] }