Skip to content
Open
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
17 changes: 16 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ High velocity native mobile development requires us to adopt continuous integrat

The most difficult part of automated testing on mobile is the tip of the testing pyramid - E2E. The core problem with E2E tests is flakiness - tests are usually not deterministic. We believe the only way to tackle flakiness head on is by moving from black box testing to gray box testing. That’s where Detox comes into play.

- **Cross Platform:** Write end-to-end tests in JavaScript for React Native apps (Android & iOS).
- **Cross Platform:** Write end-to-end tests in JavaScript for React Native apps (Android, iOS, and HarmonyOS).
- **Debuggable:** Modern async-await API allows breakpoints in asynchronous tests to work as expected.
- **Automatically Synchronized:** Stops flakiness at the core by monitoring asynchronous operations in your app.
- **Made For CI:** Execute your E2E tests on CI platforms like Travis CI, Circle CI or Jenkins without grief.
Expand All @@ -68,6 +68,21 @@ the _official_ versions compatibility is provided according to the following:

Although we do not officially support older React Native versions, we do our best to keep Detox compatible with them.

### HarmonyOS (OpenHarmony) Support

HarmonyOS support is available as a third-party driver package:

- **Platform:** HarmonyOS 6.1.0+ (API 23+), for [RNOH](https://gitcode.com/CPF-RN/ohos_react_native) apps (React Native on HarmonyOS)
- **Driver:** [`detox-driver-harmony`](detox-driver-harmony/) — uses Detox's External driver mechanism (zero changes to Detox core)
- **UI Engine:** `@kit.TestKit` (HarmonyOS built-in UI testing API)
- **Verified:** 61/61 e2e tests pass on real device (matchers, actions, expectations, waits, device ops, gray-box sync)

```sh
npm install detox-driver-harmony --save-dev
```

See the [driver README](detox-driver-harmony/README.md) for setup and usage.

> In case of a problem with an unsupported version of React Native, please [submit an issue](https://github.com/wix/Detox/issues/new/choose) or write us in our [Discord server](https://discord.gg/CkD5QKheF5) and we will do our best to help out.

### Known Issues with React Native
Expand Down
166 changes: 166 additions & 0 deletions detox-driver-harmony/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
# detox-driver-harmony

HarmonyOS (OpenHarmony) driver for [Detox](https://github.com/wix/Detox) — test RNOH (React Native on HarmonyOS) apps with the same Detox API as iOS and Android.

## What this does

Enables Detox to drive HarmonyOS apps via `@kit.TestKit` (HarmonyOS built-in UI testing API). Test code is 100% identical across platforms:

```js
await element(by.id('login-btn')).tap();
await expect(element(by.text('Welcome'))).toBeVisible();
```

## Architecture

```
Jest (Node.js) ──WS──> DetoxServer ──WS──> DetoxMain (ArkTS, in-app)
├── MethodInvocation (parses Espresso invoke tree)
├── UiDriver (@kit.TestKit Driver/Component/ON)
└── Synchronizer (gray-box idle detection)
```

This package provides the **JS-side driver** (device allocation, runtime driver, HDC wrapper, matchers). The **ArkTS native client** (DetoxMain/UiDriver/Synchronizer) is a separate HAR package installed via ohpm.

## Prerequisites

- **DevEco Studio** (includes HarmonyOS SDK API 23/6.1.0 + `hvigorw` + `ohpm` + `hdc`)
- **Node.js 22 LTS** (not Node 24 — RNOH CLI has a `Dirent.path` bug on Node 24)
- **Detox 20.51+** installed in your project
- A connected HarmonyOS device: `hdc list targets` → `<serial>`

## Installation

### 1. Install this driver package

```sh
npm install detox-driver-harmony --save-dev
```

### 2. Install the ArkTS native client (HAR)

Build the HAR from the [detox-openharmony](https://gitcode.com/react-native/detox/tree/main/detox/openHarmony) source, then add it to your app's `oh-package.json5`:

```json5
{
"dependencies": {
"@wix/detox-openharmony": "file:path/to/detox_harmony.har"
}
}
```

### 3. Configure Detox

In your `.detoxrc.js`:

```js
module.exports = {
configurations: {
'harmony.debug': {
device: {
type: 'detox-driver-harmony', // ← this package
device: {
hdcName: process.env.HARMONY_DEVICE_SN, // device serial from `hdc list targets`
},
},
app: 'harmony.debug',
},
},
apps: {
'harmony.debug': {
type: 'harmony.app',
binaryPath: 'harmony/entry/build/default/outputs/default/entry-default-signed.hap',
testBinaryPath: 'harmony/entry/build/default/outputs/ohosTest/entry-ohosTest-signed.hap',
bundleId: 'com.example.myapp', // your app's bundleName
},
},
};
```

### 4. Wire DetoxMain in your app's ohosTest module

In `entry/src/ohosTest/ets/test/List.test.ets`:

```ts
import { DetoxMain } from '@wix/detox-openharmony';

export default function testsuite() {
const detoxMain = new DetoxMain();
// ... extract DetoxServer/DetoxSessionId from launch args
await detoxMain.run(params);
}
```

See the [demo app](https://gitcode.com/react-native/detox/tree/main/examples/demo-react-native-harmony) for a complete example.

## Usage

```sh
# Set device serial
export HARMONY_DEVICE_SN=<your-device-serial>

# Run tests
detox test --configuration harmony.debug
```

## Environment setup (PowerShell)

If `hvigorw`/`ohpm`/`hdc` are not on your PATH:

```powershell
$env:JAVA_HOME = "C:\Program Files\Huawei\DevEco Studio\jbr"
$env:PATH = "C:\Program Files\Huawei\DevEco Studio\tools\hvigor\bin;C:\Program Files\Huawei\DevEco Studio\tools\ohpm\bin;$env:JAVA_HOME\bin;$env:PATH"
$env:DEVECO_SDK_HOME = "C:\Program Files\Huawei\DevEco Studio\sdk"
```

## Building HAPs

```sh
# Bundle RN JS (Hermes)
cd your-app
npx react-native bundle-harmony --dev=false --js-engine=hermes --hermesc-dir ./node_modules/hermes-compiler/hermesc

# Build HAPs
cd harmony
ohpm install
hvigorw assembleHap --mode module -p product=default -p module=entry@default -p buildMode=debug
hvigorw assembleHap --mode module -p product=default -p module=entry@ohosTest -p buildMode=debug
```

## Signing

HarmonyOS requires signed HAPs for device installation. Generate signing material:

- **DevEco Studio**: File → Project Structure → Signing Configs → "Automatically generate signature"
- **CLI**: `devecocli auth login && devecocli signature generate --product default`

## API coverage

| Category | Status | Notes |
|---|---|---|
| Selectors (`by.id/text/label/type`) | ✅ | Via `ON.id/text/description/type` |
| Actions (`tap/multiTap/longPress/typeText/clearText/replaceText/scroll/scrollTo/swipe`) | ✅ | Via `Component.click/inputText/clearText` + `Driver.swipe` |
| Assertions (`toBeVisible/toExist/toHaveText/toHaveLabel/toHaveId`) | ✅ | Via `findComponent` + bounds/text check |
| Waits (`waitFor/withTimeout/whileElement scroll`) | ✅ | Via `Driver.waitForComponent` |
| Device ops (`launchApp/terminateApp/pressBack/screenshot/orientation/reloadReactNative`) | ✅ | Via `hdc shell aa start/force-stop` + `uitest uiInput` |
| Advanced (`getAttributes/takeScreenshot/setOrientation/sendToHome`) | ✅ | Via `Component.getBounds` + `Driver.screenCap` |
| Gray-box sync | ✅ | Layout hash stability + `Driver.waitForIdle` + AppProbe (network/timer) |

Verified: **61/61 e2e tests pass** on HarmonyOS 6.1.0 (API 23) real device.

## How it works

This package exports three classes that Detox loads via its External driver mechanism:

| Export | Role |
|---|---|
| `DeviceAllocationDriverClass` | Finds free HarmonyOS device via `hdc list targets` |
| `RuntimeDriverClass` | Installs/launches/terminates app via `hdc install`/`aa test`/`aa force-stop`, sets up reverse port forwarding (`hdc rport`) |
| `ExpectClass` | Provides `element`/`expect`/`by`/`waitFor` API (reuses Android's `AndroidExpect` — same FQCN namespace) |

The driver reuses Android's `com.wix.detox.espresso.*` FQCN namespace, so the JS-side invoke protocol is identical. The ArkTS native client interprets these FQCNs and translates them to `@kit.TestKit` API calls.

## License

MIT
9 changes: 9 additions & 0 deletions detox-driver-harmony/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
const HarmonyExpect = require('./src/HarmonyExpect');
const HarmonyAttachedAllocDriver = require('./src/HarmonyAllocDriver');
const HarmonyRuntimeDriver = require('./src/HarmonyRuntimeDriver');

module.exports = {
DeviceAllocationDriverClass: HarmonyAttachedAllocDriver,
RuntimeDriverClass: HarmonyRuntimeDriver,
ExpectClass: HarmonyExpect,
};
33 changes: 33 additions & 0 deletions detox-driver-harmony/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
{
"name": "detox-driver-harmony",
"version": "0.1.0-beta.1",
"description": "HarmonyOS (OpenHarmony) driver for Detox — test RNOH apps with the same Detox API as iOS/Android",
"main": "index.js",
"scripts": {
"test": "echo \"no tests yet\""
},
"peerDependencies": {
"detox": "^20.51.0"
},
"dependencies": {
"fs-extra": "^11.0.0"
},
"license": "MIT",
"repository": "https://github.com/RuiYangLian/Detox",
"keywords": [
"detox",
"harmonyos",
"openharmony",
"react-native",
"rnoh",
"e2e",
"testing"
],
"eslintConfig": {
"root": true,
"env": { "node": true, "es2022": true },
"parserOptions": { "ecmaVersion": 2022, "sourceType": "commonjs" },
"extends": ["eslint:recommended"],
"rules": {}
}
}
77 changes: 77 additions & 0 deletions detox-driver-harmony/src/HDC.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
const { execWithRetriesAndLogs } = require('detox/src/utils/childProcess/exec');

const DEFAULT_HDC_BIN = 'hdc';

class HDC {
constructor(binary = DEFAULT_HDC_BIN) {
this.binary = binary;
}

async listTargets() {
const { stdout } = await execWithRetriesAndLogs(`${this.binary} list targets`);
return stdout
.split('\n')
.map((l) => l.trim())
.filter((l) => l && l !== '[Empty]' && !l.toLowerCase().startsWith('no'));
}

async install(serial, hapPath) {
return execWithRetriesAndLogs(`${this.binary} -t ${serial} install "${hapPath}"`);
}

async uninstall(serial, bundleId) {
return execWithRetriesAndLogs(`${this.binary} -t ${serial} uninstall ${bundleId}`, {
retries: 1,
});
}

async shell(serial, cmd) {
const { stdout } = await execWithRetriesAndLogs(`${this.binary} -t ${serial} shell ${cmd}`);
return stdout;
}

async startAbility(serial, bundleId, abilityName, params = []) {
const psArgs = params.map(([k, v]) => `--ps ${k} ${v}`).join(' ');
return execWithRetriesAndLogs(
`${this.binary} -t ${serial} shell aa start -a ${abilityName} -b ${bundleId} ${psArgs}`,
);
}

async startTestAbility(serial, bundleId, moduleName = 'entry_test', testRunner = 'OpenHarmonyTestRunner', params = [], waitSeconds = 300) {
const sArgs = params.map(([k, v]) => `-s ${k} ${v}`).join(' ');
return execWithRetriesAndLogs(
`${this.binary} -t ${serial} shell aa test -b ${bundleId} -m ${moduleName} -s unittest ${testRunner} ${sArgs} -s timeout ${waitSeconds * 1000} -w ${waitSeconds}`,
{ retries: 0, timeout: waitSeconds * 1000 + 30000 },
);
}

async stopAbility(serial, bundleId) {
return execWithRetriesAndLogs(`${this.binary} -t ${serial} shell aa force-stop ${bundleId}`);
}

async fport(serial, localPort, remotePort) {
return execWithRetriesAndLogs(
`${this.binary} -t ${serial} fport tcp:${localPort} tcp:${remotePort}`,
);
}

async rport(serial, remotePort, localPort) {
return execWithRetriesAndLogs(
`${this.binary} -t ${serial} rport tcp:${remotePort} tcp:${localPort}`,
);
}

async fportRm(serial, localPort, remotePort) {
return execWithRetriesAndLogs(
`${this.binary} -t ${serial} fport rm tcp:${localPort} tcp:${remotePort}`,
);
}

async getFile(serial, remotePath, localPath) {
return execWithRetriesAndLogs(
`${this.binary} -t ${serial} file recv ${remotePath} ${localPath}`,
);
}
}

module.exports = HDC;
66 changes: 66 additions & 0 deletions detox-driver-harmony/src/HarmonyAllocDriver.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
const log = require('detox/src/utils/logger').child({ cat: 'device,device-allocation' });

const DEVICE_LOOKUP = { event: 'HARMONY_DEVICE_LOOKUP' };

class FreeHarmonyDeviceFinder {
constructor(hdc, deviceRegistry) {
this.hdc = hdc;
this.deviceRegistry = deviceRegistry;
}

async findFreeDevice(deviceQuery) {
const targets = await this.hdc.listTargets();
const takenDevices = this.deviceRegistry.getTakenDevicesSync();

for (const candidate of targets) {
if (takenDevices.includes(candidate)) {
log.debug(DEVICE_LOOKUP, `Device ${candidate} is already taken, skipping...`);
continue;
}
if (deviceQuery && !new RegExp(deviceQuery).test(candidate)) {
log.debug(DEVICE_LOOKUP, `Device ${candidate} does not match "${deviceQuery}"`);
continue;
}
log.debug(DEVICE_LOOKUP, `Found a matching & free device ${candidate}`);
return candidate;
}
return null;
}
}

class HarmonyAttachedAllocDriver {
constructor({ detoxSession }) {
const HDC = require('./HDC');
const DeviceRegistry = require('detox/src/devices/allocation/DeviceRegistry');

this._hdc = new HDC();
this._deviceRegistry = new DeviceRegistry({ sessionId: detoxSession.id });
this._freeDeviceFinder = new FreeHarmonyDeviceFinder(this._hdc, this._deviceRegistry);
}

async init() {
await this._deviceRegistry.unregisterZombieDevices();
}

async allocate(deviceConfig) {
const hdcNameQuery = deviceConfig.device.hdcName;
const hdcName = await this._deviceRegistry.registerDevice(
() => this._freeDeviceFinder.findFreeDevice(hdcNameQuery),
);

if (!hdcName) {
const DetoxRuntimeError = require('detox/src/errors/DetoxRuntimeError');
throw new DetoxRuntimeError({
message: `No free HarmonyOS device matching "${hdcNameQuery}". Run \`hdc list targets\` to verify connectivity.`,
});
}

return { id: hdcName, hdcName, name: hdcName };
}

async free(cookie) {
await this._deviceRegistry.unregisterDevice(cookie.hdcName);
}
}

module.exports = HarmonyAttachedAllocDriver;
5 changes: 5 additions & 0 deletions detox-driver-harmony/src/HarmonyExpect.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
const AndroidExpect = require('detox/src/android/AndroidExpect');

class HarmonyExpect extends AndroidExpect {}

module.exports = HarmonyExpect;
Loading