Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/playground/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ module.exports = {
setupFilesAfterEnv: ['./src/setupFileAfterEnv.ts'],
// This is necessary to prevent Jest from transforming the workspace packages.
// Not needed in users projects, as they will have the packages installed in their node_modules.
transformIgnorePatterns: ['/packages/'],
transformIgnorePatterns: ['/packages/', '/node_modules/'],
},
],
collectCoverageFrom: ['./src/**/*.(ts|tsx)'],
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 40 additions & 0 deletions apps/playground/src/__tests__/ui/actions.harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import {
screen,
describe,
test,
render,
userEvent,
fn,
expect,
} from 'react-native-harness';
import { View, Text, Pressable } from 'react-native';

describe('Actions', () => {
test('should tap element found by testID', async () => {
const onPress = fn();

await render(
<View
style={{
flex: 1,
justifyContent: 'center',
alignItems: 'center',
backgroundColor: 'white',
}}
>
<Pressable
testID="this-is-test-id"
onPress={onPress}
style={{ padding: 10, backgroundColor: 'red' }}
>
<Text style={{ color: 'black' }}>This is a view with a testID</Text>
</Pressable>
</View>
);

const element = await screen.findByTestId('this-is-test-id');
await userEvent.tap(element);

expect(onPress).toHaveBeenCalled();
});
});
41 changes: 41 additions & 0 deletions apps/playground/src/__tests__/ui/queries.harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import { View, Text } from 'react-native';
import {
describe,
test,
expect,
render,
screen,
userEvent,
} from 'react-native-harness';

describe('Queries', () => {
test('should find element by testID', async () => {
await render(
<View>
<View testID="this-is-test-id">
<Text>This is a view with a testID</Text>
</View>
</View>
);
const element = await screen.findByTestId('this-is-test-id');
expect(element).toBeDefined();
expect(element.id).toBeDefined();
});

test('should find all elements by testID', async () => {
await render(
<View>
<View testID="this-is-test-id">
<Text>First element</Text>
</View>
<View testID="this-is-test-id">
<Text>Second element</Text>
</View>
</View>
);
const elements = await screen.findAllByTestId('this-is-test-id');
expect(elements).toBeDefined();
expect(Array.isArray(elements)).toBe(true);
expect(elements.length).toBe(2);
});
});
109 changes: 109 additions & 0 deletions apps/playground/src/__tests__/ui/screenshot.harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, test, render, screen, expect } from 'react-native-harness';
import { View, Text } from 'react-native';

