docs: add internal architecture documentation (#4909)

This commit is contained in:
Yedidya Kennard
2026-03-11 14:54:04 +02:00
committed by GitHub
parent d523063e96
commit c4ff4e4433
4 changed files with 1678 additions and 0 deletions
+344
View File
@@ -0,0 +1,344 @@
# Detox Architecture
This document provides an overview of Detox's internal architecture for contributors and maintainers.
## Overview
Detox is a **gray-box end-to-end testing framework** for React Native mobile applications. Unlike black-box testing frameworks, Detox has visibility into the app's internal state, enabling automatic synchronization between tests and the app.
```text
┌───────────────────────────────────────────────────────────┐
│ Test Environment (Node.js) │
│ │
│ ┌─────────────┐ ┌─────────────┐ ┌───────────────────┐ │
│ │ Test Runner │ │DetoxContext │ │ ArtifactsManager │ │
│ │(Jest/Mocha) │ │ (Realms) │ │ │ │
│ └──────┬──────┘ └──────┬──────┘ └─────────┬─────────┘ │
│ │ │ │ │
│ └────────────────┼───────────────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ DetoxWorker │ │
│ │ - device │ │
│ │ - element/by │ │
│ │ - expect │ │
│ └─────────┬─────────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Client │ │
│ │ (WebSocket) │ │
│ └─────────┬─────────┘ │
└──────────────────────────┼────────────────────────────────┘
│
┌─────────▼─────────┐
│ DetoxServer │
│ (WebSocket) │
│ localhost:port │
└─────────┬─────────┘
│
┌──────────────────────────┼────────────────────────────────┐
│ Mobile Device/Simulator │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ Native Client │ │
│ │ (iOS/Android) │ │
│ │ │ │
│ │ - Synchronizer │ │
│ │ - Matchers │ │
│ │ - Actions │ │
│ └───────────────────┘ │
└───────────────────────────────────────────────────────────┘
```
## Core Components
### 1. Realms (`src/realms/`)
Detox uses a "realm" pattern to manage different execution contexts:
| Realm | Class | Purpose |
|-------|-------|---------|
| **Primary** | `DetoxPrimaryContext` | Full-featured context with device allocation, used in test runner process |
| **Secondary** | `DetoxSecondaryContext` | Lightweight config-snapshot context for worker processes |
**Key files:**
- `DetoxContext.js` - Base class exposing `device`, `element`, `expect`, `by`, `waitFor`, `web`, `system`
- `DetoxPrimaryContext.js` - Handles initialization, device allocation, server lifecycle
- `DetoxSecondaryContext.js` - Receives config snapshots, no device management
The base `DetoxContext` uses **funpermaproxy** to lazily delegate API calls to the worker:
```javascript
device = funpermaproxy(() => this[symbols.worker].device);
element = funpermaproxy.callable(() => this[symbols.worker].element);
```
### 2. Worker (`src/DetoxWorker.js`)
Each test worker creates a `DetoxWorker` instance that:
- Connects to the Detox server via `Client`
- Creates the `InvocationManager` for command serialization
- Instantiates the `RuntimeDevice` via environment factories
- Creates matchers (`by`, `element`, `expect`, `waitFor`)
- Manages the `ArtifactsManager`
**Lifecycle hooks:**
- `onRunDescribeStart` / `onRunDescribeFinish`
- `onTestStart` / `onTestDone`
- `onHookFailure` / `onTestFnFailure`
### 3. Client-Server Communication (`src/client/`, `src/server/`)
See [docs/architecture/client-server.md](docs/architecture/client-server.md) for details.
**Key components:**
- `Client` - WebSocket client connecting to server, sends actions to app
- `AsyncWebSocket` - Promise-based WebSocket wrapper
- `DetoxServer` - WebSocket server mediating tester ↔ app communication
- `DetoxSession` / `DetoxSessionManager` - Per-session state management
### 4. Device Management (`src/devices/`)
See [docs/architecture/devices.md](docs/architecture/devices.md) for details.
**Structure:**
```text
devices/
├── allocation/ # Device allocation strategies
│ ├── DeviceAllocator.js
│ ├── DeviceRegistry.js
│ └── drivers/
│ ├── ios/ # SimulatorAllocDriver
│ └── android/ # Emulator, Attached, Genycloud
├── runtime/ # Runtime device abstractions
│ ├── RuntimeDevice.js
│ └── drivers/
│ ├── ios/ # IosDriver, SimulatorDriver
│ └── android/ # EmulatorDriver, AttachedAndroidDriver, GenyCloudDriver
├── cookies/ # Device state serialization
└── validation/ # Environment validators
```
### 5. Artifacts (`src/artifacts/`)
See [docs/architecture/artifacts.md](docs/architecture/artifacts.md) for details.
**Plugins:**
- `screenshot` - Captures screenshots on failure or on-demand
- `video` - Records test execution video
- `log` - Aggregates device and app logs
- `instruments` - iOS performance profiling
- `uiHierarchy` - Dumps view hierarchy for debugging (iOS only)
### 6. Invocation System (`src/invoke.js`, `src/client/actions/`)
Commands are serialized as JSON and sent to the native app:
```javascript
// Test code
await element(by.id('button')).tap();
// Serialized to invocation
{
type: 'invoke',
params: {
target: { type: 'Class', value: 'com.wix.detox.Detox' },
method: 'perform',
args: [{ /* matcher */ }, { /* action */ }]
}
}
```
The `InvocationManager` handles serialization/deserialization and batching.
### 7. Matchers & Expectations (`src/ios/`, `src/android/`)
Platform-specific implementations:
| Component | iOS | Android |
|-----------|-----|---------|
| Matchers | `src/matchers/` | `src/android/matchers/` |
| Actions | `src/ios/` | `src/android/actions/` |
| Expectations | `src/ios/expectTwo.js` | `src/android/AndroidExpect.js` |
Note: Shared matcher factory logic is in `src/matchers/`. iOS-specific expectations and test runner code are in `src/ios/`.
### 8. Test Runner Integration (`runners/`)
```text
runners/
├── jest/
│ ├── testEnvironment/ # Jest environment setup
│ ├── reporters/ # Custom reporters
│ ├── globalSetup.js
│ └── globalTeardown.js
├── jest-circus/ # Circus event handling
└── mocha/ # Mocha adapter
```
## Data Flow
### Test Execution Flow
```text
1. Jest runs test file
│
▼
2. DetoxEnvironment.setup()
└── detox.init()
│
▼
3. DetoxPrimaryContext initializes
├── Starts DetoxServer
├── Allocates device
└── Installs worker
│
▼
4. DetoxWorker.init()
├── Connects Client to server
├── Creates RuntimeDevice
├── Creates matchers
└── Installs app (if configured)
│
▼
5. Test executes
├── element(by.id('x')).tap()
│ └── Client.execute(invocation)
│ └── Server relays to app
│ └── App executes action
│ └── App responds
│ └── Client resolves
└── expect(element).toBeVisible()
│
▼
6. DetoxEnvironment.teardown()
└── detox.cleanup()
│
▼
7. Artifacts collected, device deallocated
```
### Synchronization Flow
```text
Test sends action
│
▼
Native client receives
│
▼
Synchronizer checks app state:
├── Pending network requests?
├── Main thread busy?
├── Animations running?
├── React Native bridge busy?
└── JavaScript thread idle?
│
▼
When idle: Execute action
│
▼
Return result to test
```
## Key Design Patterns
### 1. Symbol-Based Privacy
Internal members use Symbols to prevent accidental access:
```javascript
const $worker = Symbol('worker');
class DetoxContext {
[$worker] = null;
}
```
### 2. Factory Pattern
Environment-specific factories create platform-appropriate instances:
```javascript
const factories = environmentFactory.createFactories(deviceConfig);
// Returns: envValidatorFactory, artifactsManagerFactory,
// matchersFactory, runtimeDeviceFactory
```
### 3. Cancellable Async Flow (CAF)
Long-running operations use CAF for cancellation:
```javascript
this._reinstallAppsOnDevice = CAF(this._reinstallAppsOnDevice.bind(this));
// Can be cancelled via: this._initToken.abort('CLEANUP')
```
### 4. Event-Driven Architecture
`ArtifactsManager` and `RuntimeDevice` use event emitters for loose coupling:
```javascript
deviceEmitter.on('bootDevice', this.onBootDevice.bind(this));
deviceEmitter.on('launchApp', this.onLaunchApp.bind(this));
```
## Configuration
Configuration flows through composition:
```text
.detoxrc.js / detox.config.js
│
▼
loadExternalConfig()
│
▼
CLI arguments (--configuration, --device-name, etc.)
│
▼
composeDetoxConfig()
├── composeAppsConfig()
├── composeDeviceConfig()
├── composeArtifactsConfig()
├── composeBehaviorConfig()
└── composeSessionConfig()
│
▼
RuntimeConfig
```
## Directory Structure
```text
detox/
├── src/
│ ├── realms/ # Context management
│ ├── client/ # WebSocket client
│ ├── server/ # WebSocket server
│ ├── devices/ # Device management
│ ├── artifacts/ # Artifact collection
│ ├── configuration/ # Config composition
│ ├── ios/ # iOS-specific code
│ ├── android/ # Android-specific code
│ ├── matchers/ # Matcher factories
│ ├── invoke.js # Invocation manager
│ └── DetoxWorker.js # Per-test worker
├── runners/ # Test runner integrations
├── local-cli/ # CLI commands
├── ios/ # Native iOS SDK
└── android/ # Native Android SDK
```
## See Also
- [docs/architecture/client-server.md](docs/architecture/client-server.md) - Client-server protocol details
- [docs/architecture/devices.md](docs/architecture/devices.md) - Device management internals
- [docs/architecture/artifacts.md](docs/architecture/artifacts.md) - Artifact collection system
- [docs/articles/how-detox-works.md](docs/articles/how-detox-works.md) - User-facing explanation
- [docs/articles/design-principles.md](docs/articles/design-principles.md) - Design philosophy
+560
View File
@@ -0,0 +1,560 @@
# Artifacts System
The artifacts subsystem collects test artifacts like screenshots, videos, logs, and performance data during test execution.
## Architecture Overview
```text
┌─────────────────────────────────────────────────────────────┐
│ Artifacts Subsystem │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ ArtifactsManager │ │
│ │ │ │
│ │ - Registers plugins │ │
│ │ - Subscribes to device events │ │
│ │ - Orchestrates lifecycle callbacks │ │
│ │ - Manages idle callback queue │ │
│ └────────────────────────┬──────────────────────────────┘ │
│ │ │
│ ┌───────────────┼───────────────┐ │
│ │ │ │ │
│ ┌─────▼─────┐ ┌─────▼─────┐ ┌─────▼─────┐ │
│ │Screenshot │ │ Video │ │ Log │ │
│ │ Plugin │ │ Plugin │ │ Plugin │ │
│ └───────────┘ └───────────┘ └───────────┘ │
│ │
│ ┌───────────┐ ┌───────────┐ │
│ │Instruments│ │UIHierarchy│ │
│ │ Plugin │ │ (iOS) │ │
│ └───────────┘ └───────────┘ │
│ │
│ ┌───────────────────────────────────────────────────────┐ │
│ │ ArtifactPathBuilder │ │
│ │ Generates paths: artifacts/{config}/{test}/artifact │ │
│ └───────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Directory Structure
```text
src/artifacts/
├── ArtifactsManager.js # Main orchestrator
├── providers/
│ └── index.js # Exports: AndroidArtifactPluginsProvider,
│ # IosArtifactPluginsProvider,
│ # IosSimulatorArtifactPluginsProvider
├── templates/
│ ├── artifact/
│ │ ├── Artifact.js # Base artifact class
│ │ └── FileArtifact.js # File-based artifact
│ └── plugin/
│ ├── ArtifactPlugin.js # Base plugin class
│ ├── StartupAndTestRecorderPlugin.js
│ ├── TwoSnapshotsPerTestPlugin.js
│ └── WholeTestRecorderPlugin.js
├── screenshot/
│ ├── ScreenshotArtifactPlugin.js
│ ├── SimulatorScreenshotPlugin.js # iOS
│ └── ADBScreencapPlugin.js # Android
├── video/
│ ├── VideoArtifactPlugin.js
│ ├── SimulatorRecordVideoPlugin.js # iOS
│ └── ADBScreenrecorderPlugin.js # Android
├── log/
│ ├── LogArtifactPlugin.js
│ ├── ios/
│ │ └── SimulatorLogPlugin.js
│ └── android/
│ └── ADBLogcatPlugin.js
├── instruments/
│ ├── InstrumentsArtifactPlugin.js
│ ├── ios/
│ │ └── SimulatorInstrumentsPlugin.js
│ └── android/
│ └── AndroidInstrumentsPlugin.js
├── uiHierarchy/
│ └── IosUIHierarchyPlugin.js # iOS only
└── utils/
├── ArtifactPathBuilder.js
└── temporaryPath.js
```
## ArtifactsManager
### Initialization
```javascript
class ArtifactsManager extends EventEmitter {
constructor({ rootDir, pathBuilder, plugins }) {
this._pluginConfigs = plugins;
this._artifactPlugins = {};
this._pathBuilder = new ArtifactPathBuilder({ rootDir });
this._idlePromise = Promise.resolve();
}
registerArtifactPlugins(artifactPluginFactoriesMap) {
for (const [key, factory] of Object.entries(artifactPluginFactoriesMap)) {
const config = this._pluginConfigs[key];
this._artifactPlugins[key] = this._instantiateArtifactPlugin(factory, config);
}
}
}
```
### Plugin Instantiation
Each plugin receives an API object for interacting with the manager:
```javascript
_instantiateArtifactPlugin(pluginFactory, pluginUserConfig) {
const artifactsApi = {
plugin: null,
userConfig: { ...pluginUserConfig },
preparePathForArtifact: async (artifactName, testSummary) => {
const artifactPath = this._pathBuilder.buildPathForTestArtifact(artifactName, testSummary);
const artifactDir = path.dirname(artifactPath);
await fs.ensureDir(artifactDir);
return artifactPath;
},
trackArtifact: (artifact) => this.emit('trackArtifact', artifact),
untrackArtifact: (artifact) => this.emit('untrackArtifact', artifact),
requestIdleCallback: (callback) => {
// Queue for execution during idle time
this._idleCallbackRequests.push({ caller: artifactsApi.plugin, callback });
return this._idlePromise;
},
};
const plugin = pluginFactory(artifactsApi);
artifactsApi.plugin = plugin;
return plugin;
}
```
### Device Event Subscription
```javascript
subscribeToDeviceEvents(deviceEmitter) {
deviceEmitter.on('bootDevice', this.onBootDevice.bind(this));
deviceEmitter.on('beforeShutdownDevice', this.onBeforeShutdownDevice.bind(this));
deviceEmitter.on('shutdownDevice', this.onShutdownDevice.bind(this));
deviceEmitter.on('beforeLaunchApp', this.onBeforeLaunchApp.bind(this));
deviceEmitter.on('launchApp', this.onLaunchApp.bind(this));
deviceEmitter.on('appReady', this.onAppReady.bind(this));
deviceEmitter.on('beforeUninstallApp', this.onBeforeUninstallApp.bind(this));
deviceEmitter.on('beforeTerminateApp', this.onBeforeTerminateApp.bind(this));
deviceEmitter.on('terminateApp', this.onTerminateApp.bind(this));
deviceEmitter.on('createExternalArtifact', this.onCreateExternalArtifact.bind(this));
}
```
### Lifecycle Events
```javascript
// Test lifecycle
async onRunDescribeStart(suite) {
await this._callPlugins('ascending', 'onRunDescribeStart', suite);
}
async onTestStart(testSummary) {
await this._callPlugins('ascending', 'onTestStart', testSummary);
}
async onTestDone(testSummary) {
await this._callPlugins('descending', 'onTestDone', testSummary);
}
async onRunDescribeFinish(suite) {
await this._callPlugins('descending', 'onRunDescribeFinish', suite);
}
// Failure hooks
async onHookFailure(testSummary) {
await this._callPlugins('plain', 'onHookFailure', testSummary);
}
async onTestFnFailure(testSummary) {
await this._callPlugins('plain', 'onTestFnFailure', testSummary);
}
```
### Plugin Execution Strategy
Plugins can be called in different orders based on priority:
```javascript
async _callPlugins(strategy, methodName, ...args) {
for (const pluginGroup of this._groupPlugins(strategy)) {
await Promise.all(pluginGroup.map(async (plugin) => {
try {
await plugin[methodName](...args);
} catch (e) {
this._unhandledPluginExceptionHandler(e, { plugin, methodName });
}
}));
}
}
_groupPlugins(strategy) {
if (strategy === 'plain') {
return [_.values(this._artifactPlugins)];
}
const byPriority = _.chain(this._artifactPlugins)
.values()
.groupBy('priority')
.entries()
.sortBy(([priority]) => Number(priority))
.map(1)
.value();
return strategy === 'descending' ? byPriority.reverse() : byPriority;
}
```
## Plugin Types
### Base Plugin (`src/artifacts/templates/plugin/ArtifactPlugin.js`)
```javascript
class ArtifactPlugin {
constructor({ api }) {
this.api = api;
this.enabled = api.userConfig.enabled !== false;
this.keepOnlyFailedTestsArtifacts = api.userConfig.keepOnlyFailedTestsArtifacts;
}
// Lifecycle hooks (override in subclasses)
async onBootDevice(deviceInfo) {}
async onBeforeLaunchApp(appLaunchInfo) {}
async onLaunchApp(appLaunchInfo) {}
async onTestStart(testSummary) {}
async onTestDone(testSummary) {}
async onBeforeCleanup() {}
}
```
### Screenshot Plugin
Captures screenshots on test failure or on-demand:
```javascript
class ScreenshotArtifactPlugin extends TwoSnapshotsPerTestPlugin {
async preparePathForSnapshot(testSummary, name) {
return this.api.preparePathForArtifact(`${name}.png`, testSummary);
}
async takeSnapshot(name) {
// Delegate to device driver
return this.driver.takeScreenshot(name);
}
}
```
Configuration:
```javascript
{
artifacts: {
plugins: {
screenshot: {
enabled: true,
shouldTakeAutomaticSnapshots: true,
keepOnlyFailedTestsArtifacts: true,
}
}
}
}
```
### Video Plugin
Records test execution:
```javascript
class VideoArtifactPlugin extends WholeTestRecorderPlugin {
async onTestStart(testSummary) {
await this.startRecording();
}
async onTestDone(testSummary) {
await this.stopRecording();
if (testSummary.status === 'passed' && this.keepOnlyFailedTestsArtifacts) {
await this.discardRecording();
} else {
await this.saveRecording(testSummary);
}
}
}
```
### Log Plugin
Aggregates device and app logs:
```javascript
class LogArtifactPlugin extends StartupAndTestRecorderPlugin {
async onLaunchApp(appLaunchInfo) {
await this.startCapturingLogs(appLaunchInfo);
}
async onTestDone(testSummary) {
const logs = await this.stopCapturingLogs();
await this.saveLogs(logs, testSummary);
}
}
```
### Instruments Plugin (iOS)
Records performance profiles:
```javascript
class InstrumentsArtifactPlugin extends ArtifactPlugin {
async startRecording({ recordingPath, samplingInterval }) {
await this.client.startInstrumentsRecording({ recordingPath, samplingInterval });
}
async stopRecording() {
await this.client.stopInstrumentsRecording();
}
}
```
### UI Hierarchy Plugin (iOS)
Captures view hierarchy for debugging (iOS only via `IosUIHierarchyPlugin`):
```javascript
class IosUIHierarchyPlugin extends ArtifactPlugin {
async onTestFnFailure(testSummary) {
const hierarchy = await this.client.captureViewHierarchy();
await this.saveHierarchy(hierarchy, testSummary);
}
}
```
## Platform-Specific Implementations
Each artifact type has platform-specific implementations:
| Artifact | iOS (Simulator) | Android |
|----------|-----------------|---------|
| Screenshot | `SimulatorScreenshotPlugin` | `ADBScreencapPlugin` |
| Video | `SimulatorRecordVideoPlugin` | `ADBScreenrecorderPlugin` |
| Log | `SimulatorLogPlugin` | `ADBLogcatPlugin` |
| Instruments | `SimulatorInstrumentsPlugin` | `AndroidInstrumentsPlugin` |
| UI Hierarchy | `IosUIHierarchyPlugin` | Not available |
The appropriate plugin is selected by the provider based on the device type:
- `IosSimulatorArtifactPluginsProvider` for iOS simulators
- `AndroidArtifactPluginsProvider` for Android devices
## Artifact Templates
### Two Snapshots Per Test
Takes snapshots at test start and end:
```javascript
class TwoSnapshotsPerTestPlugin extends ArtifactPlugin {
async onTestStart(testSummary) {
if (this.shouldTakeAutomaticSnapshots) {
await this.takeSnapshot('testStart');
}
}
async onTestDone(testSummary) {
if (this.shouldTakeAutomaticSnapshots) {
await this.takeSnapshot('testDone');
}
}
}
```
### Whole Test Recorder
Records throughout entire test:
```javascript
class WholeTestRecorderPlugin extends ArtifactPlugin {
async onTestStart(testSummary) {
await this.startRecording(testSummary);
}
async onTestDone(testSummary) {
await this.stopRecording();
await this.saveOrDiscard(testSummary);
}
}
```
## Path Building
### ArtifactPathBuilder
Generates consistent artifact paths:
```javascript
class ArtifactPathBuilder {
constructor({ rootDir }) {
this._rootDir = rootDir;
}
buildPathForTestArtifact(artifactName, testSummary) {
// Path format: rootDir/{statusSign}{fullName}({invocations})/{artifactName}
// Example: artifacts/✓ Login should work/screenshot.png
// Example: artifacts/✗ Login should work (2)/screenshot.png (on retry)
const testDir = this._constructDirectoryNameForCurrentRunningTest(testSummary);
return path.join(this._rootDir, testDir, artifactName);
}
_constructDirectoryNameForCurrentRunningTest(testSummary) {
const prefix = this._buildTestDirectoryPrefix(testSummary);
const suffix = testSummary.invocations > 1 ? ` (${testSummary.invocations})` : '';
return constructSafeFilename(prefix, testSummary.fullName, suffix);
}
_buildTestDirectoryPrefix(testSummary) {
return testSummary.status === 'passed' ? '✓ ' : '✗ ';
}
}
```
Custom path builders can be provided:
```javascript
// detox.config.js
module.exports = {
artifacts: {
rootDir: 'e2e-artifacts',
pathBuilder: './my-path-builder.js',
}
};
```
## Configuration
```javascript
module.exports = {
artifacts: {
rootDir: 'artifacts',
pathBuilder: undefined, // or custom path builder
plugins: {
screenshot: {
enabled: true,
shouldTakeAutomaticSnapshots: true,
keepOnlyFailedTestsArtifacts: true,
},
video: {
enabled: true,
keepOnlyFailedTestsArtifacts: true,
},
log: {
enabled: true,
keepOnlyFailedTestsArtifacts: false,
},
instruments: {
enabled: false,
},
uiHierarchy: { // iOS only
enabled: true,
keepOnlyFailedTestsArtifacts: true,
},
},
},
};
```
## Lifecycle Flow
```text
Test Suite Starts
│
▼
onRunDescribeStart(suite)
│
▼
┌────────────────────────────────┐
│ For each test: │
│ │
│ onTestStart(testSummary) │
│ │ │
│ ▼ │
│ [Test executes] │
│ │ │
│ ┌────┴────┐ │
│ │ Failure │ │
│ └────┬────┘ │
│ │ │
│ onHookFailure() or │
│ onTestFnFailure() │
│ │ │
│ ▼ │
│ onTestDone(testSummary) │
│ │ │
│ [Save or discard artifacts] │
│ │
└────────────────────────────────┘
│
▼
onRunDescribeFinish(suite)
│
▼
onBeforeCleanup()
│
▼
[Final artifact processing]
```
## Idle Callbacks
Plugins can defer non-critical work:
```javascript
// In plugin
async onTestDone(testSummary) {
// Critical work first
await this.stopRecording();
// Defer compression to idle time
this.api.requestIdleCallback(async () => {
await this.compressVideo();
await this.uploadToCloud();
});
}
```
## External Artifacts
Plugins can create artifacts from external sources:
```javascript
// During test
device.createExternalArtifact({
pluginId: 'screenshot',
artifactName: 'custom-screenshot',
artifactPath: '/tmp/screenshot.png',
});
// In ArtifactsManager
async onCreateExternalArtifact({ pluginId, artifactName, artifactPath }) {
await this._callSinglePlugin(pluginId, 'onCreateExternalArtifact', {
artifact: new FileArtifact({ temporaryPath: artifactPath }),
name: artifactName,
});
}
```
## See Also
- [ARCHITECTURE.md](../../ARCHITECTURE.md) - Main architecture overview
- [docs/config/artifacts.mdx](../config/artifacts.mdx) - Artifacts configuration
- [docs/guide/taking-screenshots.md](../guide/taking-screenshots.md) - Screenshot guide
+348
View File
@@ -0,0 +1,348 @@
# Client-Server Communication
Detox uses a WebSocket-based client-server architecture for communication between the test process (Node.js) and the mobile app.
## Architecture Overview
```text
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ Test Process │ │ DetoxServer │ │ Mobile App │
│ (Node.js) │ │ (localhost) │ │ (iOS/Android) │
│ │ │ │ │ │
│ ┌────────────┐ │ WS │ ┌────────────┐ │ WS │ ┌────────────┐ │
│ │ Client │◄─┼───────►│ │ Session │◄─┼───────►│ │ Native │ │
│ │ │ │ │ │ Manager │ │ │ │ Client │ │
│ └────────────┘ │ │ └────────────┘ │ │ └────────────┘ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
```
## Why a Mediator Server?
The server acts as a mediator rather than having direct client-to-app communication:
1. **Resilience** - Either side can disconnect (app restart, simulator boot) without losing the other's state
2. **Session management** - Multiple test workers can share device sessions
3. **Debugging** - Server can log all messages for troubleshooting
4. **Flexibility** - Server can run on a different machine for CI/CD
## Components
### Client (`src/client/Client.js`)
The test-side WebSocket client that:
- Connects to the Detox server
- Sends actions to the mobile app
- Handles responses and events
- Manages synchronization state
**Key methods:**
```javascript
// Connect to server and login to session
await client.connect();
// Send an invocation (tap, type, scroll, etc.)
await client.execute(invocation);
// Wait for app to be ready (synchronized)
await client.waitUntilReady();
// Send action and wait for response
await client.sendAction(new actions.Invoke(invocation));
```
**Event handling:**
```javascript
client.setEventCallback('appConnected', this._onAppConnected);
client.setEventCallback('ready', this._onAppReady);
client.setEventCallback('AppNonresponsiveDetected', this._onAppUnresponsive);
client.setEventCallback('AppWillTerminateWithError', this._onBeforeAppCrash);
client.setEventCallback('appDisconnected', this._onAppDisconnected);
client.setEventCallback('serverError', this._onUnhandledServerError);
```
### AsyncWebSocket (`src/client/AsyncWebSocket.js`)
Promise-based wrapper around the WebSocket API:
```javascript
class AsyncWebSocket {
async open() { /* ... */ }
async close() { /* ... */ }
async send(action, options) { /* ... */ }
// Track in-flight requests for timeout handling
inFlightPromises = {};
}
```
### DetoxServer (`src/server/DetoxServer.js`)
WebSocket server that mediates all communication:
```javascript
class DetoxServer {
constructor({ port, standalone }) {
this._sessionManager = new DetoxSessionManager();
this._wss = new WebSocket.Server({ port });
}
async open() {
// Start listening
}
_onConnection(ws, req) {
this._sessionManager.registerConnection(ws, req.socket);
}
}
```
### DetoxSession (`src/server/DetoxSession.js`)
Per-session state management:
```javascript
class DetoxSession {
constructor(id) {
this.id = id;
this.tester = null; // Test process connection
this.app = null; // Mobile app connection
}
// Route messages between tester and app
// Handle connection/disconnection events
}
```
### DetoxSessionManager (`src/server/DetoxSessionManager.js`)
Manages multiple concurrent sessions:
```javascript
class DetoxSessionManager {
constructor() {
this._sessions = new Map();
}
registerConnection(ws, socket) {
// Identify session from login message
// Create or join existing session
}
getSession(sessionId) {
return this._sessions.get(sessionId);
}
}
```
## Message Protocol
All messages are JSON objects with a standard structure.
**Platform differences:** iOS and Android implement the protocol quite differently:
- **Android** is reflection-centric — the `generation/` project maps Java classes to JS invocation objects (see `src/android/espressoapi/`), allowing the test side to call Android APIs via reflection-based dispatch
- **iOS** has a more handcrafted and formalized protocol, with explicit message types handled on the native side
**Note:** Invocation targets vary by operation type:
- High-level operations use `com.wix.detox.Detox` as the entry point
- Action-specific invocations may use `com.wix.detox.espresso.DetoxAction` (Android) or platform-specific targets
### Request (Test → App)
```json
{
"type": "invoke",
"messageId": 1,
"params": {
"target": {
"type": "Class",
"value": "com.wix.detox.espresso.DetoxAction"
},
"method": "perform",
"args": [
{ "matcher": "..." },
{ "action": "..." }
]
}
}
```
### Response (App → Test)
```json
{
"type": "invokeResult",
"messageId": 1,
"params": {
"result": "success"
}
}
```
### Event (App → Test)
Events typically use fixed negative `messageId` values (e.g., `-1000`, `-0xc1ea`) rather than the incrementing positive IDs used for request-response pairs.
```json
{
"type": "ready",
"messageId": -1000
}
```
```json
{
"type": "AppWillTerminateWithError",
"params": {
"errorDetails": "..."
}
}
```
## Actions (`src/client/actions/actions.js`)
Predefined action classes for common operations:
| Action | Purpose |
|--------|---------|
| `Login` | Authenticate with session ID |
| `Ready` | Query if app is ready |
| `Invoke` | Execute a command on the app |
| `Cleanup` | Signal test completion |
| `ReloadReactNative` | Reload the RN bundle |
| `CurrentStatus` | Get app synchronization status |
| `Shake` | Trigger device shake |
| `SetOrientation` | Change device orientation |
| `SetSyncSettings` | Configure synchronization settings |
| `DeliverPayload` | Send push notification, deep link |
| `CaptureViewHierarchy` | Dump UI hierarchy |
| `GenerateViewHierarchyXml` | Generate UI hierarchy as XML (iOS and Android) |
| `SetInstrumentsRecordingState` | Control iOS performance profiling |
| `WaitForBackground` | Wait for app to background |
| `WaitForActive` | Wait for app to foreground |
## Connection Lifecycle
```text
1. Test starts
│
├── Client.connect()
│ └── WebSocket connects to server
│
├── Client.sendAction(Login)
│ └── Server creates/joins session
│
└── Server waits for app connection
2. App launches (with server URL in launch args)
│
├── Native client connects to server
│ └── Server associates with session
│
└── Server notifies test: 'appConnected'
3. App becomes ready
│
└── Native client sends 'ready' event
4. Test executes
│
├── Client.execute(invocation)
│ ├── Server routes to app
│ ├── App executes
│ └── App responds via server
│
└── Client receives result
5. Test completes
│
├── Client.cleanup()
│ └── Sends Cleanup action
│
└── Client.close()
```
## Synchronization
The client monitors app readiness through deferred promises (simplified):
```javascript
// Track connection state
this._whenAppIsConnected = new Deferred();
this._whenAppIsReady = new Deferred();
// Wait for synchronization (simplified - actual implementation has additional checks)
async waitUntilReady() {
await this._whenAppIsConnected.promise;
await this.sendAction(new actions.Ready());
this._whenAppIsReady.resolve();
}
```
## Slow Invocation Detection
When `debugSynchronization` is enabled, the client periodically queries app status during long operations:
```javascript
_scheduleSlowInvocationQuery() {
this._slowInvocationStatusHandle = setTimeout(async () => {
const status = await this.currentStatus();
log.info({ event: 'APP_STATUS' }, status);
// Reschedule if still waiting
}, this._slowInvocationTimeout);
}
```
## Error Handling
### App Crash Detection
```javascript
_onBeforeAppCrash({ params }) {
this._pendingAppCrash = new DetoxRuntimeError({
message: 'The app has crashed',
debugInfo: params.errorDetails,
});
// Reject pending promises
// Schedule app termination
}
```
### Disconnect Handling
```javascript
_onAppDisconnected() {
if (this._pendingAppCrash) {
this._whenAppDisconnected.reject(this._pendingAppCrash);
this._asyncWebSocket.rejectAll(this._pendingAppCrash);
}
}
```
## User Stack Traces
The client preserves user test stack traces for better error messages:
```javascript
async _doSendAction(action, options) {
const errorWithUserStack = createErrorWithUserStack();
try {
const response = await this._asyncWebSocket.send(action, options);
return await action.handle(response);
} catch (err) {
throw replaceErrorStack(errorWithUserStack, asError(err));
}
}
```
## See Also
- [ARCHITECTURE.md](../../ARCHITECTURE.md) - Main architecture overview
- [docs/architecture/devices.md](devices.md) - Device management
- [docs/config/session.mdx](../config/session.mdx) - Session configuration
+426
View File
@@ -0,0 +1,426 @@
# Device Management
The device subsystem handles allocation, lifecycle, and interaction with iOS simulators, Android emulators, and cloud devices.
## Architecture Overview
```text
┌────────────────────────────────────────────────────────────┐
│ Device Subsystem │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ ┌────────────┐ │
│ │ DeviceAllocator │─►│ RuntimeDevice │─►│ Driver │ │
│ │ │ │ │ │ │ │
│ │ - allocate() │ │ - launchApp() │ │ - boot() │ │
│ │ - free() │ │ - terminateApp()│ │ - install()│ │
│ │ │ │ - reloadRN() │ │ - execute()│ │
│ └────────┬────────┘ └────────┬────────┘ └────────────┘ │
│ │ │ │
│ ┌────────▼────────┐ ┌────────▼────────┐ │
│ │ DeviceRegistry │ │ DeviceCookie │ │
│ │ (Lock files) │ │ (Serialization) │ │
│ └─────────────────┘ └─────────────────┘ │
└────────────────────────────────────────────────────────────┘
```
## Directory Structure
```text
src/devices/
├── allocation/
│ ├── DeviceAllocator.js # Main allocation coordinator
│ ├── DeviceList.js # Available devices tracking
│ ├── DeviceRegistry.js # Lock-based device registry
│ ├── drivers/
│ │ ├── ios/
│ │ │ └── SimulatorAllocDriver.js
│ │ └── android/
│ │ ├── emulator/
│ │ │ └── EmulatorAllocDriver.js
│ │ ├── attached/
│ │ │ └── AttachedAndroidAllocDriver.js
│ │ └── genycloud/
│ │ └── GenyAllocDriver.js
│ └── factories/
│ ├── ios.js
│ ├── android.js
│ └── external.js
├── runtime/
│ ├── RuntimeDevice.js # Main device abstraction
│ └── drivers/
│ ├── DeviceDriverBase.js
│ ├── ios/
│ │ ├── IosDriver.js
│ │ └── SimulatorDriver.js
│ └── android/
│ ├── AndroidDriver.js
│ ├── emulator/
│ │ └── EmulatorDriver.js
│ ├── attached/
│ │ └── AttachedAndroidDriver.js
│ └── genycloud/
│ └── GenyCloudDriver.js
├── common/
│ └── drivers/
│ ├── ios/tools/
│ │ └── AppleSimUtils.js
│ └── android/
│ ├── exec/
│ │ ├── ADB.js
│ │ └── AAPT.js
│ └── tools/
│ ├── Instrumentation.js
│ └── AppInstallHelper.js
├── cookies/
│ └── index.js # Device state serialization
└── validation/
└── ... # Environment validators
```
## Device Types
| Type | Config Value | Platform | Description |
|------|--------------|----------|-------------|
| iOS Simulator | `ios.simulator` | iOS | Xcode simulators |
| Android Emulator | `android.emulator` | Android | AVD emulators |
| Android Attached | `android.attached` | Android | USB-connected devices |
| Genycloud | `android.genycloud` | Android | Genymotion cloud instances |
## Allocation System
### DeviceAllocator (`src/devices/allocation/DeviceAllocator.js`)
Coordinates device allocation with concurrency control:
```javascript
class DeviceAllocator {
constructor(allocationDriver, deviceRegistry) {
this._driver = allocationDriver;
this._registry = deviceRegistry;
}
async allocate(deviceConfig) {
// Find or create a device matching the config
// Register ownership in the registry
// Return a device cookie
}
async postAllocate(deviceCookie, configs) {
// Platform-specific device setup after allocation
// e.g., boot simulator, disable animations, unlock screen
// Returns an enriched device cookie
}
async free(deviceCookie) {
// Release the device
// Remove from registry
}
}
```
### DeviceRegistry (`src/devices/allocation/DeviceRegistry.js`)
Uses lock files for cross-process device ownership:
```javascript
class DeviceRegistry {
constructor({ lockfilePath }) {
this._lockfile = new ExclusiveLockfile(lockfilePath);
}
async allocateDevice(deviceQuery) {
return this._lockfile.exclusively(async () => {
// Find available device
// Mark as allocated
// Return device handle
});
}
}
```
### Allocation Drivers
Each device type has a specialized allocation driver:
**SimulatorAllocDriver** (`src/devices/allocation/drivers/ios/SimulatorAllocDriver.js`):
- Queries available simulators via `xcrun simctl`
- Creates simulators if needed
- Handles simulator lifecycle
**EmulatorAllocDriver** (`src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js`):
- Validates AVD existence
- Launches emulators with appropriate flags
- Assigns unique ports
**GenyAllocDriver** (`src/devices/allocation/drivers/android/genycloud/GenyAllocDriver.js`):
- Communicates with Genymotion cloud API
- Provisions cloud instances
- Handles instance lifecycle
## Runtime Device
### RuntimeDevice (`src/devices/runtime/RuntimeDevice.js`)
The main device abstraction exposed to tests:
```javascript
class RuntimeDevice {
constructor(deviceCookie, deps, configs) {
this._driver = createDriver(deviceCookie, deps);
this._appsConfig = configs.appsConfig;
}
// App management
async launchApp(params) { /* ... */ }
async terminateApp(bundleId) { /* ... */ }
async installApp() { /* ... */ }
async uninstallApp() { /* ... */ }
// React Native specific
async reloadReactNative() { /* ... */ }
// Device control
async sendToHome() { /* ... */ }
async shake() { /* ... */ }
async setLocation(lat, lon) { /* ... */ }
async setURLBlacklist(urls) { /* ... */ }
// State
get id() { return this._driver.deviceId; }
get name() { return this._driver.deviceName; }
}
```
### Driver Hierarchy
```text
DeviceDriverBase
├── IosDriver
│ └── SimulatorDriver
└── AndroidDriver
├── EmulatorDriver
├── AttachedAndroidDriver
└── GenyCloudDriver
```
**DeviceDriverBase** (`src/devices/runtime/drivers/DeviceDriverBase.js`):
- Abstract base class
- Defines common interface
- Provides utility methods
**Platform Drivers**:
- Implement platform-specific operations
- Handle app installation/launch
- Manage device state
## Platform-Specific Tools
### iOS Tools
**AppleSimUtils** (`src/devices/common/drivers/ios/tools/AppleSimUtils.js`):
```javascript
class AppleSimUtils {
async boot(deviceId) { /* xcrun simctl boot */ }
async shutdown(deviceId) { /* xcrun simctl shutdown */ }
async install(deviceId, path) { /* xcrun simctl install */ }
async launch(deviceId, bundleId, args) { /* xcrun simctl launch */ }
async setPermissions(deviceId, bundleId, permissions) { /* ... */ }
}
```
### Android Tools
**ADB** (`src/devices/common/drivers/android/exec/ADB.js`):
```javascript
class ADB {
async devices() { /* adb devices */ }
async install(deviceId, apkPath) { /* adb install */ }
async shell(deviceId, command) { /* adb shell */ }
async forward(deviceId, local, remote) { /* adb forward */ }
}
```
**Instrumentation** (`src/devices/common/drivers/android/tools/Instrumentation.js`):
```javascript
class Instrumentation {
async launch(deviceId, bundleId, testRunner, args) {
// am instrument -w -r ...
}
}
```
## Device Lifecycle
### Allocation Flow
```text
1. Test starts
│
▼
2. DeviceAllocator.allocate(deviceConfig)
│
├── Query available devices
│ └── SimulatorQuery / FreeDeviceFinder
│
├── Acquire lock
│ └── DeviceRegistry.allocateDevice()
│
└── Return DeviceCookie
3. DeviceAllocator.postAllocate(cookie, configs)
│
├── Platform-specific device setup
│ ├── iOS: Boot simulator, clean app cache
│ └── Android: Wait for boot, disable animations,
│ unlock screen, configure System UI
│
└── Return enriched DeviceCookie
4. RuntimeDevice created with cookie
│
▼
5. Tests execute
│
▼
6. DeviceAllocator.free(cookie)
│
├── Shutdown (if configured)
│
└── Release lock
```
### App Launch Flow
```text
1. device.launchApp(params)
│
├── Terminate if running
│
├── Build launch args
│ ├── Detox server URL
│ ├── Session ID
│ └── User-provided args
│
├── Platform-specific launch
│ ├── iOS: xcrun simctl launch
│ └── Android: am instrument
│
└── Wait for app connection
└── client.waitUntilReady()
```
## Device Cookie
Device cookies serialize device state for passing between processes:
```javascript
// src/devices/cookies/index.js
const DeviceCookie = {
serialize(device) {
return {
id: device.id,
type: device.type,
// Platform-specific data
};
},
deserialize(cookie) {
// Reconstruct device handle
}
};
```
## Environment Validation
Validators ensure the environment is properly configured:
```javascript
// iOS validator
class IosSimulatorEnvValidator {
async validate() {
// Check Xcode installation
// Verify xcrun availability
// Validate simulator runtime
}
}
// Android validator
class AndroidEmulatorEnvValidator {
async validate() {
// Check ANDROID_HOME
// Verify ADB installation
// Validate emulator availability
}
}
```
## Event Emission
RuntimeDevice emits lifecycle events for artifact collection:
```javascript
class RuntimeDevice {
async launchApp(params) {
await this._eventEmitter.emit('beforeLaunchApp', launchInfo);
// ... launch logic ...
await this._eventEmitter.emit('launchApp', launchInfo);
await this._eventEmitter.emit('appReady', appInfo);
}
async uninstallApp() {
await this._eventEmitter.emit('beforeUninstallApp', appInfo);
// ... uninstall logic ...
}
async terminateApp() {
await this._eventEmitter.emit('beforeTerminateApp', appInfo);
// ... terminate logic ...
await this._eventEmitter.emit('terminateApp', appInfo);
}
}
```
## Configuration
Device configuration in `.detoxrc.js`:
```javascript
module.exports = {
devices: {
simulator: {
type: 'ios.simulator',
device: {
type: 'iPhone 15 Pro',
},
},
emulator: {
type: 'android.emulator',
device: {
avdName: 'Pixel_4_API_30',
},
},
genycloud: {
type: 'android.genycloud',
device: {
recipeUUID: '...',
},
},
},
};
```
## See Also
- [ARCHITECTURE.md](../../ARCHITECTURE.md) - Main architecture overview
- [docs/config/devices.mdx](../config/devices.mdx) - Device configuration reference
- [docs/guide/android-dev-env.md](../guide/android-dev-env.md) - Android setup guide