import { readFile, access } from "fs/promises";
import { join, dirname } from "path";
import { pathToFileURL } from "url";
export interface DetoxDevice {
type: string;
device?: {
type?: string;
id?: string;
};
avdName?: string;
os?: string;
}
export interface DetoxApp {
type: string;
binaryPath: string;
build?: string;
bundleId?: string;
}
export interface DetoxConfiguration {
device: string;
app: string;
}
export interface DetoxConfig {
devices?: Record<string, DetoxDevice>;
apps?: Record<string, DetoxApp>;
configurations?: Record<string, DetoxConfiguration>;
testRunner?: string;
artifacts?: {
rootDir?: string;
plugins?: Record<string, any>;
};
behavior?: Record<string, any>;
session?: Record<string, any>;
}
const CONFIG_FILES = [
".detoxrc.js",
".detoxrc.json",
".detoxrc",
"detox.config.js",
"detox.config.json",
];
/**
* Find the Detox config file in a project
*/
export async function findConfigFile(projectPath: string): Promise<string | null> {
for (const configFile of CONFIG_FILES) {
const fullPath = join(projectPath, configFile);
try {
await access(fullPath);
return fullPath;
} catch {
continue;
}
}
// Check package.json for detox key
const packageJsonPath = join(projectPath, "package.json");
try {
await access(packageJsonPath);
const content = await readFile(packageJsonPath, "utf-8");
const pkg = JSON.parse(content);
if (pkg.detox) {
return packageJsonPath;
}
} catch {
// Ignore
}
return null;
}
/**
* Parse a Detox configuration file
*/
export async function parseConfig(configPath: string): Promise<DetoxConfig> {
const ext = configPath.split(".").pop()?.toLowerCase();
if (ext === "js") {
// Dynamic import for JS files
const fileUrl = pathToFileURL(configPath).href;
const module = await import(fileUrl);
return module.default || module;
}
const content = await readFile(configPath, "utf-8");
const config = JSON.parse(content);
// Handle package.json with detox key
if (configPath.endsWith("package.json") && config.detox) {
return config.detox;
}
return config;
}
/**
* Read Detox configuration from a project
*/
export async function readDetoxConfig(projectPath: string): Promise<{
config: DetoxConfig;
configPath: string;
} | null> {
const configPath = await findConfigFile(projectPath);
if (!configPath) {
return null;
}
try {
const config = await parseConfig(configPath);
return { config, configPath };
} catch (error) {
throw new Error(`Failed to parse Detox config at ${configPath}: ${error}`);
}
}
/**
* List available configurations from config
*/
export function listConfigurations(config: DetoxConfig): Array<{
name: string;
device: string;
app: string;
deviceType?: string;
appType?: string;
}> {
if (!config.configurations) {
return [];
}
return Object.entries(config.configurations).map(([name, conf]) => {
const device = config.devices?.[conf.device];
const app = config.apps?.[conf.app];
return {
name,
device: conf.device,
app: conf.app,
deviceType: device?.type,
appType: app?.type,
};
});
}
/**
* Validate a Detox configuration
*/
export function validateConfig(config: DetoxConfig): {
valid: boolean;
errors: string[];
warnings: string[];
} {
const errors: string[] = [];
const warnings: string[] = [];
// Check for required sections
if (!config.configurations || Object.keys(config.configurations).length === 0) {
errors.push("No configurations defined");
}
if (!config.devices || Object.keys(config.devices).length === 0) {
errors.push("No devices defined");
}
if (!config.apps || Object.keys(config.apps).length === 0) {
errors.push("No apps defined");
}
// Validate each configuration
if (config.configurations) {
for (const [name, conf] of Object.entries(config.configurations)) {
if (!config.devices?.[conf.device]) {
errors.push(`Configuration "${name}" references undefined device "${conf.device}"`);
}
if (!config.apps?.[conf.app]) {
errors.push(`Configuration "${name}" references undefined app "${conf.app}"`);
}
}
}
// Validate apps have build commands
if (config.apps) {
for (const [name, app] of Object.entries(config.apps)) {
if (!app.binaryPath) {
errors.push(`App "${name}" is missing binaryPath`);
}
if (!app.build) {
warnings.push(`App "${name}" has no build command defined`);
}
}
}
// Validate device types
const validDeviceTypes = [
"ios.simulator",
"ios.device",
"android.emulator",
"android.attached",
"android.genycloud",
];
if (config.devices) {
for (const [name, device] of Object.entries(config.devices)) {
if (!validDeviceTypes.includes(device.type)) {
warnings.push(`Device "${name}" has unknown type "${device.type}"`);
}
}
}
return {
valid: errors.length === 0,
errors,
warnings,
};
}
/**
* Generate a default Detox configuration
*/
export function generateDefaultConfig(options: {
platforms: ("ios" | "android")[];
appName?: string;
bundleId?: string;
packageName?: string;
}): DetoxConfig {
const config: DetoxConfig = {
testRunner: "jest",
artifacts: {
rootDir: "./artifacts",
plugins: {
screenshot: "failing",
video: "failing",
log: "failing",
},
},
devices: {},
apps: {},
configurations: {},
};
const appName = options.appName || "MyApp";
if (options.platforms.includes("ios")) {
config.devices!["ios.simulator"] = {
type: "ios.simulator",
device: { type: "iPhone 15" },
};
config.apps!["ios.debug"] = {
type: "ios.app",
binaryPath: `ios/build/Build/Products/Debug-iphonesimulator/${appName}.app`,
build: `xcodebuild -workspace ios/${appName}.xcworkspace -scheme ${appName} -configuration Debug -sdk iphonesimulator -derivedDataPath ios/build`,
};
config.configurations!["ios.sim.debug"] = {
device: "ios.simulator",
app: "ios.debug",
};
}
if (options.platforms.includes("android")) {
config.devices!["android.emulator"] = {
type: "android.emulator",
avdName: "Pixel_4_API_30",
};
config.apps!["android.debug"] = {
type: "android.apk",
binaryPath: "android/app/build/outputs/apk/debug/app-debug.apk",
build: "cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug",
};
config.configurations!["android.emu.debug"] = {
device: "android.emulator",
app: "android.debug",
};
}
return config;
}