describe('Screenshot', () => {
test('should match image snapshot with multiple snapshots', async () => {
await render(
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<View
style={{
width: 100,
height: 100,
backgroundColor: 'blue',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text style={{ color: 'white' }}>Hello, world!</Text>
</View>
</View>
);
const screenshot1 = await screen.screenshot();
await expect(screenshot1).toMatchImageSnapshot({ name: 'blue-square' });

// Change the background color and take another snapshot
await render(
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<View
style={{
width: 100,
height: 100,
backgroundColor: 'red',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text style={{ color: 'white' }}>Hello, world!</Text>
</View>
</View>
);
const screenshot2 = await screen.screenshot();
await expect(screenshot2).toMatchImageSnapshot({ name: 'red-square' });

// Take a third snapshot with different content
await render(
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<View
style={{
width: 100,
height: 100,
backgroundColor: 'green',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text style={{ color: 'white' }}>Goodbye, world!</Text>
</View>
</View>
);
const screenshot3 = await screen.screenshot();
await expect(screenshot3).toMatchImageSnapshot({ name: 'green-square' });
});

test('should match image snapshot with custom options', async () => {
await render(
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<View
style={{
width: 100,
height: 100,
backgroundColor: 'yellow',
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text style={{ color: 'black' }}>Custom options test</Text>
</View>
</View>
);
const screenshot = await screen.screenshot();
await expect(screenshot).toMatchImageSnapshot({
name: 'yellow-square-custom-options',
threshold: 0.05, // More sensitive threshold
diffColor: [0, 255, 0], // Green diff color
});
});

test('should create diff image when test fails', async () => {
await render(
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<View
style={{
width: 100,
height: 100,
backgroundColor: 'purple', // Different color to cause mismatch
justifyContent: 'center',
alignItems: 'center',
}}
>
<Text style={{ color: 'white' }}>This will fail</Text>
</View>
</View>
);
const screenshot = await screen.screenshot();
// This should fail and create a diff image
await expect(screenshot).toMatchImageSnapshot({
name: 'purple-square-will-fail',
});
});
});
5 changes: 5 additions & 0 deletions packages/bridge/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,17 @@
}
},
"dependencies": {
"@react-native-harness/platforms": "workspace:*",
"@react-native-harness/tools": "workspace:*",
"birpc": "^2.4.0",
"pixelmatch": "^7.1.0",
"pngjs": "^7.0.0",
"tslib": "^2.3.0",
"ws": "^8.18.2"
},
"devDependencies": {
"@types/pixelmatch": "^5.2.6",
"@types/pngjs": "^6.0.5",
"@types/ws": "^8.18.1"
},
"license": "MIT"
Expand Down
112 changes: 112 additions & 0 deletions packages/bridge/src/image-snapshot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import pixelmatch from 'pixelmatch';
import fs from 'node:fs/promises';
import path from 'node:path';
import { PNG } from 'pngjs';
import type { FileReference, ImageSnapshotOptions } from './shared.js';

type PixelmatchOptions = Parameters<typeof pixelmatch>[5];

const SNAPSHOT_DIR_NAME = '__image_snapshots__';
const DEFAULT_OPTIONS_FOR_PIXELMATCH: PixelmatchOptions = {
threshold: 0.1,
includeAA: false,
alpha: 0.1,
aaColor: [255, 255, 0],
diffColor: [255, 0, 0],
// @ts-expect-error - this is extracted from the pixelmatch package
diffColorAlt: null,
diffMask: false,
};

export const matchImageSnapshot = async (
screenshot: FileReference,
testFilePath: string,
options: ImageSnapshotOptions,
platformName: string
) => {
const pixelmatchOptions = {
...DEFAULT_OPTIONS_FOR_PIXELMATCH,
...options,
};
const receivedPath = screenshot.path;

try {
await fs.access(receivedPath);
} catch {
throw new Error(`Screenshot file not found at ${receivedPath}`);
}

const receivedBuffer = await fs.readFile(receivedPath);

// Create __image_snapshots__ directory in same directory as test file
const testDir = path.dirname(testFilePath);
const snapshotsDir = path.join(testDir, SNAPSHOT_DIR_NAME, platformName);

const snapshotName = `${options.name}.png`;
const snapshotPath = path.join(snapshotsDir, snapshotName);

await fs.mkdir(snapshotsDir, { recursive: true });

try {
await fs.access(snapshotPath);
} catch {
// First time - create snapshot
await fs.writeFile(snapshotPath, receivedBuffer);
return {
pass: true,
message: `Snapshot created at ${snapshotPath}`,
};
}

const [receivedBufferAgain, snapshotBuffer] = await Promise.all([
fs.readFile(receivedPath),
fs.readFile(snapshotPath),
]);
const img1 = PNG.sync.read(receivedBufferAgain);
const img2 = PNG.sync.read(snapshotBuffer);
const { width, height } = img1;
const diff = new PNG({ width, height });

if (img1.width !== img2.width || img1.height !== img2.height) {
return {
pass: false,
message: `Images have different dimensions. Received image width: ${img1.width}, height: ${img1.height}. Snapshot image width: ${img2.width}, height: ${img2.height}.`,
};
}

// Compare buffers byte by byte
const differences = pixelmatch(
img1.data,
img2.data,
diff.data,
width,
height,
pixelmatchOptions
);

const pass = differences === 0;

// Save diff and actual images when test fails
if (!pass) {
const diffFileName = `${snapshotName.replace('.png', '')}-diff.png`;
const diffPath = path.join(snapshotsDir, diffFileName);
await fs.writeFile(diffPath, PNG.sync.write(diff));

const actualFileName = `${snapshotName.replace('.png', '')}-actual.png`;
const actualPath = path.join(snapshotsDir, actualFileName);
await fs.writeFile(actualPath, receivedBuffer);
}

return {
pass,
message: pass
? 'Images match'
: `Images differ by ${differences} pixels. Diff saved at ${path.join(
snapshotsDir,
snapshotName.replace('.png', '') + '-diff.png'
)}. Actual image saved at ${path.join(
snapshotsDir,
snapshotName.replace('.png', '') + '-actual.png'
)}.`,
};
};
33 changes: 33 additions & 0 deletions packages/bridge/src/platform-bridge.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type {
ElementReference,
HarnessPlatformRunner,
} from '@react-native-harness/platforms';
import type { BridgeServerFunctions } from './shared.js';

export const createPlatformBridgeFunctions = (
platformRunner: HarnessPlatformRunner
): Partial<BridgeServerFunctions> => {
return {
'platform.actions.tap': async (x: number, y: number) => {
await platformRunner.actions.tap(x, y);
},
'platform.actions.inputText': async (text: string) => {
await platformRunner.actions.inputText(text);
},
'platform.actions.tapElement': async (element: ElementReference) => {
await platformRunner.actions.tapElement(element);
},
'platform.actions.screenshot': async () => {
return await platformRunner.actions.screenshot();
},
'platform.queries.getUiHierarchy': async () => {
return await platformRunner.queries.getUiHierarchy();
},
'platform.queries.findByTestId': async (testId: string) => {
return await platformRunner.queries.findByTestId(testId);
},
'platform.queries.findAllByTestId': async (testId: string) => {
return await platformRunner.queries.findAllByTestId(testId);
},
};
};
Loading
Loading