Skip to content

Commit 56e0205

Browse files
committed
feat: improvements to image handling
1 parent 74dc96c commit 56e0205

File tree

15 files changed

+263
-186
lines changed

15 files changed

+263
-186
lines changed

packages/bridge/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,11 +30,13 @@
3030
"@react-native-harness/platforms": "workspace:*",
3131
"@react-native-harness/tools": "workspace:*",
3232
"birpc": "^2.4.0",
33+
"pixelmatch": "^7.1.0",
3334
"pngjs": "^7.0.0",
3435
"tslib": "^2.3.0",
3536
"ws": "^8.18.2"
3637
},
3738
"devDependencies": {
39+
"@types/pixelmatch": "^5.2.6",
3840
"@types/pngjs": "^6.0.5",
3941
"@types/ws": "^8.18.1"
4042
},

packages/bridge/src/image-snapshot.ts

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
1-
import pixelmatch, { type PixelmatchOptions } from 'pixelmatch';
1+
import pixelmatch from 'pixelmatch';
22
import fs from 'node:fs/promises';
33
import path from 'node:path';
44
import { PNG } from 'pngjs';
55
import type { FileReference, ImageSnapshotOptions } from './shared.js';
66

7+
type PixelmatchOptions = Parameters<typeof pixelmatch>[5];
8+
79
const SNAPSHOT_DIR_NAME = '__image_snapshots__';
810
const DEFAULT_OPTIONS_FOR_PIXELMATCH: PixelmatchOptions = {
911
threshold: 0.1,
@@ -18,8 +20,9 @@ const DEFAULT_OPTIONS_FOR_PIXELMATCH: PixelmatchOptions = {
1820

1921
export const matchImageSnapshot = async (
2022
screenshot: FileReference,
21-
testPath: string,
22-
options: ImageSnapshotOptions
23+
testFilePath: string,
24+
options: ImageSnapshotOptions,
25+
platformName: string
2326
) => {
2427
const pixelmatchOptions = {
2528
...DEFAULT_OPTIONS_FOR_PIXELMATCH,
@@ -36,8 +39,8 @@ export const matchImageSnapshot = async (
3639
const receivedBuffer = await fs.readFile(receivedPath);
3740

3841
// Create __image_snapshots__ directory in same directory as test file
39-
const testDir = path.dirname(testPath);
40-
const snapshotsDir = path.join(testDir, SNAPSHOT_DIR_NAME);
42+
const testDir = path.dirname(testFilePath);
43+
const snapshotsDir = path.join(testDir, SNAPSHOT_DIR_NAME, platformName);
4144

4245
const snapshotName = `${options.name}.png`;
4346
const snapshotPath = path.join(snapshotsDir, snapshotName);
@@ -64,6 +67,13 @@ export const matchImageSnapshot = async (
6467
const { width, height } = img1;
6568
const diff = new PNG({ width, height });
6669

70+
if (img1.width !== img2.width || img1.height !== img2.height) {
71+
return {
72+
pass: false,
73+
message: `Images have different dimensions. Received image width: ${img1.width}, height: ${img1.height}. Snapshot image width: ${img2.width}, height: ${img2.height}.`,
74+
};
75+
}
76+
6777
// Compare buffers byte by byte
6878
const differences = pixelmatch(
6979
img1.data,

packages/bridge/src/server.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
DeviceDescriptor,
99
BridgeEvents,
1010
ImageSnapshotOptions,
11+
HarnessContext,
1112
} from './shared.js';
1213
import { deserialize, serialize } from './serializer.js';
1314
import { DeviceNotRespondingError } from './errors.js';
@@ -21,6 +22,7 @@ import { matchImageSnapshot } from './image-snapshot.js';
2122
export type BridgeServerOptions = {
2223
port: number;
2324
timeout?: number;
25+
context: HarnessContext;
2426
};
2527

2628
export type BridgeServerEvents = {
@@ -50,6 +52,7 @@ export type BridgeServer = {
5052
export const getBridgeServer = async ({
5153
port,
5254
timeout,
55+
context,
5356
}: BridgeServerOptions): Promise<BridgeServer> => {
5457
const wss = await new Promise<WebSocketServer>((resolve) => {
5558
const server = new WebSocketServer({ port, host: '0.0.0.0' }, () => {
@@ -92,7 +95,12 @@ export const getBridgeServer = async ({
9295
testPath: string,
9396
options: ImageSnapshotOptions
9497
) => {
95-
return await matchImageSnapshot(screenshot, testPath, options);
98+
return await matchImageSnapshot(
99+
screenshot,
100+
testPath,
101+
options,
102+
context.platform.name
103+
);
96104
},
97105
};
98106

@@ -101,6 +109,10 @@ export const getBridgeServer = async ({
101109
[],
102110
{
103111
timeout,
112+
onFunctionError: (error, functionName, args) => {
113+
console.error('Function error', error, functionName, args);
114+
throw error;
115+
},
104116
onTimeoutError(functionName, args) {
105117
throw new DeviceNotRespondingError(functionName, args);
106118
},

packages/bridge/src/shared.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import type {
88
UIElement,
99
ElementReference,
1010
FileReference,
11+
HarnessPlatform,
1112
} from '@react-native-harness/platforms';
1213

1314
export type {
@@ -105,12 +106,13 @@ export type TestExecutionOptions = {
105106
testNamePattern?: string;
106107
setupFiles?: string[];
107108
setupFilesAfterEnv?: string[];
109+
runner: string;
108110
};
109111

110112
export type BridgeClientFunctions = {
111113
runTests: (
112114
path: string,
113-
options?: TestExecutionOptions
115+
options: TestExecutionOptions
114116
) => Promise<TestSuiteResult>;
115117
};
116118

@@ -134,6 +136,11 @@ export type BridgeServerFunctions = {
134136
'test.matchImageSnapshot': (
135137
screenshot: FileReference,
136138
testPath: string,
137-
options: ImageSnapshotOptions
139+
options: ImageSnapshotOptions,
140+
runner: string
138141
) => Promise<{ pass: boolean; message: string }>;
139142
};
143+
144+
export type HarnessContext = {
145+
platform: HarnessPlatform;
146+
};

packages/jest/src/harness.ts

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { getBridgeServer } from '@react-native-harness/bridge/server';
2-
import { BridgeClientFunctions } from '@react-native-harness/bridge';
2+
import {
3+
HarnessContext,
4+
TestExecutionOptions,
5+
TestSuiteResult,
6+
} from '@react-native-harness/bridge';
37
import { HarnessPlatform } from '@react-native-harness/platforms';
48
import { getMetroInstance } from '@react-native-harness/bundler-metro';
59
import { InitializationTimeoutError } from './errors.js';
@@ -8,8 +12,14 @@ import pRetry from 'p-retry';
812

913
const BRIDGE_READY_TIMEOUT = 10000;
1014

15+
export type HarnessRunTestsOptions = Exclude<TestExecutionOptions, 'platform'>;
16+
1117
export type Harness = {
12-
runTests: BridgeClientFunctions['runTests'];
18+
context: HarnessContext;
19+
runTests: (
20+
path: string,
21+
options: HarnessRunTestsOptions
22+
) => Promise<TestSuiteResult>;
1323
restart: () => Promise<void>;
1424
dispose: () => Promise<void>;
1525
};
@@ -20,12 +30,17 @@ const getHarnessInternal = async (
2030
projectRoot: string,
2131
signal: AbortSignal
2232
): Promise<Harness> => {
33+
const context: HarnessContext = {
34+
platform,
35+
};
36+
2337
const [metroInstance, platformInstance, serverBridge] = await Promise.all([
2438
getMetroInstance({ projectRoot }, signal),
2539
import(platform.runner).then((module) => module.default(platform.config)),
2640
getBridgeServer({
2741
port: 3001,
2842
timeout: config.bridgeTimeout,
43+
context,
2944
}),
3045
]);
3146

@@ -76,14 +91,18 @@ const getHarnessInternal = async (
7691
});
7792

7893
return {
94+
context,
7995
runTests: async (path, options) => {
8096
const client = serverBridge.rpc.clients.at(-1);
8197

8298
if (!client) {
8399
throw new Error('No client found');
84100
}
85101

86-
return await client.runTests(path, options);
102+
return await client.runTests(path, {
103+
...options,
104+
runner: platform.runner,
105+
});
87106
},
88107
restart,
89108
dispose,

packages/jest/src/run.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export const runHarnessTestFile: RunHarnessTestFile = async ({
8888
testNamePattern: globalConfig.testNamePattern,
8989
setupFiles,
9090
setupFilesAfterEnv,
91+
runner: harness.context.platform.runner,
9192
});
9293
const end = Date.now();
9394

packages/runtime/src/client/factory.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ export const getClient = async () => {
2828

2929
client.rpc.$functions.runTests = async (
3030
path: string,
31-
options: TestExecutionOptions = {}
31+
options: TestExecutionOptions
3232
) => {
3333
if (store.getState().status === 'running') {
3434
throw new Error('Already running tests');
@@ -88,7 +88,11 @@ export const getClient = async () => {
8888
)
8989
: collectionResult.testSuite;
9090

91-
const result = await runner.run(processedTestSuite, path);
91+
const result = await runner.run({
92+
testSuite: processedTestSuite,
93+
testFilePath: path,
94+
runner: options.runner,
95+
});
9296
return result;
9397
} finally {
9498
collector?.dispose();
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
// This is adapted version of https://github.com/vitest-dev/vitest/blob/main/packages/vitest/src/integrations/chai/index.ts
2+
// Credits to Vitest team for the original implementation.
3+
4+
import type { Assertion, ExpectStatic, MatcherState } from '@vitest/expect';
5+
import {
6+
addCustomEqualityTesters,
7+
ASYMMETRIC_MATCHERS_OBJECT,
8+
customMatchers,
9+
getState,
10+
GLOBAL_EXPECT,
11+
setState,
12+
} from '@vitest/expect';
13+
import * as chai from 'chai';
14+
15+
// Setup additional matchers
16+
import './setup.js';
17+
import { toMatchImageSnapshot } from './matchers/toMatchImageSnapshot.js';
18+
19+
export function createExpect(): ExpectStatic {
20+
const expect = ((value: unknown, message?: string): Assertion => {
21+
const { assertionCalls } = getState(expect);
22+
setState({ assertionCalls: assertionCalls + 1 }, expect);
23+
return chai.expect(value, message) as unknown as Assertion;
24+
}) as ExpectStatic;
25+
Object.assign(expect, chai.expect);
26+
Object.assign(
27+
expect,
28+
globalThis[ASYMMETRIC_MATCHERS_OBJECT as unknown as keyof typeof globalThis]
29+
);
30+
31+
expect.getState = () => getState<MatcherState>(expect);
32+
expect.setState = (state) => setState(state as Partial<MatcherState>, expect);
33+
34+
// @ts-expect-error global is not typed
35+
const globalState = getState(globalThis[GLOBAL_EXPECT]) || {};
36+
37+
setState<MatcherState>(
38+
{
39+
// this should also add "snapshotState" that is added conditionally
40+
...globalState,
41+
assertionCalls: 0,
42+
isExpectingAssertions: false,
43+
isExpectingAssertionsError: null,
44+
expectedAssertionsNumber: null,
45+
expectedAssertionsNumberErrorGen: null,
46+
},
47+
expect
48+
);
49+
50+
// @ts-expect-error untyped
51+
expect.extend = (matchers) => chai.expect.extend(expect, matchers);
52+
// @ts-expect-error untyped
53+
expect.addEqualityTesters = (customTesters) =>
54+
addCustomEqualityTesters(customTesters);
55+
56+
// @ts-expect-error untyped
57+
expect.soft = (...args) => {
58+
// @ts-expect-error private soft access
59+
return expect(...args).withContext({ soft: true }) as Assertion;
60+
};
61+
62+
// @ts-expect-error untyped
63+
expect.unreachable = (message?: string) => {
64+
chai.assert.fail(
65+
`expected${message ? ` "${message}" ` : ' '}not to be reached`
66+
);
67+
};
68+
69+
function assertions(expected: number) {
70+
const errorGen = () =>
71+
new Error(
72+
`expected number of assertions to be ${expected}, but got ${
73+
expect.getState().assertionCalls
74+
}`
75+
);
76+
if (Error.captureStackTrace) {
77+
Error.captureStackTrace(errorGen(), assertions);
78+
}
79+
80+
expect.setState({
81+
expectedAssertionsNumber: expected,
82+
expectedAssertionsNumberErrorGen: errorGen,
83+
});
84+
}
85+
86+
function hasAssertions() {
87+
const error = new Error('expected any number of assertion, but got none');
88+
if (Error.captureStackTrace) {
89+
Error.captureStackTrace(error, hasAssertions);
90+
}
91+
92+
expect.setState({
93+
isExpectingAssertions: true,
94+
isExpectingAssertionsError: error,
95+
});
96+
}
97+
98+
chai.util.addMethod(expect, 'assertions', assertions);
99+
chai.util.addMethod(expect, 'hasAssertions', hasAssertions);
100+
101+
expect.extend(customMatchers);
102+
expect.extend({
103+
toMatchImageSnapshot,
104+
});
105+
106+
return expect;
107+
}
108+
109+
const globalExpect: ExpectStatic = createExpect();
110+
111+
Object.defineProperty(globalThis, GLOBAL_EXPECT, {
112+
value: globalExpect,
113+
writable: true,
114+
configurable: true,
115+
});
116+
117+
export { assert, should } from 'chai';
118+
export { chai, globalExpect as expect };
119+
120+
export type {
121+
Assertion,
122+
AsymmetricMatchersContaining,
123+
DeeplyAllowMatchers,
124+
ExpectStatic,
125+
JestAssertion,
126+
Matchers,
127+
} from '@vitest/expect';

0 commit comments

Comments
 (0)