mirror of
https://github.com/zoriya/react-native-svg.git
synced 2025-12-06 07:06:11 +00:00
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:
committed by
GitHub
parent
53ba6f2413
commit
a089cc2efc
@@ -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>
|
||||
|
||||
158
apps/examples/src/e2e/TestingView.tsx
Normal file
158
apps/examples/src/e2e/TestingView.tsx
Normal 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});
|
||||
};
|
||||
3
apps/examples/src/e2e/index.macos.tsx
Normal file
3
apps/examples/src/e2e/index.macos.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function () {
|
||||
return null;
|
||||
}
|
||||
8
apps/examples/src/e2e/index.tsx
Normal file
8
apps/examples/src/e2e/index.tsx
Normal 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>;
|
||||
}
|
||||
3
apps/examples/src/e2e/index.web.tsx
Normal file
3
apps/examples/src/e2e/index.web.tsx
Normal file
@@ -0,0 +1,3 @@
|
||||
export default function () {
|
||||
return null;
|
||||
}
|
||||
49
apps/examples/src/examples.macos.tsx
Normal file
49
apps/examples/src/examples.macos.tsx
Normal 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,
|
||||
};
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"moduleSuffixes": [".macos", ""]
|
||||
},
|
||||
"extends": "../../tsconfig.json",
|
||||
"include": ["**/*.ts", "**/*.tsx", "**/*.js"]
|
||||
}
|
||||
|
||||
28
apps/examples/utils/names.ts
Normal file
28
apps/examples/utils/names.ts
Normal 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',
|
||||
];
|
||||
3
apps/examples/utils/type.ts
Normal file
3
apps/examples/utils/type.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import * as examples from '../src/examples';
|
||||
|
||||
export type ExamplesKey = keyof typeof examples | 'E2E';
|
||||
Reference in New Issue
Block a user