mirror of
https://github.com/hanzoai/Detox.git
synced 2026-09-24 16:55:12 +00:00
docs: add internal architecture documentation (#4909)
This commit is contained in:
+344
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user