17 KiB
Artifacts System
The artifacts subsystem collects test artifacts like screenshots, videos, logs, and performance data during test execution.
Architecture Overview
┌─────────────────────────────────────────────────────────────┐
│ 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
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
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:
_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
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
// 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:
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)
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:
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:
{
artifacts: {
plugins: {
screenshot: {
enabled: true,
shouldTakeAutomaticSnapshots: true,
keepOnlyFailedTestsArtifacts: true,
}
}
}
}
Video Plugin
Records test execution:
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:
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:
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):
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:
IosSimulatorArtifactPluginsProviderfor iOS simulatorsAndroidArtifactPluginsProviderfor Android devices
Artifact Templates
Two Snapshots Per Test
Takes snapshots at test start and end:
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:
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:
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:
// detox.config.js
module.exports = {
artifacts: {
rootDir: 'e2e-artifacts',
pathBuilder: './my-path-builder.js',
}
};
Configuration
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
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:
// 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:
// 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 - Main architecture overview
- docs/config/artifacts.mdx - Artifacts configuration
- docs/guide/taking-screenshots.md - Screenshot guide