Skip to content

Commit 95e4ba1

Browse files
authored
Adding visual regression testing infra (#16016)
* Add ability for e2e screenshots * Change files * fix * packages.lock.json * fix * Switch away from winrt capture API since it requires user permissions * fix * update lock * publish image snapshots to an artifact * format * fix * lock file * fix
1 parent ff42955 commit 95e4ba1

18 files changed

Lines changed: 929 additions & 33 deletions

.ado/jobs/e2e-test.yml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,21 @@ jobs:
7474
workingDirectory: packages/e2e-test-app-fabric
7575
timeoutInMinutes: 10 # Time to wait for this task to complete before the server kills it.
7676
77+
- script: |
78+
if [ -d "packages/e2e-test-app-fabric/test/__image_snapshots__/__diff_output__" ]; then
79+
echo "##vso[task.setvariable variable=DiffOutputExists]true"
80+
fi
81+
displayName: Check for image diff output folder
82+
condition: failed()
83+
84+
- task: CopyFiles@2
85+
displayName: Copy Fabric image diffs
86+
inputs:
87+
sourceFolder: packages/e2e-test-app-fabric/test/__image_snapshots__/__diff_output__
88+
targetFolder: $(Build.StagingDirectory)/snapshots-image-diffs
89+
contents: "**"
90+
condition: and(failed(), eq(variables.DiffOutputExists, 'true'))
91+
7792
- script: npx jest --clearCache
7893
displayName: clear jest cache
7994
workingDirectory: packages/e2e-test-app-fabric
@@ -103,6 +118,14 @@ jobs:
103118
contents: "**"
104119
condition: failed()
105120

121+
- task: CopyFiles@2
122+
displayName: Copy Fabric image snapshots
123+
inputs:
124+
sourceFolder: packages/e2e-test-app-fabric/test/__image_snapshots__
125+
targetFolder: $(Build.StagingDirectory)/snapshots-image
126+
contents: "**"
127+
condition: failed()
128+
106129
- task: CopyFiles@2
107130
displayName: Copy RNTesterApp artifacts
108131
inputs:
@@ -133,6 +156,16 @@ jobs:
133156
condition: failed()
134157
targetPath: $(Build.StagingDirectory)/snapshots-fabric
135158
artifactName: Snapshots - RNTesterApp-Fabric-${{ matrix.Name }}-$(System.JobAttempt)
159+
- output: pipelineArtifact
160+
displayName: 'Publish Artifact: Image Diffs'
161+
condition: failed()
162+
targetPath: $(Build.StagingDirectory)/snapshots-image-diffs
163+
artifactName: Snapshots - RNTesterApp-Image-Diffs-${{ matrix.Name }}-$(System.JobAttempt)
164+
- output: pipelineArtifact
165+
displayName: 'Publish Artifact: Image Snapshots'
166+
condition: failed()
167+
targetPath: $(Build.StagingDirectory)/snapshots-image
168+
artifactName: Snapshots - RNTesterApp-Image-${{ matrix.Name }}-$(System.JobAttempt)
136169
- output: pipelineArtifact
137170
displayName: 'Upload build logs'
138171
condition: succeededOrFailed()
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
{
2+
"type": "prerelease",
3+
"comment": "Add ability for e2e screenshots",
4+
"packageName": "@react-native-windows/automation-commands",
5+
"email": "30809111+acoates-ms@users.noreply.github.com",
6+
"dependentChangeType": "patch"
7+
}

packages/@react-native-windows/automation-commands/src/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,5 +6,6 @@
66
*/
77

88
import dumpVisualTree from './dumpVisualTree';
9+
import createScreenshot from './screenshot';
910

10-
export {dumpVisualTree};
11+
export { dumpVisualTree, createScreenshot };
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
/**
2+
* Copyright (c) Microsoft Corporation.
3+
* Licensed under the MIT License.
4+
*
5+
* @format
6+
*/
7+
8+
/**
9+
* Take a screenshot of the app window
10+
*/
11+
export default async function createScreenshot(
12+
opts?: {
13+
accessibilityId: string,
14+
screenshotsPath?: string,
15+
location?: { x: number, y: number, width: number, height: number };
16+
},
17+
): Promise<void> {
18+
if (!automationClient) {
19+
throw new Error('RPC client is not enabled');
20+
}
21+
22+
const dumpResponse = await automationClient.invoke('CreateScreenshot', {
23+
...opts,
24+
});
25+
26+
if (dumpResponse.type === 'error') {
27+
throw new Error(dumpResponse.message);
28+
}
29+
}

packages/@react-native-windows/tester/src/js/examples/View/ViewExample.windows.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -424,6 +424,7 @@ function BoxShadowExample(): React.Node {
424424

425425
return (
426426
<View
427+
accessible
427428
testID="view-test-box-shadow"
428429
style={{flexDirection: 'row', flexWrap: 'wrap', gap: 30, padding: 20}}>
429430
<View

packages/e2e-test-app-fabric/jest.setup.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,10 @@ const sanitizeFilename = require('sanitize-filename');
1212
// disabled temporarily
1313
//const {LogBox} = require('react-native');
1414

15+
const { toMatchImageSnapshot } = require('jest-image-snapshot');
16+
17+
expect.extend({ toMatchImageSnapshot });
18+
1519
const screenshotDir = './errorShots';
1620
fs.mkdirSync(screenshotDir, {recursive: true});
1721

packages/e2e-test-app-fabric/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343
"@react-native-community/cli": "20.0.0",
4444
"@react-native-windows/automation": "0.0.0-canary.1052",
4545
"@react-native-windows/automation-commands": "0.0.0-canary.1052",
46+
"@react-native-windows/fs": "^0.0.0-canary.72",
4647
"@react-native-windows/perf-testing": "0.0.0-canary.1038",
4748
"@react-native/metro-config": "0.85.0-nightly-20260114-f15985f4f",
4849
"@rnw-scripts/babel-node-config": "2.3.3",
@@ -60,6 +61,7 @@
6061
"eslint": "^8.19.0",
6162
"jest": "^29.7.0",
6263
"jest-environment-node": "^29.7.0",
64+
"jest-image-snapshot": "^6.5.2",
6365
"prettier": "^3.6.2",
6466
"react-test-renderer": "19.1.0",
6567
"sanitize-filename": "^1.6.3",

packages/e2e-test-app-fabric/test/Helpers.ts

Lines changed: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,13 @@
55
* @format
66
*/
77

8-
import {AutomationClient} from '@react-native-windows/automation-channel';
8+
import type { AutomationElement } from '@react-native-windows/automation';
9+
import { AutomationClient } from '@react-native-windows/automation-channel';
10+
11+
import { createScreenshot } from '@react-native-windows/automation-commands';
12+
import { tmpdir } from 'os';
13+
import { join } from 'path';
14+
import fs from '@react-native-windows/fs';
915

1016
declare global {
1117
const automationClient: AutomationClient | undefined;
@@ -15,6 +21,23 @@ type ErrorsResult = {
1521
errors: string[];
1622
};
1723

24+
export interface ImageSnapshotConfig {
25+
failureThreshold?: number;
26+
failureThresholdType?: 'percent'
27+
}
28+
29+
export async function verifyElementVisualSnapshot(component: AutomationElement, config?: ImageSnapshotConfig) {
30+
const { x, y } = await component.getLocation();
31+
const { width, height } = await component.getSize();
32+
await createScreenshot({ screenshotsPath: tmpdir(), location: { x, y, width, height } });
33+
const myFancyImage = fs.readFileSync(join(tmpdir(), "./RNTester.png"));
34+
expect(myFancyImage).toMatchImageSnapshot({
35+
failureThreshold: 0.01,
36+
failureThresholdType: 'percent',
37+
...config
38+
});
39+
}
40+
1841
export async function verifyNoErrorLogs(
1942
errorFilter?: (errors: string[]) => string[],
2043
): Promise<void> {

packages/e2e-test-app-fabric/test/ViewComponentTest.test.ts

Lines changed: 34 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
* @format
66
*/
77

8-
import {dumpVisualTree} from '@react-native-windows/automation-commands';
9-
import {goToComponentExample} from './RNTesterNavigation';
10-
import {app} from '@react-native-windows/automation';
11-
import {verifyNoErrorLogs} from './Helpers';
8+
import { dumpVisualTree } from '@react-native-windows/automation-commands';
9+
import { goToComponentExample } from './RNTesterNavigation';
10+
import { app } from '@react-native-windows/automation';
11+
import { verifyNoErrorLogs, verifyElementVisualSnapshot } from './Helpers';
1212

1313
beforeAll(async () => {
1414
// If window is partially offscreen, tests will fail to click on certain elements
@@ -40,14 +40,23 @@ describe('View Tests', () => {
4040
test('Views can have shadows', async () => {
4141
await searchBox('sha');
4242
const component = await app.findElementByTestID('shadow');
43-
await component.waitForDisplayed({timeout: 5000});
43+
await component.waitForDisplayed({ timeout: 5000 });
4444
const dump = await dumpVisualTree('shadow');
4545
expect(dump).toMatchSnapshot();
4646
});
47+
test('Visual Snapshot of Views with shadows', async () => {
48+
await searchBox('box shadow');
49+
const component = await app.findElementByTestID('view-test-box-shadow');
50+
await component.waitForDisplayed({ timeout: 5000 });
51+
const dump = await dumpVisualTree('view-test-box-shadow');
52+
expect(dump).toMatchSnapshot();
53+
54+
await verifyElementVisualSnapshot(component);
55+
})
4756
test('Views can have border styles', async () => {
4857
await searchBox('sty');
4958
const componentsTab = await app.findElementByTestID('border-style-button');
50-
await componentsTab.waitForDisplayed({timeout: 5000});
59+
await componentsTab.waitForDisplayed({ timeout: 5000 });
5160
const dump = await dumpVisualTree('border-style-button');
5261
expect(dump).toMatchSnapshot();
5362
});
@@ -56,21 +65,21 @@ describe('View Tests', () => {
5665
const componentsTab = await app.findElementByTestID(
5766
'offscreen-alpha-compositing-button',
5867
);
59-
await componentsTab.waitForDisplayed({timeout: 5000});
68+
await componentsTab.waitForDisplayed({ timeout: 5000 });
6069
const dump = await dumpVisualTree('offscreen-alpha-compositing-button');
6170
expect(dump).toMatchSnapshot();
6271
});
6372
test('Views can have a z-index', async () => {
6473
await searchBox('z');
6574
const componentsTab = await app.findElementByTestID('z-index-button');
66-
await componentsTab.waitForDisplayed({timeout: 5000});
75+
await componentsTab.waitForDisplayed({ timeout: 5000 });
6776
const dump = await dumpVisualTree('z-index-button');
6877
expect(dump).toMatchSnapshot();
6978
});
7079
test('Views can have display: none', async () => {
7180
await searchBox('dis');
7281
const componentsTab = await app.findElementByTestID('display-none-button');
73-
await componentsTab.waitForDisplayed({timeout: 5000});
82+
await componentsTab.waitForDisplayed({ timeout: 5000 });
7483
const dump = await dumpVisualTree('display-none-button');
7584
expect(dump).toMatchSnapshot();
7685
});
@@ -80,14 +89,14 @@ describe('View Tests', () => {
8089
const componentsTab = await app.findElementByTestID(
8190
'view-test-background-color',
8291
);
83-
await componentsTab.waitForDisplayed({timeout: 5000});
92+
await componentsTab.waitForDisplayed({ timeout: 5000 });
8493
const dump = await dumpVisualTree('view-test-background-color');
8594
expect(dump).toMatchSnapshot();
8695
});
8796
test('Views can have borders', async () => {
8897
await searchBox('bor');
8998
const componentsTab = await app.findElementByTestID('view-test-border');
90-
await componentsTab.waitForDisplayed({timeout: 5000});
99+
await componentsTab.waitForDisplayed({ timeout: 5000 });
91100
const dump = await dumpVisualTree('view-test-border');
92101
expect(dump).toMatchSnapshot();
93102
});
@@ -96,7 +105,7 @@ describe('View Tests', () => {
96105
const componentsTab = await app.findElementByTestID(
97106
'view-test-padding-margin',
98107
);
99-
await componentsTab.waitForDisplayed({timeout: 5000});
108+
await componentsTab.waitForDisplayed({ timeout: 5000 });
100109
const dump = await dumpVisualTree('view-test-padding-margin');
101110
expect(dump).toMatchSnapshot();
102111
});
@@ -105,14 +114,14 @@ describe('View Tests', () => {
105114
const componentsTab = await app.findElementByTestID(
106115
'view-test-rounded-borders',
107116
);
108-
await componentsTab.waitForDisplayed({timeout: 5000});
117+
await componentsTab.waitForDisplayed({ timeout: 5000 });
109118
const dump = await dumpVisualTree('view-test-rounded-borders');
110119
expect(dump).toMatchSnapshot();
111120
});
112121
test('Views can have overflow', async () => {
113122
await searchBox('ove');
114123
const componentsTab = await app.findElementByTestID('view-test-overflow');
115-
await componentsTab.waitForDisplayed({timeout: 5000});
124+
await componentsTab.waitForDisplayed({ timeout: 5000 });
116125
const dump = await dumpVisualTree('view-test-overflow');
117126
expect(dump).toMatchSnapshot();
118127
});
@@ -121,21 +130,21 @@ describe('View Tests', () => {
121130
const componentsTab = await app.findElementByTestID(
122131
'view-test-rounded-borders',
123132
);
124-
await componentsTab.waitForDisplayed({timeout: 5000});
133+
await componentsTab.waitForDisplayed({ timeout: 5000 });
125134
const dump = await dumpVisualTree('view-test-rounded-borders');
126135
expect(dump).toMatchSnapshot();
127136
});
128137
test('Views can have customized opacity', async () => {
129138
await searchBox('opa');
130139
const componentsTab = await app.findElementByTestID('view-test-opacity');
131-
await componentsTab.waitForDisplayed({timeout: 5000});
140+
await componentsTab.waitForDisplayed({ timeout: 5000 });
132141
const dump = await dumpVisualTree('view-test-opacity');
133142
expect(dump).toMatchSnapshot();
134143
});
135144
test('Views can have tooltips', async () => {
136145
await searchBox('too');
137146
const componentsTab = await app.findElementByTestID('tool-tip');
138-
await componentsTab.waitForDisplayed({timeout: 5000});
147+
await componentsTab.waitForDisplayed({ timeout: 5000 });
139148
const dump = await dumpVisualTree('tool-tip');
140149
expect(dump).toMatchSnapshot();
141150
});
@@ -144,42 +153,42 @@ describe('View Tests', () => {
144153
const componentsTab = await app.findElementByTestID(
145154
'view-test-backface-visibility',
146155
);
147-
await componentsTab.waitForDisplayed({timeout: 5000});
156+
await componentsTab.waitForDisplayed({ timeout: 5000 });
148157
const dump = await dumpVisualTree('view-test-backface-visibility');
149158
expect(dump).toMatchSnapshot();
150159
});
151160
test('Views can have aria-labels', async () => {
152161
await searchBox('ari');
153162
const componentsTab = await app.findElementByTestID('view-test-aria-label');
154-
await componentsTab.waitForDisplayed({timeout: 5000});
163+
await componentsTab.waitForDisplayed({ timeout: 5000 });
155164
const dump = await dumpVisualTree('view-test-aria-label');
156165
expect(dump).toMatchSnapshot();
157166
});
158167
test('Views can have flexgap', async () => {
159168
await searchBox('fle');
160169
const componentsTab = await app.findElementByTestID('view-test-flexgap');
161-
await componentsTab.waitForDisplayed({timeout: 5000});
170+
await componentsTab.waitForDisplayed({ timeout: 5000 });
162171
const dump = await dumpVisualTree('view-test-flexgap');
163172
expect(dump).toMatchSnapshot();
164173
});
165174
test('Views can have insets', async () => {
166175
await searchBox('ins');
167176
const componentsTab = await app.findElementByTestID('view-test-insets');
168-
await componentsTab.waitForDisplayed({timeout: 5000});
177+
await componentsTab.waitForDisplayed({ timeout: 5000 });
169178
const dump = await dumpVisualTree('view-test-insets');
170179
expect(dump).toMatchSnapshot();
171180
});
172181
test('Views can have customized accessibility', async () => {
173182
await searchBox('acc');
174183
const componentsTab = await app.findElementByTestID('accessibility');
175-
await componentsTab.waitForDisplayed({timeout: 5000});
184+
await componentsTab.waitForDisplayed({ timeout: 5000 });
176185
const dump = await dumpVisualTree('accessibility');
177186
expect(dump).toMatchSnapshot();
178187
});
179188
test('Views can have a hitslop region', async () => {
180189
await searchBox('hit');
181190
const componentsTab = await app.findElementByTestID('hitslop');
182-
await componentsTab.waitForDisplayed({timeout: 5000});
191+
await componentsTab.waitForDisplayed({ timeout: 5000 });
183192
const dump = await dumpVisualTree('hitslop');
184193
expect(dump).toMatchSnapshot();
185194
});
@@ -188,14 +197,14 @@ describe('View Tests', () => {
188197
const componentsTab = await app.findElementByTestID(
189198
'view-test-logical-border-color',
190199
);
191-
await componentsTab.waitForDisplayed({timeout: 5000});
200+
await componentsTab.waitForDisplayed({ timeout: 5000 });
192201
const dump = await dumpVisualTree('view-test-logical-border-color');
193202
expect(dump).toMatchSnapshot();
194203
});
195204
test('Views can have a nativeid', async () => {
196205
await searchBox('nat');
197206
const componentsTab = await app.findElementByTestID('nativeid');
198-
await componentsTab.waitForDisplayed({timeout: 5000});
207+
await componentsTab.waitForDisplayed({ timeout: 5000 });
199208
const dump = await dumpVisualTree('nativeid');
200209
expect(dump).toMatchSnapshot();
201210
});
Loading

0 commit comments

Comments
 (0)