Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## [0.0.40] (2026-05-20)
* Feat: `config.driver` now accepts a driver instance — third-party drivers can implement `MobilewrightDriver` from `@mobilewright/protocol` without depending on the framework package
* Feat: `MobilewrightDriver` interface now includes `allocate()`, `release()`, `setup()`, and `teardown()` — one class covers both coordinator and worker roles
* Feat: `MobilecliDriver` and `MobileNextDriver` both implement the unified `MobilewrightDriver` interface; `MobileNextDriver` manages per-device sessions internally
* Feat: `NoDeviceAvailableError`, `AllocationCriteria`, and `AllocateResult` are now exported from `@mobilewright/protocol` for use by third-party driver authors
* Feat: `resolveDriver()` compat shim — the legacy `{ type: 'mobilecli' }` object form still works but logs a deprecation warning
* Feat: `DummyDriver` reference implementation in `@mobilewright/test` — shows the minimal surface a third-party driver must implement

## [0.0.39] (2026-05-21)
* Feat: add test step instrumentation for HTML reporter ([#144](https://github.com/mobile-next/mobilewright/pull/144))
* Feat: add Dockerfile for mobilewright image, multi-arch for arm64 and amd64 ([#143](https://github.com/mobile-next/mobilewright/pull/143))
Expand All @@ -9,6 +17,7 @@
* General: skip redundant `mobilecli devices` shell-outs in `connect()` and `installApp()` when device type is already known, reducing test startup time by ~4s
* General: break early if installApps points to non-zip containers, before allocating devices
* General: added plenty of verbose logs when `DEBUG=mw:*`
>>>>>>> main

## [0.0.37] (2026-05-16)
* Feat: add installApps to per-project overrides ([#133](https://github.com/mobile-next/mobilewright/pull/133))
Expand Down
21 changes: 10 additions & 11 deletions e2e/mobilewright.config.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import { defineConfig } from 'mobilewright';
import type { DriverConfig, MobilewrightConfig } from 'mobilewright';
import type { MobilewrightConfig } from 'mobilewright';
import { MobilecliDriver } from '@mobilewright/driver-mobilecli';
import { MobileNextDriver } from '@mobilewright/driver-mobilenext';
import type { MobilewrightDriver } from '@mobilewright/protocol';

function resolveDriver(): DriverConfig {
function resolveDriver(): MobilewrightDriver {
const name = process.env['MOBILEWRIGHT_DRIVER'] ?? 'mobilecli';
console.log(`Using driver: ${name}`);

Expand All @@ -10,17 +13,13 @@ function resolveDriver(): DriverConfig {
if (!process.env['MOBILENEXT_API_KEY']) {
throw new Error('MOBILENEXT_API_KEY is required for mobilenext driver');
}

return {
type: 'mobilenext',
apiKey: process.env['MOBILENEXT_API_KEY'],
};
return new MobileNextDriver({ apiKey: process.env['MOBILENEXT_API_KEY'] });

case 'mobilecli':
return { type: 'mobilecli' };
case 'mobilecli':
return new MobilecliDriver();

default:
throw new Error(`Unknown driver: ${name}. Use ['mobilecli' or 'mobilenext']`);
throw new Error(`Unknown driver: ${name}. Use 'mobilecli' or 'mobilenext'`);
}
}

Expand All @@ -34,7 +33,7 @@ const config: MobilewrightConfig = defineConfig({
// parallel by test() instead of parallel by file
fullyParallel: true,

// supports mobilecli and mobilenext drivers
// pass a driver instance — any class implementing MobilewrightDriver works
driver: resolveDriver(),

// filter used devices with regexp
Expand Down
64 changes: 62 additions & 2 deletions packages/driver-mobilecli/src/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import createDebug from 'debug';
import { execFileSync } from 'node:child_process';
import { openSync, readSync, closeSync } from 'node:fs';
import type {
AllocateResult,
AllocationCriteria,
AppInfo,
ConnectionConfig,
DeviceInfo,
Expand All @@ -23,8 +25,10 @@ import type {
SwipeOptions,
ViewNode,
} from '@mobilewright/protocol';
import { NoDeviceAvailableError } from '@mobilewright/protocol';
import { RpcClient } from './rpc-client.js';
import { resolveMobilecliBinary } from './resolve-binary.js';
import { ensureMobilecliReachable, type ServerHandle } from './server.js';

export const DEFAULT_URL = 'ws://localhost:12000/ws';

Expand Down Expand Up @@ -150,14 +154,70 @@ function assertValidZipFile(path: string): void {
const debug = createDebug('mw:driver-mobilecli');

export class MobilecliDriver implements MobilewrightDriver {
readonly name = 'mobilecli';

private session: { deviceId: string; deviceName: string; platform: Platform; deviceType: DeviceType; rpc: RpcClient } | null = null;
private readonly serverUrl: string;
private readonly autoStart: boolean;
private serverHandle: ServerHandle | undefined;

constructor(opts?: { url?: string }) {
constructor(opts?: { url?: string; autoStart?: boolean }) {
this.serverUrl = opts?.url ?? DEFAULT_URL;
this.autoStart = opts?.autoStart ?? true;
}

// ─── Pool management (coordinator-side) ─────────────────────

async setup(): Promise<void> {
const result = await ensureMobilecliReachable(this.serverUrl, { autoStart: this.autoStart });
this.serverHandle = result.serverProcess;
}

async teardown(): Promise<void> {
await this.serverHandle?.kill();
this.serverHandle = undefined;
}

async allocate(
criteria: AllocationCriteria,
takenDeviceIds: ReadonlySet<string>,
_signal?: AbortSignal,
): Promise<AllocateResult> {
const devices = await this.listDevices(
criteria.platform ? { platform: criteria.platform } : undefined,
);

const namePattern = criteria.deviceNamePattern
? new RegExp(criteria.deviceNamePattern)
: undefined;

const match = devices
.filter((d) => d.state === 'online')
.filter((d) => !takenDeviceIds.has(d.id))
.filter((d) => !criteria.deviceId || d.id === criteria.deviceId)
.filter((d) => !namePattern || namePattern.test(d.name))
.at(0);

if (!match) {
throw new NoDeviceAvailableError(
`no online device available matching criteria ${JSON.stringify(criteria)}`,
);
}
return {
deviceId: match.id,
platform: match.platform,
driver: this.name,
model: match.model,
osVersion: match.osVersion,
type: match.type,
};
}

async release(_deviceId: string): Promise<void> {
// Local devices don't need to be released.
}

// ─── Connection ──────────────────────────────────────────────
// ─── Connection (worker-side) ────────────────────────────────

async connect(config: ConnectionConfig): Promise<Session> {
const url = config.url ?? this.serverUrl;
Expand Down
1 change: 1 addition & 0 deletions packages/driver-mobilecli/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { MobilecliDriver, DEFAULT_URL } from './driver.js';
export { resolveMobilecliBinary } from './resolve-binary.js';
export { RpcClient, RpcError } from './rpc-client.js';
export { ensureMobilecliReachable, startMobilecliServer, isLocalUrl, type ServerHandle } from './server.js';
103 changes: 103 additions & 0 deletions packages/driver-mobilecli/src/server.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { spawn, type ChildProcess } from 'node:child_process';
import WebSocket from 'ws';
import { resolveMobilecliBinary } from './resolve-binary.js';

const HEALTH_CHECK_TIMEOUT = 5_000;
const SERVER_START_TIMEOUT = 10_000;
const SERVER_POLL_INTERVAL = 500;

export interface ServerHandle {
process: ChildProcess;
kill: () => Promise<void>;
}

function sleep(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

export function isLocalUrl(url: string): boolean {
try {
const host = new URL(url).hostname;
return host === 'localhost' || host === '127.0.0.1' || host === '::1';
} catch {
return true;
}
}

export async function startMobilecliServer(opts?: {
binaryPath?: string;
port?: number;
}): Promise<ServerHandle> {
const binary = opts?.binaryPath ?? 'mobilecli';
const port = opts?.port ?? 12000;

const proc = spawn(binary, ['server', 'start', '--listen', `localhost:${port}`], {
stdio: 'pipe',
detached: false,
});

const wsUrl = `ws://localhost:${port}/ws`;
const deadline = Date.now() + SERVER_START_TIMEOUT;

while (Date.now() < deadline) {
if (await checkWebSocket(wsUrl, 1_000)) {
return {
process: proc,
kill: async () => {
proc.kill('SIGTERM');
await new Promise<void>((resolve) => {
const timer = setTimeout(() => { proc.kill('SIGKILL'); resolve(); }, 3_000);
proc.on('exit', () => { clearTimeout(timer); resolve(); });
});
},
};
}
await sleep(SERVER_POLL_INTERVAL);
}

proc.kill('SIGTERM');
throw new Error(
`mobilecli server did not become ready within ${SERVER_START_TIMEOUT / 1000}s.\n` +
`Try starting it manually with: ${binary} server start`,
);
}

export async function ensureMobilecliReachable(
url: string,
opts?: { autoStart?: boolean },
): Promise<{ serverProcess?: ServerHandle }> {
if (await checkWebSocket(url, HEALTH_CHECK_TIMEOUT)) return {};

if (!isLocalUrl(url)) {
throw new Error(
`Cannot reach mobilecli server at ${url}.\n\n` +
'Ensure the remote server is running and accessible.',
);
}

let binaryPath: string | null;
try { binaryPath = resolveMobilecliBinary(); } catch { binaryPath = null; }

if (opts?.autoStart && binaryPath) {
let port = 12000;
try { port = Number(new URL(url).port) || 12000; } catch { /* default */ }
const handle = await startMobilecliServer({ binaryPath, port });
return { serverProcess: handle };
}

const hint = binaryPath
? 'Start it with:\n mobilecli server start'
: 'Install mobilecli from:\n https://github.com/mobile-next/mobilecli\n\n' +
'Then start the server with:\n mobilecli server start';

throw new Error(`mobilecli server is not running at ${url}.\n\n${hint}`);
}

function checkWebSocket(url: string, timeout: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const ws = new WebSocket(url);
const timer = setTimeout(() => { ws.terminate(); resolve(false); }, timeout);
ws.on('open', () => { clearTimeout(timer); ws.close(); resolve(true); });
ws.on('error', () => { clearTimeout(timer); resolve(false); });
});
}
42 changes: 41 additions & 1 deletion packages/driver-mobilenext/src/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ import { stat } from 'node:fs/promises';
import { basename } from 'node:path';
import createDebug from 'debug';
import type {
AllocateResult,
AllocationCriteria,
AppInfo,
ConnectionConfig,
DeviceInfo,
Expand Down Expand Up @@ -210,9 +212,14 @@ type DeviceFilter =
const debug = createDebug('mw:driver-mobilenext');

export class MobileNextDriver implements MobilewrightDriver {
readonly name = 'mobilenext';

private session: ActiveSession | null = null;
private readonly options: MobileNextDriverOptions;
private ownsLease = false;
// Coordinator-side: one entry per device allocated via allocate().
// Independent from the worker-side `session` field.
private readonly allocatedSessions = new Map<string, MobileNextDriver>();

get deviceInfo(): MobileNextDeviceInfo | null {
if (!this.session) {
Expand All @@ -225,7 +232,40 @@ export class MobileNextDriver implements MobilewrightDriver {
this.options = options;
}

// ─── Connection ──────────────────────────────────────────────
// ─── Pool management (coordinator-side) ─────────────────────

async allocate(
criteria: AllocationCriteria,
_takenDeviceIds: ReadonlySet<string>,
_signal?: AbortSignal,
): Promise<AllocateResult> {
const holder = new MobileNextDriver(this.options);
const result = await holder.connect({
platform: criteria.platform ?? 'ios',
deviceId: criteria.deviceId,
deviceName: criteria.deviceNamePattern ? new RegExp(criteria.deviceNamePattern) : undefined,
});
this.allocatedSessions.set(result.deviceId, holder);
const info = holder.deviceInfo;
return {
deviceId: result.deviceId,
platform: result.platform,
driver: this.name,
model: info?.model ?? undefined,
osVersion: info?.osVersion ?? undefined,
type: info?.type ?? undefined,
};
}

async release(deviceId: string): Promise<void> {
const holder = this.allocatedSessions.get(deviceId);
if (holder) {
this.allocatedSessions.delete(deviceId);
await holder.disconnect();
}
}

// ─── Connection (worker-side) ────────────────────────────────

async connect(config: ConnectionConfig): Promise<Session> {
const baseUrl = config.url ?? DEFAULT_URL;
Expand Down
3 changes: 3 additions & 0 deletions packages/mobilewright-core/src/expect.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,9 @@ function createMockDriver(hierarchy: ViewNode[]): MobilewrightDriver & { _tracke
return {
_tracker: tracker,
_setHierarchy: (h: ViewNode[]) => { currentHierarchy = h; },
name: 'mock',
allocate: async () => ({ deviceId: 'device1', platform: 'ios' as const }),
release: async () => {},
connect: async () => ({ deviceId: 'device1', platform: 'ios' as const }),
disconnect: async () => {},
getViewHierarchy: async () => currentHierarchy,
Expand Down
3 changes: 3 additions & 0 deletions packages/mobilewright-core/src/locator.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ function createMockDriver(hierarchy: ViewNode[]): MobilewrightDriver & { _tracke
return {
_tracker: tracker,
_setHierarchy: (h: ViewNode[]) => { currentHierarchy = h; },
name: 'mock',
allocate: async () => ({ deviceId: 'device1', platform: 'ios' as const }),
release: async () => {},
connect: async () => ({ deviceId: 'device1', platform: 'ios' as const }),
disconnect: async () => {},
getViewHierarchy: async () => currentHierarchy,
Expand Down
Loading