Skip to content
Merged
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
4 changes: 2 additions & 2 deletions apps/playground/ios/Podfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ PODS:
- FBLazyVector (0.82.1)
- fmt (11.0.2)
- glog (0.3.5)
- HarnessUI (1.0.0-alpha.20):
- HarnessUI (1.0.0-alpha.24):
- boost
- DoubleConversion
- fast_float
Expand Down Expand Up @@ -2592,7 +2592,7 @@ SPEC CHECKSUMS:
FBLazyVector: 0aa6183b9afe3c31fc65b5d1eeef1f3c19b63bfa
fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd
glog: 5683914934d5b6e4240e497e0f4a3b42d1854183
HarnessUI: 2957b94c9c4a7e6e54b636229f4aa5e3809936bf
HarnessUI: 9593f42f9c8f68200ccd07a6ed64d02de42637b1
hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5
RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669
RCTDeprecation: f17e2ebc07876ca9ab8eb6e4b0a4e4647497ae3a
Expand Down
1 change: 1 addition & 0 deletions apps/playground/rn-harness.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,5 @@ export default {
resetEnvironmentBetweenTestFiles: true,
unstable__skipAlreadyIncludedModules: false,
forwardClientLogs: true,
disableViewFlattening: true,
};
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.
25 changes: 25 additions & 0 deletions apps/playground/src/__tests__/ui/out-of-bounds.harness.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, test, render, expect } from "react-native-harness";
import { View } from "react-native";
import { screen } from "@react-native-harness/ui";

const COLORS = ['red', 'green', 'blue', 'yellow', 'purple', 'orange', 'pink', 'brown', 'gray', 'black'];

describe('Out of bounds', () => {
test('should screenshot specific element only', async () => {
await render(
<View style={{ flex: 1, justifyContent: 'center', alignItems: 'center' }}>
<View testID="out-of-bounds" style={{ width: 1000, height: 100, backgroundColor: 'yellow', flexDirection: 'row' }}>
{Array.from({ length: 10 }).map((_, index) => (
<View key={index} style={{ width: 100, height: 100, backgroundColor: COLORS[index % COLORS.length] }} />
))}
</View>
</View>
);

const element = await screen.findByTestId('out-of-bounds');
const screenshot = await screen.screenshot(element);
await expect(screenshot).toMatchImageSnapshot({
name: 'out-of-bounds',
});
});
});
3 changes: 2 additions & 1 deletion packages/babel-preset/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@
"babel-plugin-istanbul": "^7.0.1"
},
"peerDependencies": {
"@babel/core": "^7.22.0"
"@babel/core": "^7.22.0",
"@babel/plugin-transform-react-jsx": "*"
},
"devDependencies": {
"@babel/core": "^7.22.0",
Expand Down
7 changes: 7 additions & 0 deletions packages/babel-preset/src/preset.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,13 @@ export const rnHarnessPlugins = [
'@babel/plugin-transform-class-static-block',
resolveWeakPlugin,
getIstanbulPlugin(),
[
'@babel/plugin-transform-react-jsx',
{
runtime: 'automatic',
importSource: '@react-native-harness/runtime',
},
],
].filter((plugin) => plugin !== null);

export const rnHarnessPreset = () => {
Expand Down
11 changes: 10 additions & 1 deletion packages/config/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,15 @@ export const ConfigSchema = z
.min(100, 'Crash detection interval must be at least 100ms')
.default(500),

disableViewFlattening: z
.boolean()
.optional()
.default(false)
.describe(
'Disable view flattening in React Native. This will set collapsable={true} for all View components ' +
'to ensure they are not flattened by the native layout engine.'
),

coverage: z
.object({
root: z
Expand All @@ -56,7 +65,7 @@ export const ConfigSchema = z
'Root directory for coverage instrumentation in monorepo setups. ' +
'Specifies the directory from which coverage data should be collected. ' +
'Use ".." for create-react-native-library projects where tests run from example/ ' +
'but source files are in parent directory. Passed to babel-plugin-istanbul\'s cwd option.'
"but source files are in parent directory. Passed to babel-plugin-istanbul's cwd option."
),
})
.optional(),
Expand Down
4 changes: 4 additions & 0 deletions packages/jest/src/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,10 @@ export const setup = async (globalConfig: JestConfig.GlobalConfig) => {
}
}

if (harnessConfig.disableViewFlattening) {
process.env.RN_HARNESS_VIEW_FLATTENING = 'false';
}

