Files
2026-07-13 12:22:50 +08:00

1164 lines
33 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env npx ts-node
/**
* Unified E2E Test Runner
*
* Single entry point for all local E2E test execution patterns:
* - Run a spec once on the default device
* - Iterate a spec across multiple models (per-model isolation)
* - Iterate a spec across multiple devices (multi-device pipeline)
* - Full matrix: every model × every device
*
* Usage:
* # Simple run
* npx ts-node scripts/run-e2e.ts --platform ios --spec quick-smoke
*
* # Per-model iteration
* npx ts-node scripts/run-e2e.ts --platform ios --each-model
* npx ts-node scripts/run-e2e.ts --platform ios --each-model --models smollm2-135m,qwen3-0.6b
*
* # Crash repro (load-stress on specific model)
* npx ts-node scripts/run-e2e.ts --platform ios --spec load-stress --models gemma-2-2b
*
* # Multi-device pipeline
* npx ts-node scripts/run-e2e.ts --platform ios --each-device
* npx ts-node scripts/run-e2e.ts --platform ios --each-device --devices virtual-only
*
* # Full matrix
* npx ts-node scripts/run-e2e.ts --platform ios --each-device --each-model
*
* # Device farm mode (switches wdio config, no local Appium)
* npx ts-node scripts/run-e2e.ts --platform ios --each-model --mode device-farm
*
* # Info commands
* npx ts-node scripts/run-e2e.ts --list-models
* npx ts-node scripts/run-e2e.ts --help
*/
import {config} from 'dotenv';
import {execSync} from 'child_process';
import * as fs from 'fs';
import * as path from 'path';
import {
TEST_MODELS,
CRASH_REPRO_MODELS,
ALL_MODELS,
ModelTestConfig,
} from '../fixtures/models';
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
interface DeviceConfig {
id: string;
name: string;
platform: 'ios' | 'android';
type: 'simulator' | 'emulator' | 'real';
enabled: boolean;
deviceName: string;
platformVersion: string;
udid?: string;
appPath?: string;
xcodeOrgId?: string;
xcodeSigningId?: string;
}
interface DeviceInventory {
devices: DeviceConfig[];
}
interface RunArgs {
platform: 'ios' | 'android' | 'both';
spec: string;
models?: string[];
eachModel: boolean;
allModels: boolean;
devices: string;
eachDevice: boolean;
mode: 'local' | 'device-farm';
skipBuild: boolean;
dryRun: boolean;
listModels: boolean;
reportDir?: string;
appiumBasePort: number;
help: boolean;
}
interface TestRunResult {
deviceId: string | null;
deviceName: string | null;
devicePlatform: 'ios' | 'android';
deviceType: 'simulator' | 'emulator' | 'real' | null;
modelId: string | null;
spec: string;
success: boolean;
duration: number;
error?: string;
junitFile?: string;
}
interface RunSummary {
timestamp: string;
branch: string;
commit: string;
platform: string;
spec: string;
mode: string;
totalRuns: number;
passed: number;
failed: number;
totalDuration: number;
results: TestRunResult[];
}
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const E2E_DIR = path.join(__dirname, '..');
const REPO_ROOT = path.join(E2E_DIR, '..');
const DEVICES_FILE = path.join(E2E_DIR, 'devices.json');
const DEVICES_TEMPLATE = path.join(E2E_DIR, 'devices.template.json');
const REPORTS_DIR = path.join(E2E_DIR, 'reports');
// Default base Appium port. Override per-run with --appium-port or the
// E2E_BASE_APPIUM_PORT env var (e.g. when another E2E run already holds 4723).
const DEFAULT_BASE_APPIUM_PORT = 4723;
const ENV_FILE = path.join(E2E_DIR, '.env');
// Load environment variables from e2e/.env (secrets, server URLs, etc.)
config({path: ENV_FILE});
// ---------------------------------------------------------------------------
// CLI Parsing
// ---------------------------------------------------------------------------
function printHelp(): void {
console.log(`
Unified E2E Test Runner
USAGE:
npx ts-node scripts/run-e2e.ts [OPTIONS]
OPTIONS:
--platform <platform> Platform: 'ios', 'android', or 'both' (required)
--spec <spec> Test spec: 'quick-smoke', 'load-stress', 'diagnostic',
'language', 'visual-capture', 'memory-profile',
'benchmark-matrix', or 'all' (default: 'quick-smoke')
--models <ids> Comma-separated model IDs to test
--each-model Iterate spec once per model (isolated WDIO process each)
--all-models Include crash-repro models in the model pool
--devices <filter> Device filter: 'all', 'virtual-only', 'real-only',
'connected', or comma-separated device IDs (default: 'all')
--each-device Iterate across devices from devices.json
--mode <mode> Execution mode: 'local' or 'device-farm' (default: 'local')
--skip-build Skip app build step
--dry-run Print matched targets and commands without executing
--list-models List all available models
--report-dir <path> Custom report directory (default: e2e/reports/<timestamp>)
--appium-port <n> Base Appium port (default: 4723, or E2E_BASE_APPIUM_PORT).
Per-device runs increment from this. Use a free port
when another E2E run already holds the default.
--help Show this help message
EXAMPLES:
# Quick smoke test on default device
yarn e2e:ios --spec quick-smoke
# Test each model in isolation
yarn e2e:ios --each-model
yarn e2e:ios --each-model --models smollm2-135m,qwen3-0.6b
# Crash reproduction (load-stress on a specific model)
yarn e2e --platform ios --spec load-stress --models gemma-2-2b
# Multi-device pipeline
yarn e2e:ios --each-device
yarn e2e:ios --devices virtual-only --skip-build
# Run on whatever real devices are currently plugged in
yarn e2e:android --devices connected --skip-build
# Full matrix: every model × every device
yarn e2e:ios --each-device --each-model
# Device farm mode (for running ON a Device Farm host)
yarn e2e:ios --each-model --mode device-farm
# Dry run to see what would execute
yarn e2e --platform both --each-device --each-model --dry-run
# Benchmark matrix (Android only)
yarn e2e --platform android --spec benchmark-matrix --skip-build
BENCH_MODELS=qwen3-1.7b BENCH_QUANTS=q4_0 BENCH_BACKENDS=cpu \\
yarn e2e --platform android --spec benchmark-matrix --skip-build
MODELS_PRESEEDED=1 yarn e2e --platform android --spec benchmark-matrix --skip-build
`);
}
function parsePort(value: string | undefined, fallback: number): number {
if (!value) {
return fallback;
}
const parsed = parseInt(value, 10);
return Number.isNaN(parsed) ? fallback : parsed;
}
function parseArgs(): RunArgs {
const args = process.argv.slice(2);
const result: RunArgs = {
platform: 'ios',
spec: 'quick-smoke',
eachModel: false,
allModels: false,
devices: 'all',
eachDevice: false,
mode: 'local',
skipBuild: false,
dryRun: false,
listModels: false,
appiumBasePort: parsePort(
process.env.E2E_BASE_APPIUM_PORT,
DEFAULT_BASE_APPIUM_PORT,
),
help: false,
};
let platformSet = false;
for (let i = 0; i < args.length; i++) {
const arg = args[i];
const nextArg = args[i + 1];
switch (arg) {
case '--platform':
if (
nextArg &&
(nextArg === 'ios' || nextArg === 'android' || nextArg === 'both')
) {
result.platform = nextArg;
platformSet = true;
i++;
}
break;
case '--spec':
if (nextArg) {
result.spec = nextArg;
i++;
}
break;
case '--model':
case '--models':
if (nextArg) {
result.models = nextArg.split(',').map(m => m.trim());
i++;
}
break;
case '--each-model':
result.eachModel = true;
break;
case '--all-models':
result.allModels = true;
break;
case '--device':
case '--devices':
if (nextArg) {
result.devices = nextArg;
result.eachDevice = true; // --devices implies --each-device
i++;
}
break;
case '--each-device':
result.eachDevice = true;
break;
case '--mode':
if (nextArg && (nextArg === 'local' || nextArg === 'device-farm')) {
result.mode = nextArg;
i++;
}
break;
case '--skip-build':
result.skipBuild = true;
break;
case '--dry-run':
result.dryRun = true;
break;
case '--list-models':
result.listModels = true;
break;
case '--report-dir':
if (nextArg) {
result.reportDir = nextArg;
i++;
}
break;
case '--appium-port':
if (nextArg) {
result.appiumBasePort = parsePort(nextArg, result.appiumBasePort);
i++;
}
break;
case '--help':
case '-h':
result.help = true;
break;
}
}
if (!result.help && !result.listModels && !platformSet) {
console.error('Error: --platform is required. Use --help for usage.');
process.exit(1);
}
if (result.eachDevice && result.mode === 'device-farm') {
console.error(
'Error: --each-device cannot be used with --mode device-farm.',
);
console.error('Device Farm manages its own device. Remove --each-device.');
process.exit(1);
}
return result;
}
// ---------------------------------------------------------------------------
// Git Info
// ---------------------------------------------------------------------------
function getGitInfo(): {branch: string; commit: string} {
try {
const branch = execSync('git branch --show-current', {
cwd: REPO_ROOT,
encoding: 'utf8',
}).trim();
const commit = execSync('git rev-parse --short HEAD', {
cwd: REPO_ROOT,
encoding: 'utf8',
}).trim();
return {branch, commit};
} catch {
return {branch: 'unknown', commit: 'unknown'};
}
}
// ---------------------------------------------------------------------------
// Device Loading & Filtering
// ---------------------------------------------------------------------------
function loadDevices(): DeviceConfig[] {
if (!fs.existsSync(DEVICES_FILE)) {
console.error(`Error: ${DEVICES_FILE} not found.`);
console.error('Copy the template to get started:');
console.error(` cp ${DEVICES_TEMPLATE} ${DEVICES_FILE}`);
console.error('Then edit devices.json for your machine.');
process.exit(1);
}
const raw = fs.readFileSync(DEVICES_FILE, 'utf8');
const inventory: DeviceInventory = JSON.parse(raw);
if (!inventory.devices || !Array.isArray(inventory.devices)) {
console.error('Error: devices.json must have a "devices" array.');
process.exit(1);
}
return inventory.devices;
}
function getConnectedAndroidUdids(): Set<string> {
try {
const output = execSync('adb devices', {encoding: 'utf8', timeout: 5000});
const udids = new Set<string>();
for (const line of output.split('\n')) {
const match = line.match(/^(\S+)\s+device$/);
if (match) {
udids.add(match[1]);
}
}
return udids;
} catch {
return new Set();
}
}
function getConnectedIosUdids(): Set<string> {
try {
const output = execSync('xcrun xctrace list devices 2>/dev/null', {
encoding: 'utf8',
timeout: 10000,
});
const udids = new Set<string>();
// Matches lines like: "iPhone 15 (18.2) (00008110-XXXXXXXXXXXX)"
for (const match of output.matchAll(/\(([0-9A-F-]{20,})\)/gi)) {
udids.add(match[1]);
}
return udids;
} catch {
return new Set();
}
}
function getConnectedUdids(platform: 'ios' | 'android' | 'both'): Set<string> {
const udids = new Set<string>();
if (platform === 'android' || platform === 'both') {
for (const udid of getConnectedAndroidUdids()) {
udids.add(udid);
}
}
if (platform === 'ios' || platform === 'both') {
for (const udid of getConnectedIosUdids()) {
udids.add(udid);
}
}
return udids;
}
function filterDevices(
devices: DeviceConfig[],
platform: 'ios' | 'android' | 'both',
deviceFilter: string,
): DeviceConfig[] {
let filtered = devices;
if (platform !== 'both') {
filtered = filtered.filter(d => d.platform === platform);
}
switch (deviceFilter) {
case 'all':
filtered = filtered.filter(d => d.enabled);
break;
case 'virtual-only':
filtered = filtered.filter(
d => d.enabled && (d.type === 'simulator' || d.type === 'emulator'),
);
break;
case 'real-only':
filtered = filtered.filter(d => d.enabled && d.type === 'real');
break;
case 'connected': {
console.log('Detecting connected devices...');
const connectedUdids = getConnectedUdids(platform);
if (connectedUdids.size === 0) {
console.log(' No connected devices detected.');
} else {
console.log(
` Found ${connectedUdids.size} connected device(s): ${[...connectedUdids].join(', ')}`,
);
}
filtered = filtered.filter(d => {
if (!d.udid) {
return false;
}
return connectedUdids.has(d.udid);
});
break;
}
default: {
const ids = deviceFilter.split(',').map(id => id.trim());
filtered = filtered.filter(d => ids.includes(d.id));
break;
}
}
return filtered;
}
// ---------------------------------------------------------------------------
// Model Loading & Filtering
// ---------------------------------------------------------------------------
function getModelsForIteration(
filterIds?: string[],
useAllModels = false,
): ModelTestConfig[] {
const modelPool = useAllModels ? ALL_MODELS : TEST_MODELS;
if (!filterIds) {
return modelPool;
}
const filtered = modelPool.filter(m =>
filterIds.some(id => m.id.toLowerCase() === id.toLowerCase()),
);
if (filtered.length === 0) {
console.error(
`Error: No models matched filter. Available: ${modelPool.map(m => m.id).join(', ')}`,
);
process.exit(1);
}
return filtered;
}
function listModels(): void {
console.log('\n=== Available Models ===\n');
console.log('Regular Test Models:');
console.log('-'.repeat(60));
for (const model of TEST_MODELS) {
const vision = model.isVision ? ' [vision]' : '';
console.log(` ${model.id}${vision}`);
console.log(` File: ${model.downloadFile}`);
console.log(` Query: ${model.searchQuery}`);
console.log();
}
console.log('\nCrash Reproduction Models:');
console.log('-'.repeat(60));
for (const model of CRASH_REPRO_MODELS) {
const vision = model.isVision ? ' [vision]' : '';
const timeout = model.downloadTimeout
? ` (download timeout: ${model.downloadTimeout / 60000}min)`
: '';
console.log(` ${model.id}${vision}${timeout}`);
console.log(` File: ${model.downloadFile}`);
console.log(` Query: ${model.searchQuery}`);
console.log();
}
}
// ---------------------------------------------------------------------------
// Build
// ---------------------------------------------------------------------------
function buildApps(
platform: 'ios' | 'android' | 'both',
dryRun: boolean,
spec: string,
): void {
// E2E builds default to __E2E_SKIP_ONBOARDING__=true (babel.config.js) so the
// OnboardingStack is bypassed and the other specs reach the chat shell
// directly. The onboarding spec is the one exception — it needs the flow to
// actually mount, so build that variant with E2E_SKIP_ONBOARDING=false. It is
// a distinct binary; run the onboarding spec in its own build/--skip-build
// cycle, not bundled with specs that expect onboarding bypassed.
const onboardingPrefix =
spec === 'onboarding' ? 'E2E_SKIP_ONBOARDING=false ' : '';
if (platform === 'ios' || platform === 'both') {
const cmd = `${onboardingPrefix}yarn ios:build:e2e`;
if (dryRun) {
console.log(`[DRY RUN] Would run: ${cmd} (cwd: ${REPO_ROOT})`);
} else {
console.log('Building iOS E2E app...');
execSync(cmd, {stdio: 'inherit', cwd: REPO_ROOT});
}
}
if (platform === 'android' || platform === 'both') {
// E2E runs must target the e2e flavor (com.pocketpalai.e2e) so the
// automation bridge is present. The prod flavor's APK has the
// bridge DCE-stripped and specs would silently fail.
const cmd = `cd android && ${onboardingPrefix}E2E_BUILD=true ./gradlew assembleE2eReleaseE2e`;
if (dryRun) {
console.log(`[DRY RUN] Would run: ${cmd} (cwd: ${REPO_ROOT})`);
} else {
console.log('Building Android E2E APK (e2e flavor, releaseE2e buildType)...');
execSync(cmd, {stdio: 'inherit', cwd: REPO_ROOT});
}
}
}
// ---------------------------------------------------------------------------
// WDIO Config Selection
// ---------------------------------------------------------------------------
/**
* Resolve a spec name to its file path.
* Checks specs/{name}.spec.ts first, then specs/features/{name}.spec.ts.
*/
function resolveSpecPath(spec: string): string {
if (spec === 'all') {
return '';
}
const direct = path.join(E2E_DIR, 'specs', `${spec}.spec.ts`);
if (fs.existsSync(direct)) {
return `specs/${spec}.spec.ts`;
}
const feature = path.join(E2E_DIR, 'specs', 'features', `${spec}.spec.ts`);
if (fs.existsSync(feature)) {
return `specs/features/${spec}.spec.ts`;
}
// Fallback: assume top-level (WDIO will report the error if missing)
return `specs/${spec}.spec.ts`;
}
function getWdioConfig(
platform: 'ios' | 'android',
mode: 'local' | 'device-farm',
): string {
if (mode === 'device-farm') {
return platform === 'ios' ? 'wdio.ios.conf.ts' : 'wdio.android.conf.ts';
}
return platform === 'ios'
? 'wdio.ios.local.conf.ts'
: 'wdio.android.local.conf.ts';
}
// ---------------------------------------------------------------------------
// Test Execution
// ---------------------------------------------------------------------------
function getRunSubdir(
device: DeviceConfig | null,
model: ModelTestConfig | null,
): string {
const parts: string[] = [];
if (device) {
parts.push(device.id);
}
if (model) {
parts.push(model.id);
}
return parts.length > 0 ? parts.join(path.sep) : '.';
}
function getRunLabel(
device: DeviceConfig | null,
model: ModelTestConfig | null,
platform: 'ios' | 'android',
): string {
const parts: string[] = [];
if (device) {
parts.push(device.name);
} else {
parts.push(platform);
}
if (model) {
parts.push(model.id);
}
return parts.join(' / ');
}
function findJunitFiles(dir: string): string[] {
const results: string[] = [];
try {
const entries = fs.readdirSync(dir, {withFileTypes: true});
for (const entry of entries) {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...findJunitFiles(fullPath));
} else if (
entry.name.startsWith('junit-') &&
entry.name.endsWith('.xml')
) {
results.push(fullPath);
}
}
} catch {
// Directory may not exist
}
return results;
}
function runSingleTest(opts: {
spec: string;
platform: 'ios' | 'android';
mode: 'local' | 'device-farm';
model: ModelTestConfig | null;
device: DeviceConfig | null;
appiumPort: number;
reportDir: string;
}): TestRunResult {
const {spec, platform, mode, model, device, appiumPort, reportDir} = opts;
const startTime = Date.now();
const configFile = getWdioConfig(platform, mode);
const specArg = spec === 'all' ? '' : `--spec ${resolveSpecPath(spec)}`;
const label = getRunLabel(device, model, platform);
// Per-run report subdirectory
const subdir = getRunSubdir(device, model);
const runReportDir =
subdir === '.' ? reportDir : path.join(reportDir, subdir);
fs.mkdirSync(runReportDir, {recursive: true});
fs.mkdirSync(path.join(runReportDir, 'screenshots'), {recursive: true});
console.log(`\n${'='.repeat(60)}`);
console.log(`Run: ${label}`);
console.log(`Spec: ${spec} | Config: ${configFile}`);
if (device) {
console.log(
`Device: ${device.deviceName} (${device.type}) | Port: ${appiumPort}`,
);
}
if (model) {
console.log(`Model: ${model.id} (${model.downloadFile})`);
}
console.log(`${'='.repeat(60)}\n`);
// Build env vars
const env: Record<string, string> = {
...(process.env as Record<string, string>),
DEVICEFARM_LOG_DIR: runReportDir,
DEVICEFARM_SCREENSHOT_PATH: path.join(runReportDir, 'screenshots'),
};
if (model) {
env.TEST_MODELS = model.id;
}
if (device) {
env.E2E_DEVICE_NAME = device.deviceName;
env.E2E_PLATFORM_VERSION = device.platformVersion;
env.E2E_APPIUM_PORT = String(appiumPort);
if (device.udid) {
env.E2E_DEVICE_UDID = device.udid;
}
if (device.appPath) {
env.E2E_APP_PATH = device.appPath;
}
if (device.xcodeOrgId) {
env.E2E_XCODE_ORG_ID = device.xcodeOrgId;
}
if (device.xcodeSigningId) {
env.E2E_XCODE_SIGNING_ID = device.xcodeSigningId;
}
}
try {
execSync(`npx wdio ${configFile} ${specArg}`, {
stdio: 'inherit',
cwd: E2E_DIR,
env,
});
const duration = Date.now() - startTime;
console.log(
`\n[PASS] ${label} completed in ${(duration / 1000).toFixed(1)}s\n`,
);
return {
deviceId: device?.id ?? null,
deviceName: device?.name ?? null,
devicePlatform: platform,
deviceType: device?.type ?? null,
modelId: model?.id ?? null,
spec,
success: true,
duration,
};
} catch (error) {
const duration = Date.now() - startTime;
const errorMessage = error instanceof Error ? error.message : String(error);
console.log(
`\n[FAIL] ${label} failed after ${(duration / 1000).toFixed(1)}s\n`,
);
return {
deviceId: device?.id ?? null,
deviceName: device?.name ?? null,
devicePlatform: platform,
deviceType: device?.type ?? null,
modelId: model?.id ?? null,
spec,
success: false,
duration,
error: errorMessage,
};
}
}
// ---------------------------------------------------------------------------
// JUnit Merging
// ---------------------------------------------------------------------------
function mergeJUnitReports(reportDir: string): void {
const junitFiles = findJunitFiles(reportDir);
if (junitFiles.length === 0) {
console.log('No JUnit files found to merge');
return;
}
let totalTests = 0;
let totalFailures = 0;
let totalErrors = 0;
let totalSkipped = 0;
const testSuites: string[] = [];
for (const filePath of junitFiles) {
const content = fs.readFileSync(filePath, 'utf8');
const suiteMatch = content.match(/<testsuite[\s\S]*?<\/testsuite>/g);
if (suiteMatch) {
for (const suite of suiteMatch) {
testSuites.push(suite);
const testsMatch = suite.match(/tests="(\d+)"/);
const failuresMatch = suite.match(/failures="(\d+)"/);
const errorsMatch = suite.match(/errors="(\d+)"/);
const skippedMatch = suite.match(/skipped="(\d+)"/);
if (testsMatch) {
totalTests += parseInt(testsMatch[1], 10);
}
if (failuresMatch) {
totalFailures += parseInt(failuresMatch[1], 10);
}
if (errorsMatch) {
totalErrors += parseInt(errorsMatch[1], 10);
}
if (skippedMatch) {
totalSkipped += parseInt(skippedMatch[1], 10);
}
}
}
}
const mergedXml = `<?xml version="1.0" encoding="UTF-8"?>
<testsuites tests="${totalTests}" failures="${totalFailures}" errors="${totalErrors}" skipped="${totalSkipped}">
${testSuites.join('\n')}
</testsuites>`;
const mergedPath = path.join(reportDir, 'junit-results.xml');
fs.writeFileSync(mergedPath, mergedXml);
console.log(
`\nMerged ${junitFiles.length} JUnit reports into: ${mergedPath}`,
);
console.log(
` Total: ${totalTests} tests, ${totalFailures} failures, ${totalErrors} errors, ${totalSkipped} skipped`,
);
}
// ---------------------------------------------------------------------------
// Model Reports (cumulative report from quick-smoke / load-stress specs)
// ---------------------------------------------------------------------------
interface ModelReport {
model: string;
prompt: string;
response: string;
timing: string;
timestamp: string;
success: boolean;
}
function printModelReports(reportDir: string): void {
const reportPath = path.join(reportDir, 'all-models-report.json');
if (!fs.existsSync(reportPath)) {
return;
}
try {
const reports: ModelReport[] = JSON.parse(
fs.readFileSync(reportPath, 'utf8'),
);
if (reports.length === 0) {
return;
}
console.log(`\n${'='.repeat(60)}`);
console.log('MODEL INFERENCE RESULTS');
console.log('='.repeat(60));
for (const report of reports) {
console.log(`\n${report.model}:`);
console.log(` Timing: ${report.timing}`);
console.log(
` Response: ${report.response.substring(0, 100)}${report.response.length > 100 ? '...' : ''}`,
);
}
console.log();
} catch {
// Ignore parse errors
}
}
// ---------------------------------------------------------------------------
// Summary
// ---------------------------------------------------------------------------
function printSummary(results: TestRunResult[]): void {
console.log(`\n${'='.repeat(60)}`);
console.log('TEST SUMMARY');
console.log(`${'='.repeat(60)}\n`);
const passed = results.filter(r => r.success);
const failed = results.filter(r => !r.success);
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
console.log(`Total runs: ${results.length}`);
console.log(`Passed: ${passed.length}`);
console.log(`Failed: ${failed.length}`);
console.log(`Duration: ${(totalDuration / 1000 / 60).toFixed(1)} minutes\n`);
if (passed.length > 0) {
console.log('Passed:');
passed.forEach(r => {
const label = [r.deviceName, r.modelId].filter(Boolean).join(' / ');
console.log(
` [PASS] ${label || r.devicePlatform} (${(r.duration / 1000).toFixed(1)}s)`,
);
});
console.log();
}
if (failed.length > 0) {
console.log('Failed:');
failed.forEach(r => {
const label = [r.deviceName, r.modelId].filter(Boolean).join(' / ');
console.log(
` [FAIL] ${label || r.devicePlatform} (${(r.duration / 1000).toFixed(1)}s)`,
);
});
console.log();
}
}
function writeSummary(
reportDir: string,
args: RunArgs,
results: TestRunResult[],
gitInfo: {branch: string; commit: string},
timestamp: string,
): void {
const passed = results.filter(r => r.success).length;
const failed = results.filter(r => !r.success).length;
const totalDuration = results.reduce((sum, r) => sum + r.duration, 0);
const summary: RunSummary = {
timestamp,
branch: gitInfo.branch,
commit: gitInfo.commit,
platform: args.platform,
spec: args.spec,
mode: args.mode,
totalRuns: results.length,
passed,
failed,
totalDuration,
results,
};
const summaryPath = path.join(reportDir, 'summary.json');
fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
console.log(`Summary written to: ${summaryPath}`);
}
// ---------------------------------------------------------------------------
// Dry Run
// ---------------------------------------------------------------------------
function printDryRun(
args: RunArgs,
platforms: Array<'ios' | 'android'>,
devices: Array<DeviceConfig | null>,
models: Array<ModelTestConfig | null>,
reportDir: string,
gitInfo: {branch: string; commit: string},
): void {
console.log(`\n${'='.repeat(60)}`);
console.log('DRY RUN - No tests will be executed');
console.log(`${'='.repeat(60)}\n`);
console.log(`Branch: ${gitInfo.branch}`);
console.log(`Commit: ${gitInfo.commit}`);
console.log(`Platform: ${args.platform}`);
console.log(`Spec: ${args.spec}`);
console.log(`Mode: ${args.mode}`);
console.log(`Report dir: ${reportDir}`);
console.log(`Skip build: ${args.skipBuild}`);
console.log(
`Devices: ${devices[0] === null ? 'default (1)' : `${devices.length} matched`}`,
);
console.log(
`Models: ${models[0] === null ? 'default (spec decides)' : `${models.length} matched`}`,
);
if (!args.skipBuild && args.mode === 'local') {
console.log('\nBuild commands:');
if (args.platform === 'ios' || args.platform === 'both') {
console.log(` yarn ios:build:e2e (cwd: ${REPO_ROOT})`);
}
if (args.platform === 'android' || args.platform === 'both') {
console.log(
` cd android && E2E_BUILD=true ./gradlew assembleE2eReleaseE2e (cwd: ${REPO_ROOT})`,
);
}
}
console.log('\nTest runs:');
let runIndex = 0;
let portIndex = 0;
for (const device of devices) {
const runPlatforms = device ? [device.platform] : platforms;
for (const platform of runPlatforms) {
for (const model of models) {
runIndex++;
const configFile = getWdioConfig(platform, args.mode);
const specArg =
args.spec === 'all' ? '' : `--spec ${resolveSpecPath(args.spec)}`;
const port = args.appiumBasePort + portIndex;
const label = getRunLabel(device, model, platform);
console.log(`\n ${runIndex}. ${label}`);
console.log(` Command: npx wdio ${configFile} ${specArg}`);
if (model) {
console.log(` Env: TEST_MODELS=${model.id}`);
}
if (device) {
console.log(` E2E_DEVICE_NAME=${device.deviceName}`);
console.log(
` E2E_PLATFORM_VERSION=${device.platformVersion}`,
);
console.log(` E2E_APPIUM_PORT=${port}`);
if (device.udid) {
console.log(` E2E_DEVICE_UDID=${device.udid}`);
}
if (device.appPath) {
console.log(` E2E_APP_PATH=${device.appPath}`);
}
if (device.xcodeOrgId) {
console.log(` E2E_XCODE_ORG_ID=${device.xcodeOrgId}`);
}
}
}
}
portIndex++;
}
console.log();
}
// ---------------------------------------------------------------------------
// Main
// ---------------------------------------------------------------------------
async function main(): Promise<void> {
const args = parseArgs();
if (args.help) {
printHelp();
return;
}
if (args.listModels) {
listModels();
return;
}
const gitInfo = getGitInfo();
const timestamp = new Date()
.toISOString()
.replace(/[:.]/g, '-')
.replace('Z', '');
const reportDir = args.reportDir || path.join(REPORTS_DIR, timestamp);
// Resolve platforms
const platforms: Array<'ios' | 'android'> =
args.platform === 'both' ? ['ios', 'android'] : [args.platform];
// Resolve devices
let devices: Array<DeviceConfig | null>;
if (args.eachDevice) {
const allDevices = loadDevices();
const filtered = filterDevices(allDevices, args.platform, args.devices);
if (filtered.length === 0) {
console.error('Error: No devices matched the filter criteria.');
console.error(`Platform: ${args.platform}, Filter: ${args.devices}`);
console.error(
'Check devices.json and ensure matching devices have "enabled": true',
);
process.exit(1);
}
devices = filtered;
} else {
devices = [null];
}
// Resolve models
let models: Array<ModelTestConfig | null>;
if (args.eachModel || args.models) {
const pool = getModelsForIteration(args.models, args.allModels);
models = pool;
} else {
models = [null];
}
// Dry run
if (args.dryRun) {
printDryRun(args, platforms, devices, models, reportDir, gitInfo);
if (!args.skipBuild && args.mode === 'local') {
buildApps(args.platform, true, args.spec);
}
return;
}
// Create report directory
fs.mkdirSync(reportDir, {recursive: true});
// Build step
if (!args.skipBuild && args.mode === 'local') {
console.log('Building apps...\n');
buildApps(args.platform, false, args.spec);
} else if (args.skipBuild) {
console.log('Skipping build step (--skip-build)\n');
}
// Print banner
console.log(`\n${'='.repeat(60)}`);
console.log('E2E Test Runner');
console.log(`${'='.repeat(60)}`);
console.log(`Branch: ${gitInfo.branch} | Commit: ${gitInfo.commit}`);
console.log(
`Platform: ${args.platform} | Spec: ${args.spec} | Mode: ${args.mode}`,
);
console.log(
`Devices: ${devices[0] === null ? 'default' : devices.length}`,
);
console.log(
`Models: ${models[0] === null ? 'default' : (models as ModelTestConfig[]).map(m => m.id).join(', ')}`,
);
console.log(`${'='.repeat(60)}\n`);
// Run tests
const results: TestRunResult[] = [];
let portIndex = 0;
for (const device of devices) {
const appiumPort = args.appiumBasePort + portIndex;
const runPlatforms = device ? [device.platform] : platforms;
for (const platform of runPlatforms) {
for (const model of models) {
const result = runSingleTest({
spec: args.spec,
platform,
mode: args.mode,
model,
device,
appiumPort,
reportDir,
});
results.push(result);
}
}
portIndex++;
}
// Print results
printSummary(results);
printModelReports(reportDir);
mergeJUnitReports(reportDir);
writeSummary(reportDir, args, results, gitInfo, timestamp);
// Exit with error code if any tests failed
const hasFailures = results.some(r => !r.success);
process.exit(hasFailures ? 1 : 0);
}
main().catch(error => {
console.error('Runner failed:', error);
process.exit(1);
});