Skip to content
Draft
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
89 changes: 89 additions & 0 deletions apps/common-app/runtime-tests/AutoRunRuntimeTestsApp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import React from 'react';
import {
LogBox,
NativeModules,
Platform,
StyleSheet,
Text,
View,
} from 'react-native';

import AutoRunRuntimeTestsRunner from './ReJest/AutoRunRuntimeTestsRunner';
import { RUNTIME_TEST_SUITES } from './suites';

LogBox.ignoreLogs([
"Deep imports from the 'react-native' package are deprecated",
]);

const DEFAULT_PORT = 8082;

interface SourceCodeConstants {
scriptURL?: string;
}

function deriveWsUrl(): string {
const override =
(globalThis as { __RUNTIME_TESTS_WS_URL__?: string })
.__RUNTIME_TESTS_WS_URL__;
if (override) {
return override;
}

const scriptURL: string | undefined = (
NativeModules.SourceCode?.getConstants?.() as
| SourceCodeConstants
| undefined
)?.scriptURL;

// Simulator fallback — localhost.
let host = 'localhost';
if (scriptURL) {
const match = /^https?:\/\/([^/:]+)(?::\d+)?\//.exec(scriptURL);
if (match) {
host = match[1];
}
} else if (Platform.OS === 'android') {
host = '10.0.2.2';
}

return `ws://${host}:${DEFAULT_PORT}`;
}

export default function AutoRunRuntimeTestsApp() {
const wsUrl = deriveWsUrl();
return (
<View style={styles.container}>
<Text style={styles.title}>Reanimated Runtime Tests</Text>
<Text style={styles.subtitle}>WS server: {wsUrl}</Text>
<View style={styles.runner}>
<AutoRunRuntimeTestsRunner
tests={RUNTIME_TEST_SUITES}
autoRun={{ wsUrl }}
/>
</View>
</View>
);
}

const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: 'white',
paddingTop: 40,
},
title: {
fontSize: 18,
fontWeight: '600',
textAlign: 'center',
color: 'navy',
},
subtitle: {
fontSize: 12,
textAlign: 'center',
color: 'gray',
marginBottom: 8,
},
runner: {
flex: 1,
},
});
168 changes: 168 additions & 0 deletions apps/common-app/runtime-tests/ReJest/AutoRunRuntimeTestsRunner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
import type { ReactNode } from 'react';
import React, { useEffect, useState } from 'react';
import { StyleSheet, Text, View } from 'react-native';

import { runWithRemoteReporter } from './utils/remoteReporter';
import { RenderLock } from './utils/SyncUIRunner';

// IMPORTANT: do not statically import `./RuntimeTestsApi` or anything else that pulls in
// react-native-reanimated. The framework is loaded lazily once the host sends `start`, so
// the app shell never registers reanimated's commit hook.
//
// `RenderLock` and `runWithRemoteReporter` are safe — they depend on
// `react-native-worklets` / `react-native` only, no reanimated.

let renderLock: RenderLock = new RenderLock();

export interface AutoRunConfig {
wsUrl: string;
}

interface SuiteData {
testSuiteName: string;
importTest: () => void;
skipByDefault?: boolean;
disabled?: boolean;
}

interface AutoRunRuntimeTestsRunnerProps {
tests: SuiteData[];
autoRun: AutoRunConfig;
}

interface ProgressState {
current: number;
total: number;
currentName: string;
}

export default function AutoRunRuntimeTestsRunner({
tests,
autoRun,
}: AutoRunRuntimeTestsRunnerProps) {
const [component, setComponent] = useState<ReactNode | null>(null);
const [status, setStatus] = useState<string>(
`Connecting to ${autoRun.wsUrl}…`
);
const [progress, setProgress] = useState<ProgressState | null>(null);

useEffect(() => {
if (renderLock) {
renderLock.unlock();
}
}, [component]);

useEffect(() => {
let cancelled = false;

const declaredSuites = tests.map((test) => ({
name: test.testSuiteName,
skipByDefault: !!test.skipByDefault,
disabled: !!test.disabled,
}));

const teardown = runWithRemoteReporter({
wsUrl: autoRun.wsUrl,
declaredSuites,
onStatus: (message) => {
if (!cancelled) {
setStatus(message);
}
},
onStart: async ({ only }) => {
const filterSet = only ? new Set(only) : null;
const selected = tests.filter((test) => {
if (test.disabled) {
return false;
}
if (filterSet) {
return filterSet.has(test.testSuiteName);
}
return !test.skipByDefault;
});

if (filterSet) {
const known = new Set(tests.map((test) => test.testSuiteName));
const unknown = [...filterSet].filter((name) => !known.has(name));
if (unknown.length > 0) {
throw new Error(`Unknown test suites: ${unknown.join(', ')}`);
}
}

// Lazy-load the framework now. This is the first point reanimated enters the
// JS bundle, by which time the WebSocket is connected and a `start` was received.
const { configure, runTests } = require('./RuntimeTestsApi') as {
configure: (config: {
render: (component: ReactNode) => void;
onProgress?: (p: ProgressState) => void;
}) => RenderLock;
runTests: () => Promise<{
passed: number;
failed: number;
skipped: number;
failedTests: string[];
durationMs: number;
}>;
};

selected.forEach((test) => test.importTest());
renderLock = configure({
render: setComponent,
onProgress: setProgress,
});
return runTests();
},
});

return () => {
cancelled = true;
teardown();
};
}, [autoRun.wsUrl, tests]);

return (
<View style={styles.flexOne}>
<Text style={styles.statusText}>{status}</Text>
{progress ? (
<View style={styles.progressBlock}>
<Text style={styles.progressCount}>
Running test {progress.current} of {progress.total}
</Text>
<Text style={styles.progressName} numberOfLines={2}>
{progress.currentName}
</Text>
</View>
) : null}
{component || null}
</View>
);
}

