feat: e2e snapshot tests (#2338)

<!-- Thanks for submitting a pull request! We appreciate you spending
the time to work on these changes. Please follow the template so that
the reviewers can easily understand what the code changes affect -->

# Summary

This PR adds E2E tests based on view screenshots done via
`react-native-view-shot`. It only works with devices that have their
[pixel ratio](https://reactnative.dev/docs/pixelratio) equal `3`. If you
want to use device with different pixel ratio, you need to adjust it in
`e2e/generateReferences.ts` viewport and regenerate reference images
(see below).

Steps to run tests:
- Run Metro server for example app via `yarn start` in example app's
directory
- Run `example` app on platform of your choice (currently only Android &
iOS are supported) via `yarn android` or `yarn ios` in example app's
directory
- Run `yarn e2e` in project's root directory to start Jest server
- Select `E2E` tab in example app
- Wait for tests to finish
- You can see test results, as well as diffs (actual rendered svg vs
reference image) in `e2e/diffs` directory

Steps to add new test cases:
- Put SVG of your choice to `e2e/cases` directory
- Run `yarn generateE2eRefrences`, this will open headless chrome
browser via `puppeteer` and snapshot all rendered SVGs to .png files and
later use them as reference in tests
- You should see new .png files in `e2e/references`
- When you run E2E tests again, it will use new test case(s) you've
added

## Test Plan


https://github.com/software-mansion/react-native-svg/assets/41289688/24ee5447-ce9a-43b6-9dde-76229d25a30a


https://github.com/software-mansion/react-native-svg/assets/41289688/71d1873f-8155-4494-80bd-e4c1fa72a065


### What's required for testing (prerequisites)?
See Summary

### What are the steps to reproduce (after prerequisites)?
See Summary

## Compatibility

| OS      | Implemented |
| ------- | :---------: |
| iOS     |    	     |
| Android |         |
| Web | 			 |
## Checklist



<!-- Check completed item, when applicable, via: [X] -->

- [X] I have tested this on a device and a simulator
- [x] I added documentation in `README.md`
- [X] I updated the typed files (typescript)
- [X] I added a test for the API in the `__tests__` folder

---------

Co-authored-by: bohdanprog <bohdan.artiukhov@swmansion.com>
Co-authored-by: Jakub Grzywacz <jakub.grzywacz@swmansion.com>
This commit is contained in:
Bartosz Stefańczyk
2024-08-23 13:29:38 +02:00
committed by GitHub
parent 53ba6f2413
commit a089cc2efc
52 changed files with 1899 additions and 101 deletions

View File

@@ -7,45 +7,22 @@
import React, {Component} from 'react';
import {
Dimensions,
Modal,
Platform,
SafeAreaView,
ScrollView,
StyleSheet,
Text,
View,
ScrollView,
TouchableHighlight,
TouchableOpacity,
SafeAreaView,
View,
} from 'react-native';
import {Modal, Platform} from 'react-native';
import {Svg, Circle, Line} from 'react-native-svg';
import {Circle, Line, Svg} from 'react-native-svg';
import * as examples from './src/examples';
import {commonStyles} from './src/commonStyles';
const names: (keyof typeof examples)[] = [
'Svg',
'Stroking',
'Path',
'Line',
'Rect',
'Polygon',
'Polyline',
'Circle',
'Ellipse',
'G',
'Text',
'Gradients',
'Clipping',
'Image',
'TouchEvents',
'PanResponder',
'Reusable',
'Reanimated',
'Transforms',
'Markers',
'Mask',
'Filters',
'FilterImage',
];
import E2eTestingView from './src/e2e';
import * as examples from './src/examples';
import {names} from './utils/names';
const initialState = {
modal: false,
@@ -88,25 +65,30 @@ export default class SvgExample extends Component {
};
getExamples = () => {
return names.map(name => {
var icon;
let example = examples[name];
if (example) {
icon = example.icon;
}
return (
<TouchableHighlight
style={styles.link}
underlayColor="#ccc"
key={`example-${name}`}
onPress={() => this.show(name)}>
<View style={commonStyles.cell}>
{icon}
<Text style={commonStyles.title}>{name}</Text>
</View>
</TouchableHighlight>
);
});
return names
.filter(el => {
if (el !== 'E2E') return true;
return Platform.OS === 'android' || Platform.OS === 'ios';
})
.map(name => {
var icon;
let example = examples[name as keyof typeof examples];
if (example) {
icon = example.icon;
}
return (
<TouchableHighlight
style={styles.link}
underlayColor="#ccc"
key={`example-${name}`}
onPress={() => this.show(name as keyof typeof examples)}>
<View style={commonStyles.cell}>
{icon}
<Text style={commonStyles.title}>{name}</Text>
</View>
</TouchableHighlight>
);
});
};
modalContent = () => (
@@ -132,6 +114,12 @@ export default class SvgExample extends Component {
);
render() {
if (process.env.E2E) {
console.log(
'Opening E2E example, as E2E env is set to ' + process.env.E2E,
);
return <E2eTestingView />;
}
return (
<SafeAreaView style={styles.container}>
<Text style={commonStyles.welcome}>SVG library for React Apps</Text>

View File

@@ -0,0 +1,158 @@
import React, {
Component,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
import {Platform, Text, View} from 'react-native';
import * as RNSVG from 'react-native-svg';
import ViewShot from 'react-native-view-shot';
const address = ['ios', 'web'].includes(Platform.OS) ? 'localhost' : '10.0.2.2';
const wsUri = `ws://${address}:7123`;
const TestingView = () => {
const wrapperRef = useRef<ViewShot>(null);
const [wsClient, setWsClient] = useState<WebSocket | null>(null);
const [renderedContent, setRenderedContent] =
useState<React.ReactElement | null>();
const [readyToSnapshot, setReadyToSnapshot] = useState(false);
const [resolution, setResolution] = useState([0, 0]); // placeholder value, later updated by incoming render requests
const [message, setMessage] = useState('⏳ Connecting to Jest server...');
const connect = useCallback(() => {
const client = new WebSocket(wsUri);
setWsClient(client);
setMessage('⏳ Connecting to Jest server...');
client.onopen = () => {
client.send(
JSON.stringify({
os: Platform.OS,
version: Platform.Version,
arch: isFabric() ? 'fabric' : 'paper',
connectionTime: new Date(),
}),
);
setMessage('✅ Connected to Jest server. Waiting for render requests.');
};
client.onerror = (err: any) => {
if (!err.message) {
return;
}
console.error(
`Error while connecting to E2E WebSocket server at ${wsUri}: ${err.message}. Will retry in 3 seconds.`,
);
setMessage(
`🚨 Failed to connect to Jest server at ${wsUri}: ${err.message}! Will retry in 3 seconds.`,
);
setTimeout(() => {
connect();
}, 3000);
};
client.onmessage = ({data: rawMessage}) => {
const message = JSON.parse(rawMessage);
if (message.type == 'renderRequest') {
setMessage(`✅ Rendering tests, please don't close this tab.`);
setResolution([message.width, message.height]);
setRenderedContent(
createElementFromObject(
message.data.type || 'SvgFromXml',
message.data.props,
),
);
setReadyToSnapshot(true);
}
};
client.onclose = event => {
if (event.code == 1006 && event.reason) {
// this is an error, let error handler take care of it
return;
}
setMessage(
`✅ Connection to Jest server has been closed. You can close this tab safely. (${event.code})`,
);
};
}, [wsClient]);
// Create initial connection when rendering the view
useEffect(connect, []);
// Whenever new content is rendered, send renderResponse with snapshot view
useEffect(() => {
if (!readyToSnapshot || !wrapperRef.current) {
return;
}
wrapperRef.current.capture?.().then((value: string) => {
wsClient?.send(
JSON.stringify({
type: 'renderResponse',
data: value,
}),
);
setReadyToSnapshot(false);
});
}, [wrapperRef, readyToSnapshot]);
return (
<View>
<Text style={{marginLeft: 'auto', marginRight: 'auto'}}>{message}</Text>
<ViewShot
ref={wrapperRef}
style={{width: resolution[0], height: resolution[1]}}
options={{result: 'base64'}}>
{renderedContent}
</ViewShot>
</View>
);
};
class TestingViewWrapper extends Component {
static title = 'E2E Testing';
render() {
return <TestingView />;
}
}
const samples = [TestingViewWrapper];
const icon = (
<RNSVG.Svg height="30" width="30" viewBox="0 0 20 20">
<RNSVG.Circle
cx="10"
cy="10"
r="8"
stroke="purple"
strokeWidth="1"
fill="pink"
/>
</RNSVG.Svg>
);
function isFabric(): boolean {
// @ts-expect-error nativeFabricUIManager is not yet included in the RN types
return !!global?.nativeFabricUIManager;
}
export {samples, icon};
const createElementFromObject = (
element: keyof typeof RNSVG,
props: any,
): React.ReactElement => {
const children: any[] = [];
if (props.children) {
if (Array.isArray(props.children)) {
props?.children.forEach((child: {type: any; props: any}) =>
children.push(createElementFromObject(child.type, child?.props)),
);
} else if (typeof props.children === 'object') {
children.push(
createElementFromObject(props.children.type, props.children?.props),
);
} else {
children.push(props.children);
}
}
return React.createElement(RNSVG[element] as any, {...props, children});
};

View File

@@ -0,0 +1,3 @@
export default function () {
return null;
}

View File

@@ -0,0 +1,8 @@
import React from 'react';
import {SafeAreaView} from 'react-native';
import {samples} from './TestingView';
export default function () {
const e2eTab = React.createElement(samples[0]);
return <SafeAreaView>{e2eTab}</SafeAreaView>;
}

View File

@@ -0,0 +1,3 @@
export default function () {
return null;
}

View File

@@ -0,0 +1,49 @@
import * as Svg from './examples/Svg';
import * as Rect from './examples/Rect';
import * as Circle from './examples/Circle';
import * as Ellipse from './examples/Ellipse';
import * as Line from './examples/Line';
import * as Polygon from './examples/Polygon';
import * as Polyline from './examples/Polyline';
import * as Path from './examples/Path';
import * as Text from './examples/Text';
import * as G from './examples/G';
import * as Stroking from './examples/Stroking';
import * as Gradients from './examples/Gradients';
import * as Clipping from './examples/Clipping';
import * as Image from './examples/Image';
import * as Reusable from './examples/Reusable';
import * as TouchEvents from './examples/TouchEvents';
import * as PanResponder from './examples/PanResponder';
import * as Reanimated from './examples/Reanimated';
import * as Transforms from './examples/Transforms';
import * as Markers from './examples/Markers';
import * as Mask from './examples/Mask';
import * as Filters from './examples/Filters';
import * as FilterImage from './examples/FilterImage';
export {
Svg,
Rect,
Circle,
Ellipse,
Line,
Polygon,
Polyline,
Path,
Text,
Stroking,
G,
Gradients,
Clipping,
Image,
TouchEvents,
Reusable,
PanResponder,
Reanimated,
Transforms,
Markers,
Mask,
Filters,
FilterImage,
};

View File

@@ -19,6 +19,7 @@ import * as Reanimated from './examples/Reanimated';
import * as Transforms from './examples/Transforms';
import * as Markers from './examples/Markers';
import * as Mask from './examples/Mask';
import * as E2E from './e2e/TestingView';
import * as Filters from './examples/Filters';
import * as FilterImage from './examples/FilterImage';
@@ -44,6 +45,7 @@ export {
Transforms,
Markers,
Mask,
E2E,
Filters,
FilterImage,
};

View File

@@ -1,4 +1,7 @@
{
"compilerOptions": {
"moduleSuffixes": [".macos", ""]
},
"extends": "../../tsconfig.json",
"include": ["**/*.ts", "**/*.tsx", "**/*.js"]
}

View File

@@ -0,0 +1,28 @@
import {ExamplesKey} from './type';
export const names: ExamplesKey[] = [
'Svg',
'Stroking',
'Path',
'Line',
'Rect',
'Polygon',
'Polyline',
'Circle',
'Ellipse',
'G',
'Text',
'Gradients',
'Clipping',
'Image',
'TouchEvents',
'PanResponder',
'Reusable',
'Reanimated',
'Transforms',
'Markers',
'Mask',
'E2E',
'Filters',
'FilterImage',
];

View File

@@ -0,0 +1,3 @@
import * as examples from '../src/examples';
export type ExamplesKey = keyof typeof examples | 'E2E';