logTestRunHeader(selectedRunner);
const harness = await getHarness(
harnessConfig,
Expand Down
3 changes: 2 additions & 1 deletion packages/metro/src/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import { Config as HarnessConfig } from '@react-native-harness/config';
const getManifestContent = (harnessConfig: HarnessConfig): string => {
return `global.RN_HARNESS = {
appRegistryComponentName: '${harnessConfig.appRegistryComponentName}',
webSocketPort: ${harnessConfig.webSocketPort}
webSocketPort: ${harnessConfig.webSocketPort},
disableViewFlattening: ${harnessConfig.disableViewFlattening},
};`;
};

Expand Down
28 changes: 28 additions & 0 deletions packages/metro/src/resolvers/resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,33 @@ export const createJestGlobalsResolver = (): HarnessResolver => {
};
};

export const createJsxRuntimeResolver = (): HarnessResolver => {
const resolvedJsxRuntimePath = require.resolve(
'@react-native-harness/runtime/jsx-runtime'
);
const resolvedJsxDevRuntimePath = require.resolve(
'@react-native-harness/runtime/jsx-dev-runtime'
);

return (_context, moduleName, _platform) => {
if (moduleName === '@react-native-harness/runtime/jsx-runtime') {
return {
type: 'sourceFile',
filePath: resolvedJsxRuntimePath,
};
}

if (moduleName === '@react-native-harness/runtime/jsx-dev-runtime') {
return {
type: 'sourceFile',
filePath: resolvedJsxDevRuntimePath,
};
}

return null;
};
};