const styles = StyleSheet.create({
flexOne: {
flex: 1,
flexDirection: 'column',
},
statusText: {
fontSize: 14,
color: 'navy',
alignSelf: 'center',
paddingVertical: 6,
},
progressBlock: {
paddingHorizontal: 16,
paddingBottom: 8,
},
progressCount: {
fontSize: 13,
fontWeight: '600',
color: 'navy',
textAlign: 'center',
},
progressName: {
fontSize: 12,
color: 'gray',
textAlign: 'center',
marginTop: 2,
},
});
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export function getTestComponent(name: string): TestComponent {
}

export async function runTests() {
await testRunner.runTests();
return testRunner.runTests();
}

export async function wait(delay: number) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@ import { FlatList } from 'react-native-gesture-handler';
import { configure, runTests } from './RuntimeTestsApi';
import { RenderLock } from './utils/SyncUIRunner';

export { default as AutoRunRuntimeTestsRunner } from './AutoRunRuntimeTestsRunner';
export type { AutoRunConfig } from './AutoRunRuntimeTestsRunner';

export class ErrorBoundary extends React.Component<
{ children: React.JSX.Element | Array<React.JSX.Element> },
{ hasError: boolean }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type {
ValueWrapper,
TestCase,
TestConfiguration,
TestProgress,
TestSuite,
TestValue,
} from '../types';
Expand Down Expand Up @@ -39,6 +40,9 @@ export class TestRunner {
private _notificationRegistry = new NotificationRegistry();
private _workletRuntimePool = new WorkletRuntimePool();
private _testSuiteBuilder = new TestSuiteBuilder();
private _progressHook: ((progress: TestProgress) => void) | null = null;
private _progressIndex: number = 0;
private _progressTotal: number = 0;

public getWindowDimensionsMocker() {
return this._windowDimensionsMocker;
Expand Down Expand Up @@ -70,6 +74,7 @@ export class TestRunner {

public configure(config: TestConfiguration) {
this._renderHook = config.render;
this._progressHook = config.onProgress ?? null;
return this._renderLock;
}

Expand Down Expand Up @@ -153,10 +158,21 @@ export class TestRunner {
public async runTests() {
console.log('\n');
await this._testSuiteBuilder.buildTests();
this._progressIndex = 0;
this._progressTotal = this._testSuiteBuilder
.getTestSuites()
.reduce(
(sum, suite) =>
suite.skip
? sum
: sum + suite.testCases.filter((testCase) => !testCase.skip).length,
0
);
for (const testSuite of this._testSuiteBuilder.getTestSuites()) {
await this.runTestSuite(testSuite);
}
this._testSummary.printSummary();
return this._testSummary.getSummary();
}

private async runTestSuite(testSuite: TestSuite) {
Expand Down Expand Up @@ -187,16 +203,40 @@ export class TestRunner {
this._callTrackerRegistry.resetRegistry();
this._notificationRegistry.resetRegistry();
this._currentTestCase = testCase;
this._progressIndex += 1;
this._progressHook?.({
current: this._progressIndex,
total: this._progressTotal,
currentName: testCase.name,
});

if (testSuite.beforeEach) {
await testSuite.beforeEach();
try {
if (testSuite.beforeEach) {
await testSuite.beforeEach();
}
await testCase.run();
} catch (error) {
// Convert an uncaught exception from a test body (or its beforeEach)
// into a recorded test failure so the rest of the suite still runs.
const message =
error instanceof Error
? (error.stack ?? error.message)
: String(error);
testCase.errors.push(`[uncaught] ${message}`);
}
await testCase.run();

this._testSummary.showTestCaseSummary(testCase, testSuite.nestingLevel);

if (testSuite.afterEach) {
await testSuite.afterEach();
try {
if (testSuite.afterEach) {
await testSuite.afterEach();
}
} catch (error) {
const message =
error instanceof Error
? (error.stack ?? error.message)
: String(error);
testCase.errors.push(`[uncaught in afterEach] ${message}`);
}

this._currentTestCase = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,16 @@ export class TestSummaryLogger {
}
}

public getSummary() {
return {
passed: this._passed,
failed: this._failed,
skipped: this._skipped,
failedTests: [...this._failedTests],
durationMs: Date.now() - this._startTime,
};
}

public printSummary() {
const endTime = Date.now();
const timeInSeconds = Math.round((endTime - this._startTime) / 1000);
Expand Down
Loading