Skip to content

Commit a4fb24b

Browse files
authored
chore(tests): resolve duplicate react-native instances in harness tests (#123)
## Summary - Fix Metro config to resolve duplicate react-native instances causing `require()` to fail in harness tests - Extract `withSingleReactNative` helper to `example/metro.helpers.js` for reuse - Simplify test architecture: tests defined in `__tests__/*.harness.ts` with `describe`/`it` - TestsPage uses `require.context` + `getTestCollector().collect()` to run same tests in-app ## Test plan - [x] `yarn typecheck` passes - [x] `yarn test:harness:ios` passes (4 tests) - [x] In-app TestsPage loads and runs tests
1 parent 950598c commit a4fb24b

11 files changed

Lines changed: 143 additions & 196 deletions

File tree

example/__tests__/rive.harness.ts

Lines changed: 55 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,65 @@
1-
import { describe, it, beforeAll } from 'react-native-harness';
1+
import { describe, it, expect } from 'react-native-harness';
22
import { RiveFileFactory } from '@rive-app/react-native';
3-
import type { RiveFile } from '@rive-app/react-native';
4-
import {
5-
testViewModelBasicFunctionality,
6-
testReplaceViewModelSharesState,
7-
} from '../src/testing/suites/viewModelTests';
83

9-
const ASSET_URL =
10-
'http://localhost:8081/assets/assets/rive/viewmodelproperty.riv';
4+
const QUICK_START = require('../assets/rive/quick_start.riv');
5+
const VIEWMODEL = require('../assets/rive/viewmodelproperty.riv');
116

12-
describe('ViewModel', () => {
13-
let file: RiveFile;
7+
describe('RiveFile Loading', () => {
8+
it('fromSource with require() works', async () => {
9+
const file = await RiveFileFactory.fromSource(QUICK_START, undefined);
10+
expect(file).toBeDefined();
11+
expect(file.artboardNames).toContain('health_bar_v01');
12+
});
1413

15-
beforeAll(async () => {
16-
file = await RiveFileFactory.fromURL(ASSET_URL, undefined);
14+
it('fromURL works', async () => {
15+
const file = await RiveFileFactory.fromURL(
16+
'http://localhost:8081/assets/assets/rive/viewmodelproperty.riv',
17+
undefined
18+
);
19+
expect(file).toBeDefined();
20+
expect(file.artboardNames.length).toBeGreaterThan(0);
1721
});
22+
});
23+
24+
describe('ViewModel', () => {
25+
it('viewModel() basic functionality', async () => {
26+
const file = await RiveFileFactory.fromSource(VIEWMODEL, undefined);
27+
const vm = file.defaultArtboardViewModel();
28+
expect(vm).toBeDefined();
29+
30+
const instance = vm?.createDefaultInstance();
31+
expect(instance).toBeDefined();
1832

19-
it('viewModel() basic functionality', () => {
20-
testViewModelBasicFunctionality(file);
33+
const vm1 = instance?.viewModel('vm1');
34+
const vm2 = instance?.viewModel('vm2');
35+
expect(vm1).toBeDefined();
36+
expect(vm2).toBeDefined();
37+
38+
expect(instance?.viewModel('nonexistent')).toBeUndefined();
39+
40+
expect(vm1?.instanceName).toBeDefined();
41+
expect(typeof vm1?.instanceName).toBe('string');
42+
expect(vm1?.stringProperty('name')).toBeDefined();
2143
});
2244

23-
it('replaceViewModel() replaces and shares state', () => {
24-
testReplaceViewModelSharesState(file);
45+
it('replaceViewModel() replaces and shares state', async () => {
46+
const file = await RiveFileFactory.fromSource(VIEWMODEL, undefined);
47+
const vm = file.defaultArtboardViewModel();
48+
const instance = vm?.createDefaultInstance();
49+
expect(instance).toBeDefined();
50+
51+
const vm2Instance = instance?.viewModel('vm2');
52+
expect(vm2Instance).toBeDefined();
53+
54+
const vm2NameProp = vm2Instance?.stringProperty('name');
55+
expect(vm2NameProp).toBeDefined();
56+
const testValue = `test-${Date.now()}`;
57+
vm2NameProp!.value = testValue;
58+
59+
instance?.replaceViewModel('vm1', vm2Instance!);
60+
61+
const vm1AfterReplace = instance?.viewModel('vm1');
62+
const vm1NameProp = vm1AfterReplace?.stringProperty('name');
63+
expect(vm1NameProp?.value).toBe(testValue);
2564
});
2665
});

example/babel.config.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ module.exports = getConfig(
88
{
99
presets: ['module:@react-native/babel-preset'],
1010
plugins: [
11+
'@babel/plugin-transform-class-static-block',
1112
['babel-plugin-react-compiler', {}],
1213
'react-native-reanimated/plugin',
1314
],

example/metro.config.js

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,21 +2,23 @@ const path = require('path');
22
const { getDefaultConfig } = require('@react-native/metro-config');
33
const { getConfig } = require('react-native-builder-bob/metro-config');
44
const { withRnHarness } = require('react-native-harness/metro');
5+
const { withSingleReactNative } = require('./metro.helpers');
56

67
const root = path.resolve(__dirname, '..');
78

89
const config = getDefaultConfig(__dirname);
910
config.resolver.assetExts = [...config.resolver.assetExts, 'riv'];
1011
config.transformer.unstable_allowRequireContext = true;
12+
1113
/**
1214
* Metro configuration
1315
* https://facebook.github.io/metro/docs/configuration
1416
*
1517
* @type {import('metro-config').MetroConfig}
1618
*/
17-
module.exports = withRnHarness(
18-
getConfig(config, {
19-
root,
20-
project: __dirname,
21-
})
22-
);
19+
const finalConfig = getConfig(config, {
20+
root,
21+
project: __dirname,
22+
});
23+
24+
module.exports = withRnHarness(withSingleReactNative(finalConfig, __dirname));

example/metro.helpers.js

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
const path = require('path');
2+
3+
/**
4+
* Forces all react-native imports to resolve to a single instance from the project's node_modules.
5+
* Both the library (root) and example apps have their own react-native in node_modules.
6+
* Without this, Metro may resolve react-native from the library's node_modules, causing duplicate instances.
7+
*
8+
* @param {import('metro-config').MetroConfig} config - Metro configuration
9+
* @param {string} projectDir - Directory containing node_modules with react-native
10+
* @returns {import('metro-config').MetroConfig}
11+
*/
12+
function withSingleReactNative(config, projectDir) {
13+
const rnPath = path.join(projectDir, 'node_modules/react-native/index.js');
14+
const originalResolveRequest = config.resolver.resolveRequest;
15+
const defaultResolve = (context, moduleName, platform) =>
16+
context.resolveRequest(context, moduleName, platform);
17+
const resolveRequest = originalResolveRequest ?? defaultResolve;
18+
19+
return {
20+
...config,
21+
resolver: {
22+
...config.resolver,
23+
resolveRequest: (context, moduleName, platform) => {
24+
if (moduleName === 'react-native') {
25+
return { type: 'sourceFile', filePath: rnPath };
26+
}
27+
return resolveRequest(context, moduleName, platform);
28+
},
29+
},
30+
};
31+
}
32+
33+
module.exports = { withSingleReactNative };

example/src/pages/TestsPage.tsx

Lines changed: 22 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -6,61 +6,47 @@ import {
66
TouchableOpacity,
77
ActivityIndicator,
88
} from 'react-native';
9+
import { getTestCollector } from 'react-native-harness';
10+
import type { TestSuite, TestCase } from '@react-native-harness/bridge';
911
import { useState, useEffect } from 'react';
10-
import { RiveFileFactory } from '@rive-app/react-native';
1112
import type { Metadata } from '../helpers/metadata';
12-
import type { TestCase, TestResult, TestStatus } from '../testing';
13-
import { allSuites } from '../testing/suites';
1413

15-
interface LoadedSuite {
16-
name: string;
17-
tests: TestCase[];
18-
}
14+
const testContext = require.context('../../__tests__', false, /\.harness\.ts$/);
15+
16+
type TestStatus = 'pending' | 'running' | 'passed' | 'failed';
1917

2018
interface TestState {
2119
status: TestStatus;
2220
error?: string;
2321
}
2422

2523
export default function TestsPage() {
24+
const [suites, setSuites] = useState<TestSuite[]>([]);
2625
const [loading, setLoading] = useState(true);
27-
const [loadError, setLoadError] = useState<string | null>(null);
28-
const [suites, setSuites] = useState<LoadedSuite[]>([]);
2926
const [testStates, setTestStates] = useState<Map<string, TestState>>(
3027
new Map()
3128
);
3229
const [runningAll, setRunningAll] = useState(false);
3330

3431
useEffect(() => {
35-
async function loadSuites() {
36-
try {
37-
const loaded: LoadedSuite[] = [];
32+
async function collectTests() {
33+
const collector = getTestCollector();
34+
const result = await collector.collect(() => {
35+
testContext.keys().forEach((key) => testContext(key));
36+
}, 'harness-tests');
3837

39-
for (const suite of allSuites) {
40-
const file = await RiveFileFactory.fromSource(
41-
suite.riveAsset,
42-
undefined
43-
);
44-
const tests = suite.getTests(file);
45-
loaded.push({ name: suite.name, tests });
38+
setSuites(result.testSuite.suites);
4639

47-
const initialStates = new Map<string, TestState>();
48-
for (const test of tests) {
49-
initialStates.set(getTestKey(suite.name, test.name), {
50-
status: 'pending',
51-
});
52-
}
53-
setTestStates((prev) => new Map([...prev, ...initialStates]));
40+
const states = new Map<string, TestState>();
41+
for (const suite of result.testSuite.suites) {
42+
for (const test of suite.tests) {
43+
states.set(`${suite.name}::${test.name}`, { status: 'pending' });
5444
}
55-
56-
setSuites(loaded);
57-
setLoading(false);
58-
} catch (e) {
59-
setLoadError(e instanceof Error ? e.message : String(e));
60-
setLoading(false);
6145
}
46+
setTestStates(states);
47+
setLoading(false);
6248
}
63-
loadSuites();
49+
collectTests();
6450
}, []);
6551

6652
function getTestKey(suiteName: string, testName: string): string {
@@ -72,13 +58,8 @@ export default function TestsPage() {
7258
setTestStates((prev) => new Map(prev).set(key, { status: 'running' }));
7359

7460
try {
75-
const result: TestResult = await test.run();
76-
setTestStates((prev) =>
77-
new Map(prev).set(key, {
78-
status: result.status,
79-
error: result.error,
80-
})
81-
);
61+
await test.fn();
62+
setTestStates((prev) => new Map(prev).set(key, { status: 'passed' }));
8263
} catch (e) {
8364
setTestStates((prev) =>
8465
new Map(prev).set(key, {
@@ -131,16 +112,7 @@ export default function TestsPage() {
131112
return (
132113
<View style={styles.centered}>
133114
<ActivityIndicator size="large" color="#007AFF" />
134-
<Text style={styles.loadingText}>Loading test suites...</Text>
135-
</View>
136-
);
137-
}
138-
139-
if (loadError) {
140-
return (
141-
<View style={styles.centered}>
142-
<Text style={styles.errorText}>Failed to load tests:</Text>
143-
<Text style={styles.errorDetail}>{loadError}</Text>
115+
<Text style={styles.loadingText}>Collecting tests...</Text>
144116
</View>
145117
);
146118
}
@@ -224,24 +196,12 @@ const styles = StyleSheet.create({
224196
flex: 1,
225197
justifyContent: 'center',
226198
alignItems: 'center',
227-
padding: 20,
228199
},
229200
loadingText: {
230201
marginTop: 10,
231202
fontSize: 16,
232203
color: '#666',
233204
},
234-
errorText: {
235-
fontSize: 18,
236-
fontWeight: 'bold',
237-
color: '#FF3B30',
238-
marginBottom: 8,
239-
},
240-
errorDetail: {
241-
fontSize: 14,
242-
color: '#666',
243-
textAlign: 'center',
244-
},
245205
header: {
246206
padding: 16,
247207
borderBottomWidth: 1,

example/src/testing/index.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

example/src/testing/suites/index.ts

Lines changed: 0 additions & 4 deletions
This file was deleted.

example/src/testing/suites/viewModelTests.ts

Lines changed: 0 additions & 69 deletions
This file was deleted.

example/src/testing/types.ts

Lines changed: 0 additions & 19 deletions
This file was deleted.

0 commit comments

Comments
 (0)