export const getHarnessResolver = (
metroConfig: MetroConfig,
harnessConfig: HarnessConfig
Expand All @@ -71,6 +98,7 @@ export const getHarnessResolver = (
const resolvers: HarnessResolver[] = [
createHarnessEntryPointResolver(harnessConfig),
createJestGlobalsResolver(),
createJsxRuntimeResolver(),
createTsConfigResolver(process.cwd()),
userResolver,
].filter((resolver): resolver is HarnessResolver => !!resolver);
Expand Down
30 changes: 29 additions & 1 deletion packages/platform-web/src/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,37 @@ const getWebRunner = async (
await page.exposeFunction(
'__RN_HARNESS_CAPTURE_SCREENSHOT__',
async (
bounds: { x: number; y: number; width: number; height: number } | null
bounds: {
x: number;
y: number;
width: number;
height: number;
nativeId: string;
} | null
) => {
if (!page) return null;

if (bounds?.nativeId) {
try {
const elementHandle = await page.evaluateHandle((id) => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
return (window as any).__RN_HARNESS_VIEW_REGISTRY__?.get(id);
}, bounds.nativeId);

const element = elementHandle.asElement();
if (element) {
const buffer = await element.screenshot();
return buffer.toString('base64');
}
} catch (e) {
// Fallback to page screenshot if element screenshot fails
console.warn(
`Failed to capture element screenshot for ${bounds.nativeId}, falling back to clip`,
e
);
}
}

const buffer = await page.screenshot({
clip: bounds
? {
Expand Down
10 changes: 10 additions & 0 deletions packages/runtime/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@
"types": "./dist/entry-point.d.ts",
"import": "./dist/entry-point.js",
"default": "./dist/entry-point.js"
},
"./jsx-runtime": {
"development": "./src/jsx/jsx-runtime.ts",
"import": "./dist/jsx/jsx-runtime.js",
"default": "./dist/jsx/jsx-runtime.js"
},
"./jsx-dev-runtime": {
"development": "./src/jsx/jsx-dev-runtime.ts",
"import": "./dist/jsx/jsx-dev-runtime.js",
"default": "./dist/jsx/jsx-dev-runtime.js"
}
},
"peerDependencies": {
Expand Down
1 change: 1 addition & 0 deletions packages/runtime/src/globals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { ImageSnapshotOptions } from '@react-native-harness/bridge';
export type HarnessGlobal = {
appRegistryComponentName: string;
webSocketPort?: number;
disableViewFlattening?: boolean;
};

declare global {
Expand Down
29 changes: 29 additions & 0 deletions packages/runtime/src/jsx/jsx-dev-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import * as ReactJSXRuntimeDev from 'react/jsx-dev-runtime';

export const Fragment = ReactJSXRuntimeDev.Fragment;

export function jsxDEV(
type: any,
props: any,
key: any,
isStaticChildren: any,
source: any,
self: any
) {
if (
type &&
(type.displayName === 'View' || type.name === 'View') &&
props &&
props.collapsable === undefined
) {
props = { ...props, collapsable: true };
}
return ReactJSXRuntimeDev.jsxDEV(
type,
props,
key,
isStaticChildren,
source,
self
);
}
38 changes: 38 additions & 0 deletions packages/runtime/src/jsx/jsx-runtime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { View } from 'react-native';
import * as ReactJSXRuntime from 'react/jsx-runtime';
import { getHarnessGlobal } from '../globals.js';

export const Fragment = ReactJSXRuntime.Fragment;

function wrap(
type: React.ElementType,
props: unknown,
key: React.Key | undefined,
isStatic: boolean,
): React.ReactElement {
const disableViewFlattening = getHarnessGlobal().disableViewFlattening;

if (disableViewFlattening && type === View) {
props = { ...(props as Record<string, unknown>), collapsable: false };
}

return isStatic
? ReactJSXRuntime.jsxs(type, props, key)
: ReactJSXRuntime.jsx(type, props, key);
}

export function jsx(
type: React.ElementType,
props: unknown,
key?: React.Key,
): React.ReactElement {
return wrap(type, props, key, false);
}

export function jsxs(
type: React.ElementType,
props: unknown,
key?: React.Key,
): React.ReactElement {
return wrap(type, props, key, true);
}
36 changes: 25 additions & 11 deletions packages/ui/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ Native UI testing module for React Native Harness that provides view queries and

- **View Queries**: Find elements by testID or accessibility label
- **Touch Simulation**: Simulate user presses and text input
- **Screenshot Capture**: Capture screenshots of the entire screen or specific elements
- **Screenshot Capture**: Capture screenshots of the entire screen, specific elements, or custom regions
- **Debug-Only**: Automatically excluded from release builds, only available in debug builds

## Installation
Expand Down Expand Up @@ -45,6 +45,14 @@ describe('My Component', () => {

// Take screenshots for debugging
const screenshot = await screen.screenshot();

// Or capture a specific region
const regionScreenshot = await screen.screenshot({
x: 0,
y: 0,
width: 100,
height: 100,
});
});
});
```
Expand Down Expand Up @@ -88,11 +96,13 @@ Queries for an element by accessibility label without throwing. Returns null if

Queries for all elements by accessibility label without throwing. Returns an empty array if none found.

#### `screenshot(element?: ElementReference): Promise<ScreenshotResult | null>`
#### `screenshot(target?: ElementReference | BoundingBox): Promise<ScreenshotResult | null>`

Captures a screenshot of the entire app window or a specific element.
Captures a screenshot of the entire app window, a specific element, or a custom region.
Returns a ScreenshotResult with PNG data, or null if capture fails.

> **Warning**: If you are capturing screenshots of elements that extend beyond the screen boundaries (e.g., large scroll views or absolutely positioned views that are partially off-screen), you must disable view flattening in your configuration by setting `disableViewFlattening: true` in your `rn-harness.config.mjs` file.

### `userEvent`

Provides methods to simulate user interactions.
Expand All @@ -110,6 +120,7 @@ Simulates a press at the specified screen coordinates.
Simulates typing text into a text input element. Focuses the element, types each character, and blurs the element.

**TypeOptions:**

- `skipPress?: boolean` - If true, pressIn and pressOut events will not be triggered
- `skipBlur?: boolean` - If true, endEditing and blur events will not be triggered
- `submitEditing?: boolean` - If true, submitEditing event will be triggered after typing
Expand All @@ -118,16 +129,19 @@ Simulates typing text into a text input element. Focuses the element, types each

### `ElementReference`

Represents an element found on screen with its position and dimensions.
An opaque reference to an element found on screen.

### `BoundingBox`

Represents a region on screen.

```typescript
type ElementReference = {
interface BoundingBox {
x: number;
y: number;
width: number;
height: number;
// ... additional view info
};
}
```

### `ScreenshotResult`
Expand All @@ -136,9 +150,9 @@ Screenshot result containing PNG image data.

```typescript
interface ScreenshotResult {
data: Uint8Array; // PNG image data
width: number; // Width in logical pixels
height: number; // Height in logical pixels
data: Uint8Array; // PNG image data
width: number; // Width in logical pixels
height: number; // Height in logical pixels
}
```

Expand All @@ -161,4 +175,4 @@ Like the project? ⚛️ [Join the team](https://callstack.com/careers/?utm_camp
[prs-welcome-badge]: https://img.shields.io/badge/PRs-welcome-brightgreen.svg?style=for-the-badge
[prs-welcome]: ../../CONTRIBUTING.md
[chat-badge]: https://img.shields.io/discord/426714625279524876.svg?style=for-the-badge
[chat]: https://discord.gg/xgGt7KAjxv
[chat]: https://discord.gg/xgGt7KAjxv
Loading