diff --git a/CHANGELOG.md b/CHANGELOG.md index 04b4bba..bcf2d76 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) @@ -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)) diff --git a/e2e/mobilewright.config.ts b/e2e/mobilewright.config.ts index 3c00d80..7abb5c8 100644 --- a/e2e/mobilewright.config.ts +++ b/e2e/mobilewright.config.ts @@ -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}`); @@ -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'`); } } @@ -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 diff --git a/packages/driver-mobilecli/src/driver.ts b/packages/driver-mobilecli/src/driver.ts index c3df114..2e90a3b 100644 --- a/packages/driver-mobilecli/src/driver.ts +++ b/packages/driver-mobilecli/src/driver.ts @@ -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, @@ -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'; @@ -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 { + const result = await ensureMobilecliReachable(this.serverUrl, { autoStart: this.autoStart }); + this.serverHandle = result.serverProcess; + } + + async teardown(): Promise { + await this.serverHandle?.kill(); + this.serverHandle = undefined; + } + + async allocate( + criteria: AllocationCriteria, + takenDeviceIds: ReadonlySet, + _signal?: AbortSignal, + ): Promise { + 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 { + // Local devices don't need to be released. } - // ─── Connection ────────────────────────────────────────────── + // ─── Connection (worker-side) ──────────────────────────────── async connect(config: ConnectionConfig): Promise { const url = config.url ?? this.serverUrl; diff --git a/packages/driver-mobilecli/src/index.ts b/packages/driver-mobilecli/src/index.ts index 8d12458..1fb9e5c 100644 --- a/packages/driver-mobilecli/src/index.ts +++ b/packages/driver-mobilecli/src/index.ts @@ -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'; diff --git a/packages/driver-mobilecli/src/server.ts b/packages/driver-mobilecli/src/server.ts new file mode 100644 index 0000000..3c84070 --- /dev/null +++ b/packages/driver-mobilecli/src/server.ts @@ -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; +} + +function sleep(ms: number): Promise { + 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 { + 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((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 { + return new Promise((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); }); + }); +} diff --git a/packages/driver-mobilenext/src/driver.ts b/packages/driver-mobilenext/src/driver.ts index 9ef9e52..4192b12 100644 --- a/packages/driver-mobilenext/src/driver.ts +++ b/packages/driver-mobilenext/src/driver.ts @@ -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, @@ -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(); get deviceInfo(): MobileNextDeviceInfo | null { if (!this.session) { @@ -225,7 +232,40 @@ export class MobileNextDriver implements MobilewrightDriver { this.options = options; } - // ─── Connection ────────────────────────────────────────────── + // ─── Pool management (coordinator-side) ───────────────────── + + async allocate( + criteria: AllocationCriteria, + _takenDeviceIds: ReadonlySet, + _signal?: AbortSignal, + ): Promise { + 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 { + const holder = this.allocatedSessions.get(deviceId); + if (holder) { + this.allocatedSessions.delete(deviceId); + await holder.disconnect(); + } + } + + // ─── Connection (worker-side) ──────────────────────────────── async connect(config: ConnectionConfig): Promise { const baseUrl = config.url ?? DEFAULT_URL; diff --git a/packages/mobilewright-core/src/expect.test.ts b/packages/mobilewright-core/src/expect.test.ts index d1b3b76..0b527ff 100644 --- a/packages/mobilewright-core/src/expect.test.ts +++ b/packages/mobilewright-core/src/expect.test.ts @@ -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, diff --git a/packages/mobilewright-core/src/locator.test.ts b/packages/mobilewright-core/src/locator.test.ts index ffd2144..2cd95fe 100644 --- a/packages/mobilewright-core/src/locator.test.ts +++ b/packages/mobilewright-core/src/locator.test.ts @@ -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, diff --git a/packages/mobilewright/src/config.ts b/packages/mobilewright/src/config.ts index c9b9cc3..6e0835d 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -2,6 +2,9 @@ import { access } from 'node:fs/promises'; import { isAbsolute, join, resolve } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createRequire } from 'node:module'; +import type { MobilewrightDriver } from '@mobilewright/protocol'; +import { MobilecliDriver } from '@mobilewright/driver-mobilecli'; +import { MobileNextDriver } from '@mobilewright/driver-mobilenext'; const _require = createRequire(import.meta.url); @@ -79,8 +82,13 @@ export interface MobilewrightConfig { mobilecliPath?: string; /** Auto-start mobilecli server if not running. Default: true. */ autoStart?: boolean; - /** Driver to use. Default: { type: 'mobilecli' }. */ - driver?: DriverConfig; + /** + * Driver to use. Pass an instance for custom/third-party drivers: + * driver: new MobilecliDriver() + * driver: new MobileNextDriver({ apiKey }) + * The legacy object form `{ type: 'mobilecli' }` still works but is deprecated. + */ + driver?: MobilewrightDriver | DriverConfig; // ── Test runner ───────────────────────────────────────────── /** Directory to search for test files. Default: config file directory. */ @@ -135,6 +143,40 @@ export function defineConfig(config: MobilewrightConfig): MobilewrightConfig { }; } +/** + * Resolve config.driver to a MobilewrightDriver instance. + * Accepts either an instance (new MobilecliDriver()) or the legacy object form + * ({ type: 'mobilecli' }). The legacy form is deprecated and logs a warning. + */ +export function resolveDriver(config: MobilewrightConfig): MobilewrightDriver { + const { driver } = config; + + if (driver && typeof (driver as MobilewrightDriver).allocate === 'function') { + return driver as MobilewrightDriver; + } + + const legacyConfig = driver as DriverConfig | undefined; + const type = legacyConfig?.type ?? 'mobilecli'; + + if (legacyConfig) { + console.warn( + `[mobilewright] config.driver as an object ({ type: '${type}' }) is deprecated. ` + + `Pass a driver instance instead, e.g. new ${type === 'mobilecli' ? 'MobilecliDriver' : 'MobileNextDriver'}()`, + ); + } + + if (!legacyConfig || type === 'mobilecli') { + return new MobilecliDriver({ url: config.url, autoStart: config.autoStart }); + } + + if (type === 'mobile-use' || type === 'mobilenext') { + const mobileNextConfig = legacyConfig as DriverConfigMobileNext; + return new MobileNextDriver({ region: mobileNextConfig.region, apiKey: mobileNextConfig.apiKey }); + } + + throw new Error(`[mobilewright] Unknown driver type: "${(legacyConfig as DriverConfig).type}"`); +} + const CONFIG_FILES = [ 'mobilewright.config.ts', 'mobilewright.config.js', diff --git a/packages/mobilewright/src/device-pool/adapters/http-client.test.ts b/packages/mobilewright/src/device-pool/adapters/http-client.test.ts index 695f451..368fa33 100644 --- a/packages/mobilewright/src/device-pool/adapters/http-client.test.ts +++ b/packages/mobilewright/src/device-pool/adapters/http-client.test.ts @@ -2,14 +2,15 @@ import { test, expect } from '@playwright/test'; import { DevicePool } from '../application/device-pool.js'; import { DevicePoolHttpServer } from './http-server.js'; import { HttpDevicePoolClient } from './http-client.js'; -import type { AllocateResult, DeviceAllocator } from '../application/ports.js'; +import type { AllocateResult, MobilewrightDriver } from '@mobilewright/protocol'; -function makeAllocator(devices: AllocateResult[]): DeviceAllocator { +function makeDriver(devices: AllocateResult[]): MobilewrightDriver { let i = 0; return { + name: 'test', async allocate() { return devices[i++ % devices.length]; }, async release() {}, - }; + } as unknown as MobilewrightDriver; } interface ServerHandle { @@ -31,7 +32,7 @@ async function startServerAndClient(pool: DevicePool): Promise { test('client.allocate returns a handle from the server', async () => { const pool = new DevicePool({ - allocator: makeAllocator([{ deviceId: 'd1', platform: 'ios' }]), + driver: makeDriver([{ deviceId: 'd1', platform: 'ios' }]), maxSlots: 1, }); const { client, stop } = await startServerAndClient(pool); @@ -47,7 +48,7 @@ test('client.allocate returns a handle from the server', async () => { test('client.release frees the device for a subsequent allocate', async () => { const pool = new DevicePool({ - allocator: makeAllocator([{ deviceId: 'd1', platform: 'ios' }]), + driver: makeDriver([{ deviceId: 'd1', platform: 'ios' }]), maxSlots: 1, }); const { client, stop } = await startServerAndClient(pool); @@ -65,7 +66,7 @@ test('client.release frees the device for a subsequent allocate', async () => { test('install-tracking round-trip via client', async () => { const pool = new DevicePool({ - allocator: makeAllocator([{ deviceId: 'd1', platform: 'ios' }]), + driver: makeDriver([{ deviceId: 'd1', platform: 'ios' }]), maxSlots: 1, }); const { client, stop } = await startServerAndClient(pool); diff --git a/packages/mobilewright/src/device-pool/adapters/http-server.test.ts b/packages/mobilewright/src/device-pool/adapters/http-server.test.ts index 19d139e..234f480 100644 --- a/packages/mobilewright/src/device-pool/adapters/http-server.test.ts +++ b/packages/mobilewright/src/device-pool/adapters/http-server.test.ts @@ -1,17 +1,18 @@ import { test, expect } from '@playwright/test'; import { request as httpRequest } from 'node:http'; import { DevicePool } from '../application/device-pool.js'; -import type { DeviceAllocator, AllocateResult } from '../application/ports.js'; +import type { AllocateResult, MobilewrightDriver } from '@mobilewright/protocol'; import { DevicePoolHttpServer } from './http-server.js'; -function makeAllocator(devices: AllocateResult[]): DeviceAllocator { +function makeDriver(devices: AllocateResult[]): MobilewrightDriver { let i = 0; return { + name: 'test', async allocate() { return devices[i++ % devices.length]; }, async release() {}, - }; + } as unknown as MobilewrightDriver; } interface ServerHandle { @@ -49,7 +50,7 @@ function postAllocateAndReadFirstLine(url: string, body: string): Promise { const pool = new DevicePool({ - allocator: makeAllocator([{ deviceId: 'd1', platform: 'ios' }]), + driver: makeDriver([{ deviceId: 'd1', platform: 'ios' }]), maxSlots: 1, }); const server = await startServer(pool); @@ -78,7 +79,7 @@ function postReleaseRequest(url: string, allocationId: string): Promise test('POST /release frees the slot for the next allocate', async () => { const pool = new DevicePool({ - allocator: makeAllocator([{ deviceId: 'd1', platform: 'ios' }]), + driver: makeDriver([{ deviceId: 'd1', platform: 'ios' }]), maxSlots: 1, }); const server = await startServer(pool); @@ -128,7 +129,7 @@ function startAllocateRequest(url: string, body: string): Promise<{ test('closing the /allocate socket releases the allocation', async () => { const pool = new DevicePool({ - allocator: makeAllocator([{ deviceId: 'd1', platform: 'ios' }]), + driver: makeDriver([{ deviceId: 'd1', platform: 'ios' }]), maxSlots: 1, }); const server = await startServer(pool); @@ -149,7 +150,7 @@ test('closing the /allocate socket releases the allocation', async () => { test('POST /release with unknown allocationId returns 200 (idempotent)', async () => { const pool = new DevicePool({ - allocator: makeAllocator([]), + driver: makeDriver([]), maxSlots: 1, }); const server = await startServer(pool); @@ -179,7 +180,7 @@ function postJson(url: string, path: string, body: unknown): Promise<{ status: n test('/installed/is-installed and /installed/record round-trip', async () => { const pool = new DevicePool({ - allocator: makeAllocator([{ deviceId: 'd1', platform: 'ios' }]), + driver: makeDriver([{ deviceId: 'd1', platform: 'ios' }]), maxSlots: 1, }); const server = await startServer(pool); @@ -212,7 +213,7 @@ test('/installed/is-installed and /installed/record round-trip', async () => { test('/shutdown drains the pool and rejects subsequent allocates', async () => { const pool = new DevicePool({ - allocator: makeAllocator([{ deviceId: 'd1', platform: 'ios' }]), + driver: makeDriver([{ deviceId: 'd1', platform: 'ios' }]), maxSlots: 1, }); const server = await startServer(pool); diff --git a/packages/mobilewright/src/device-pool/adapters/mobilecli-allocator.test.ts b/packages/mobilewright/src/device-pool/adapters/mobilecli-allocator.test.ts deleted file mode 100644 index 207730a..0000000 --- a/packages/mobilewright/src/device-pool/adapters/mobilecli-allocator.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -import { test, expect } from '@playwright/test'; -import type { DeviceInfo } from '@mobilewright/protocol'; -import { MobilecliAllocator } from './mobilecli-allocator.js'; -import { NoDeviceAvailableError } from '../application/ports.js'; - -interface FakeDriver { - listDevices(opts?: { platform?: string }): Promise; -} - -function makeFakeDriver(devices: DeviceInfo[]): FakeDriver { - return { - async listDevices(): Promise { - return devices; - }, - }; -} - -test('allocates the first online device matching platform', async () => { - const driver = makeFakeDriver([ - { id: 'a', name: 'iPhone 14', platform: 'ios', type: 'simulator', state: 'online' }, - { id: 'b', name: 'iPhone 16', platform: 'ios', type: 'simulator', state: 'online' }, - ]); - const allocator = new MobilecliAllocator({ driver }); - - const result = await allocator.allocate({ platform: 'ios' }, new Set()); - - expect(result.deviceId).toBe('a'); - expect(result.platform).toBe('ios'); -}); - -test('skips devices that are already taken', async () => { - const driver = makeFakeDriver([ - { id: 'a', name: 'iPhone 14', platform: 'ios', type: 'simulator', state: 'online' }, - { id: 'b', name: 'iPhone 16', platform: 'ios', type: 'simulator', state: 'online' }, - ]); - const allocator = new MobilecliAllocator({ driver }); - - const result = await allocator.allocate({ platform: 'ios' }, new Set(['a'])); - - expect(result.deviceId).toBe('b'); -}); - -test('filters by deviceNamePattern', async () => { - const driver = makeFakeDriver([ - { id: 'a', name: 'iPhone 14', platform: 'ios', type: 'simulator', state: 'online' }, - { id: 'b', name: 'iPhone 16', platform: 'ios', type: 'simulator', state: 'online' }, - ]); - const allocator = new MobilecliAllocator({ driver }); - - const result = await allocator.allocate( - { platform: 'ios', deviceNamePattern: 'iPhone 16' }, - new Set(), - ); - - expect(result.deviceId).toBe('b'); -}); - -test('filters by exact deviceId', async () => { - const driver = makeFakeDriver([ - { id: 'a', name: 'iPhone 14', platform: 'ios', type: 'simulator', state: 'online' }, - { id: 'b', name: 'iPhone 16', platform: 'ios', type: 'simulator', state: 'online' }, - ]); - const allocator = new MobilecliAllocator({ driver }); - - const result = await allocator.allocate( - { platform: 'ios', deviceId: 'b' }, - new Set(), - ); - - expect(result.deviceId).toBe('b'); -}); - -test('throws NoDeviceAvailableError when no device matches', async () => { - const driver = makeFakeDriver([ - { id: 'a', name: 'iPhone 14', platform: 'ios', type: 'simulator', state: 'offline' }, - ]); - const allocator = new MobilecliAllocator({ driver }); - - const err = await allocator.allocate({ platform: 'ios' }, new Set()).catch((e: unknown) => e); - expect(err).toBeInstanceOf(NoDeviceAvailableError); -}); - -test('release is a no-op for local devices', async () => { - const driver = makeFakeDriver([]); - const allocator = new MobilecliAllocator({ driver }); - await expect(allocator.release('whatever')).resolves.toBeUndefined(); -}); diff --git a/packages/mobilewright/src/device-pool/adapters/mobilecli-allocator.ts b/packages/mobilewright/src/device-pool/adapters/mobilecli-allocator.ts deleted file mode 100644 index 36b0067..0000000 --- a/packages/mobilewright/src/device-pool/adapters/mobilecli-allocator.ts +++ /dev/null @@ -1,54 +0,0 @@ -import type { DeviceInfo, Platform } from '@mobilewright/protocol'; -import { NoDeviceAvailableError } from '../application/ports.js'; -import type { AllocationCriteria, AllocateResult, DeviceAllocator } from '../application/ports.js'; - -interface ListDevicesOpts { - platform?: Platform; -} - -interface ListDevicesDriver { - listDevices(opts?: ListDevicesOpts): Promise; -} - -export interface MobilecliAllocatorOptions { - driver: ListDevicesDriver; -} - -export class MobilecliAllocator implements DeviceAllocator { - private readonly driver: ListDevicesDriver; - - constructor(options: MobilecliAllocatorOptions) { - this.driver = options.driver; - } - - async allocate( - criteria: AllocationCriteria, - takenDeviceIds: ReadonlySet, - ): Promise { - const devices = await this.driver.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: 'mobilecli', model: match.model, osVersion: match.osVersion, type: match.type }; - } - - async release(_deviceId: string): Promise { - // mobilecli devices are local; nothing to release. - } -} diff --git a/packages/mobilewright/src/device-pool/adapters/mobilenext-allocator.ts b/packages/mobilewright/src/device-pool/adapters/mobilenext-allocator.ts deleted file mode 100644 index b2f36ee..0000000 --- a/packages/mobilewright/src/device-pool/adapters/mobilenext-allocator.ts +++ /dev/null @@ -1,43 +0,0 @@ -import createDebug from 'debug'; -import { MobileNextDriver } from '@mobilewright/driver-mobilenext'; -import type { MobileNextDriverOptions } from '@mobilewright/driver-mobilenext'; -import type { AllocationCriteria, AllocateResult, DeviceAllocator } from '../application/ports.js'; - -const debug = createDebug('mw:device-pool:mobilenext'); - -export interface MobileNextAllocatorOptions { - driverOptions: MobileNextDriverOptions; -} - -export class MobileNextAllocator implements DeviceAllocator { - private readonly driverOptions: MobileNextDriverOptions; - private readonly activeDrivers = new Map(); - - constructor(options: MobileNextAllocatorOptions) { - this.driverOptions = options.driverOptions; - } - - async allocate(criteria: AllocationCriteria): Promise { - debug('allocating device (criteria=%o)', criteria); - const driver = new MobileNextDriver(this.driverOptions); - const session = await driver.connect({ - platform: criteria.platform ?? 'ios', - deviceName: criteria.deviceNamePattern ? new RegExp(criteria.deviceNamePattern) : undefined, - deviceId: criteria.deviceId, - }); - this.activeDrivers.set(session.deviceId, driver); - debug('allocated device %s (platform=%s)', session.deviceId, session.platform); - const info = driver.deviceInfo; - return { deviceId: session.deviceId, platform: session.platform, driver: 'mobilenext', model: info?.model, osVersion: info?.osVersion, type: info?.type }; - } - - async release(deviceId: string): Promise { - debug('releasing device %s', deviceId); - const driver = this.activeDrivers.get(deviceId); - if (driver) { - this.activeDrivers.delete(deviceId); - await driver.disconnect(); - debug('released device %s', deviceId); - } - } -} diff --git a/packages/mobilewright/src/device-pool/allocator-factory.ts b/packages/mobilewright/src/device-pool/allocator-factory.ts deleted file mode 100644 index a194181..0000000 --- a/packages/mobilewright/src/device-pool/allocator-factory.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { DEFAULT_URL, MobilecliDriver } from '@mobilewright/driver-mobilecli'; -import { ensureMobilecliReachable } from '../server.js'; -import { MobilecliAllocator } from './adapters/mobilecli-allocator.js'; -import { MobileNextAllocator } from './adapters/mobilenext-allocator.js'; -import type { MobilewrightConfig, DriverConfigMobileNext } from '../config.js'; -import type { DeviceAllocator } from './application/ports.js'; - -export interface AllocatorResult { - allocator: DeviceAllocator; - serverProcess?: { kill: () => void }; -} - -export async function createAllocator(config: MobilewrightConfig): Promise { - const driverType = config.driver?.type ?? 'mobilecli'; - - if (driverType === 'mobilecli') { - const url = config.url ?? DEFAULT_URL; - const ensured = await ensureMobilecliReachable(url, { autoStart: config.autoStart ?? true }); - const allocator = new MobilecliAllocator({ driver: new MobilecliDriver({ url }) }); - return { allocator, serverProcess: ensured.serverProcess ?? undefined }; - } - - if (driverType === 'mobilenext' || driverType === 'mobile-use') { - const mobileNextConfig = config.driver as DriverConfigMobileNext; - const allocator = new MobileNextAllocator({ - driverOptions: { - region: mobileNextConfig.region, - apiKey: mobileNextConfig.apiKey, - }, - }); - return { allocator }; - } - - throw new Error(`Unsupported driver type: "${driverType}". Supported types: "mobilecli", "mobilenext".`); -} diff --git a/packages/mobilewright/src/device-pool/application/device-pool.test.ts b/packages/mobilewright/src/device-pool/application/device-pool.test.ts index e8de169..fb24e83 100644 --- a/packages/mobilewright/src/device-pool/application/device-pool.test.ts +++ b/packages/mobilewright/src/device-pool/application/device-pool.test.ts @@ -1,11 +1,12 @@ import { test, expect } from '@playwright/test'; import { DevicePool } from './device-pool.js'; -import { NoDeviceAvailableError } from './ports.js'; -import type { DeviceAllocator, AllocateResult } from './ports.js'; +import { NoDeviceAvailableError } from '@mobilewright/protocol'; +import type { AllocateResult, MobilewrightDriver } from '@mobilewright/protocol'; -function makeAllocator(devices: AllocateResult[]): DeviceAllocator { +function makeDriver(devices: AllocateResult[]): MobilewrightDriver { let i = 0; return { + name: 'test', async allocate() { if (i >= devices.length) { throw new Error('no more fake devices'); @@ -13,12 +14,12 @@ function makeAllocator(devices: AllocateResult[]): DeviceAllocator { return devices[i++]; }, async release() { /* no-op */ }, - }; + } as unknown as MobilewrightDriver; } test('first allocate spins up a slot and returns a handle', async () => { - const allocator = makeAllocator([{ deviceId: 'd1', platform: 'ios' }]); - const pool = new DevicePool({ allocator, maxSlots: 2 }); + const driver = makeDriver([{ deviceId: 'd1', platform: 'ios' }]); + const pool = new DevicePool({ driver, maxSlots: 2 }); const handle = await pool.allocate({ platform: 'ios' }); @@ -28,8 +29,8 @@ test('first allocate spins up a slot and returns a handle', async () => { }); test('a released slot is reused by a subsequent allocate', async () => { - const allocator = makeAllocator([{ deviceId: 'd1', platform: 'ios' }]); - const pool = new DevicePool({ allocator, maxSlots: 2 }); + const driver = makeDriver([{ deviceId: 'd1', platform: 'ios' }]); + const pool = new DevicePool({ driver, maxSlots: 2 }); const first = await pool.allocate({ platform: 'ios' }); await pool.release(first.allocationId); @@ -41,14 +42,15 @@ test('a released slot is reused by a subsequent allocate', async () => { test('a second concurrent allocate when no free slot triggers parallel allocation', async () => { let calls = 0; - const allocator: DeviceAllocator = { + const driver = { + name: 'test', async allocate(): Promise { calls++; return { deviceId: `d${calls}`, platform: 'ios' }; }, async release() {}, - }; - const pool = new DevicePool({ allocator, maxSlots: 2 }); + } as unknown as MobilewrightDriver; + const pool = new DevicePool({ driver, maxSlots: 2 }); const [a, b] = await Promise.all([ pool.allocate({ platform: 'ios' }), @@ -61,8 +63,8 @@ test('a second concurrent allocate when no free slot triggers parallel allocatio }); test('waiter resolves when an existing allocated slot is released', async () => { - const allocator = makeAllocator([{ deviceId: 'd1', platform: 'ios' }]); - const pool = new DevicePool({ allocator, maxSlots: 1 }); + const driver = makeDriver([{ deviceId: 'd1', platform: 'ios' }]); + const pool = new DevicePool({ driver, maxSlots: 1 }); const first = await pool.allocate({ platform: 'ios' }); @@ -81,11 +83,12 @@ test('waiter resolves when an existing allocated slot is released', async () => test('NoDeviceAvailableError re-queues the waiter to be served on next release', async () => { // Simulates 3 workers competing for 2 devices. // Workers 0 and 1 allocate. Worker 2 gets NoDeviceAvailableError from the - // allocator. It should re-queue and be served when worker 0 releases. + // driver. It should re-queue and be served when worker 0 releases. let counter = 0; const devices = ['sim-a', 'sim-b']; - const allocator: DeviceAllocator = { - async allocate(_c, takenDeviceIds): Promise { + const driver = { + name: 'test', + async allocate(_c: unknown, takenDeviceIds: ReadonlySet): Promise { const available = devices.filter(id => !takenDeviceIds.has(id)); if (available.length === 0) { throw new NoDeviceAvailableError('no device available'); @@ -94,8 +97,8 @@ test('NoDeviceAvailableError re-queues the waiter to be served on next release', return { deviceId, platform: 'ios' }; }, async release() {}, - }; - const pool = new DevicePool({ allocator, maxSlots: 3 }); + } as unknown as MobilewrightDriver; + const pool = new DevicePool({ driver, maxSlots: 3 }); const w0 = await pool.allocate({ platform: 'ios' }); await pool.allocate({ platform: 'ios' }); @@ -116,7 +119,8 @@ test('NoDeviceAvailableError re-queues the waiter to be served on next release', test('allocation failure rejects the requesting waiter and drops the slot', async () => { let attempts = 0; - const allocator: DeviceAllocator = { + const driver = { + name: 'test', async allocate() { attempts++; if (attempts === 1) { @@ -125,8 +129,8 @@ test('allocation failure rejects the requesting waiter and drops the slot', asyn return { deviceId: `d${attempts}`, platform: 'ios' }; }, async release() {}, - }; - const pool = new DevicePool({ allocator, maxSlots: 2 }); + } as unknown as MobilewrightDriver; + const pool = new DevicePool({ driver, maxSlots: 2 }); await expect(pool.allocate({ platform: 'ios' })).rejects.toThrow('boom'); @@ -135,8 +139,8 @@ test('allocation failure rejects the requesting waiter and drops the slot', asyn }); test('FIFO order across multiple waiters', async () => { - const allocator = makeAllocator([{ deviceId: 'd1', platform: 'ios' }]); - const pool = new DevicePool({ allocator, maxSlots: 1 }); + const driver = makeDriver([{ deviceId: 'd1', platform: 'ios' }]); + const pool = new DevicePool({ driver, maxSlots: 1 }); const first = await pool.allocate({ platform: 'ios' }); @@ -156,8 +160,8 @@ test('FIFO order across multiple waiters', async () => { }); test('isAppInstalled is false until recordAppInstalled is called', async () => { - const allocator = makeAllocator([{ deviceId: 'd1', platform: 'ios' }]); - const pool = new DevicePool({ allocator, maxSlots: 1 }); + const driver = makeDriver([{ deviceId: 'd1', platform: 'ios' }]); + const pool = new DevicePool({ driver, maxSlots: 1 }); const handle = await pool.allocate({ platform: 'ios' }); expect(pool.isAppInstalled(handle.allocationId, 'app.ipa')).toBe(false); @@ -166,8 +170,8 @@ test('isAppInstalled is false until recordAppInstalled is called', async () => { }); test('install tracking persists across releases of the same slot', async () => { - const allocator = makeAllocator([{ deviceId: 'd1', platform: 'ios' }]); - const pool = new DevicePool({ allocator, maxSlots: 1 }); + const driver = makeDriver([{ deviceId: 'd1', platform: 'ios' }]); + const pool = new DevicePool({ driver, maxSlots: 1 }); const first = await pool.allocate({ platform: 'ios' }); pool.recordAppInstalled(first.allocationId, 'app.ipa'); @@ -177,17 +181,18 @@ test('install tracking persists across releases of the same slot', async () => { expect(pool.isAppInstalled(second.allocationId, 'app.ipa')).toBe(true); }); -test('shutdown calls allocator.release for every available slot', async () => { +test('shutdown calls driver.release for every available slot', async () => { const released: string[] = []; let counter = 0; - const allocator: DeviceAllocator = { + const driver = { + name: 'test', async allocate(): Promise { counter++; return { deviceId: `d${counter}`, platform: 'ios' }; }, async release(deviceId: string) { released.push(deviceId); }, - }; - const pool = new DevicePool({ allocator, maxSlots: 2 }); + } as unknown as MobilewrightDriver; + const pool = new DevicePool({ driver, maxSlots: 2 }); const a = await pool.allocate({ platform: 'ios' }); const b = await pool.allocate({ platform: 'ios' }); @@ -199,13 +204,14 @@ test('shutdown calls allocator.release for every available slot', async () => { }); test('shutdown rejects in-flight waiters', async () => { - const allocator: DeviceAllocator = { + const driver = { + name: 'test', async allocate() { return new Promise(() => {}); }, async release() {}, - }; - const pool = new DevicePool({ allocator, maxSlots: 1 }); + } as unknown as MobilewrightDriver; + const pool = new DevicePool({ driver, maxSlots: 1 }); const promise = pool.allocate({ platform: 'ios' }); await Promise.resolve(); @@ -215,15 +221,16 @@ test('shutdown rejects in-flight waiters', async () => { }); test('allocation that exceeds allocationTimeoutMs rejects with timeout error', async () => { - const allocator: DeviceAllocator = { - async allocate(_c, _t, signal) { + const driver = { + name: 'test', + async allocate(_c: unknown, _t: unknown, signal: AbortSignal) { return new Promise((_, reject) => { signal?.addEventListener('abort', () => reject(new Error('aborted'))); }); }, async release() {}, - }; - const pool = new DevicePool({ allocator, maxSlots: 1, allocationTimeoutMs: 50 }); + } as unknown as MobilewrightDriver; + const pool = new DevicePool({ driver, maxSlots: 1, allocationTimeoutMs: 50 }); await expect(pool.allocate({ platform: 'ios' })).rejects.toThrow(/timed out/i); }); diff --git a/packages/mobilewright/src/device-pool/application/device-pool.ts b/packages/mobilewright/src/device-pool/application/device-pool.ts index 29b2256..8c527d4 100644 --- a/packages/mobilewright/src/device-pool/application/device-pool.ts +++ b/packages/mobilewright/src/device-pool/application/device-pool.ts @@ -1,14 +1,14 @@ import { DeviceSlot } from '../domain/device-slot.js'; import { Allocation } from '../domain/allocation.js'; -import { NoDeviceAvailableError } from './ports.js'; +import { NoDeviceAvailableError } from '@mobilewright/protocol'; +import type { MobilewrightDriver } from '@mobilewright/protocol'; import type { AllocationCriteria, AllocationHandle, - DeviceAllocator, } from './ports.js'; export interface DevicePoolOptions { - allocator: DeviceAllocator; + driver: MobilewrightDriver; maxSlots: number; /** Per-allocation timeout in ms. Default 600_000 (10 min). */ allocationTimeoutMs?: number; @@ -21,7 +21,7 @@ interface Waiter { } export class DevicePool { - private readonly allocator: DeviceAllocator; + private readonly driver: MobilewrightDriver; private readonly maxSlots: number; private readonly allocationTimeoutMs: number; private readonly slots: DeviceSlot[] = []; @@ -31,7 +31,7 @@ export class DevicePool { private isShutdown = false; constructor(options: DevicePoolOptions) { - this.allocator = options.allocator; + this.driver = options.driver; this.maxSlots = options.maxSlots; this.allocationTimeoutMs = options.allocationTimeoutMs ?? 600_000; } @@ -62,7 +62,7 @@ export class DevicePool { const releases: Promise[] = []; for (const slot of this.slots) { if (slot.state !== 'allocating' && slot.deviceId !== undefined) { - releases.push(this.allocator.release(slot.deviceId).catch(() => {})); + releases.push(this.driver.release(slot.deviceId).catch(() => {})); } } await Promise.all(releases); @@ -157,7 +157,7 @@ export class DevicePool { let result; try { - result = await this.allocator.allocate( + result = await this.driver.allocate( waiter.criteria, this.takenDeviceIds(), abortController.signal, @@ -194,7 +194,7 @@ export class DevicePool { clearTimeout(timer); if (!this.inFlightWaiters.delete(waiter)) { // Shutdown already rejected this waiter; just discard the device. - this.allocator.release(result.deviceId).catch(() => {}); + this.driver.release(result.deviceId).catch(() => {}); return; } slot.markAvailable(result.deviceId, result.platform, result.driver, result.model, result.osVersion, result.type); diff --git a/packages/mobilewright/src/device-pool/application/ports.ts b/packages/mobilewright/src/device-pool/application/ports.ts index 7f1a6a5..89621e5 100644 --- a/packages/mobilewright/src/device-pool/application/ports.ts +++ b/packages/mobilewright/src/device-pool/application/ports.ts @@ -1,48 +1,8 @@ -import type { DeviceType, Platform } from '@mobilewright/protocol'; +import type { AllocationCriteria, DeviceType, Platform } from '@mobilewright/protocol'; -/** - * Thrown by a DeviceAllocator when no device is currently available but one - * may become available later (e.g. all matching devices are already taken). - * DevicePool treats this as a temporary condition and re-queues the waiter - * rather than rejecting it outright. - */ -export class NoDeviceAvailableError extends Error { - constructor(message: string) { - super(message); - this.name = 'NoDeviceAvailableError'; - } -} - -export interface AllocationCriteria { - platform?: Platform; - /** Serialized regex source — `RegExp.prototype.source`. The allocator reconstructs `new RegExp(...)`. */ - deviceNamePattern?: string; - deviceId?: string; -} - -export interface AllocateResult { - deviceId: string; - platform: Platform; - driver?: string; - model?: string; - osVersion?: string; - type?: DeviceType; -} - -/** - * Driver-specific allocator. Implementations are at the outer adapter layer. - * `takenDeviceIds` lets the allocator avoid handing out devices the pool already has. - */ -export interface DeviceAllocator { - allocate( - criteria: AllocationCriteria, - takenDeviceIds: ReadonlySet, - signal?: AbortSignal, - ): Promise; - - /** Called at pool shutdown for every slot in `available` or `allocated` state. */ - release(deviceId: string): Promise; -} +// Moved to @mobilewright/protocol — re-exported here so existing imports in the codebase keep working. +export { NoDeviceAvailableError } from '@mobilewright/protocol'; +export type { AllocationCriteria, AllocateResult } from '@mobilewright/protocol'; export interface AllocationHandle { allocationId: string; diff --git a/packages/mobilewright/src/device-pool/setup.ts b/packages/mobilewright/src/device-pool/setup.ts index a724ca7..e62b17b 100644 --- a/packages/mobilewright/src/device-pool/setup.ts +++ b/packages/mobilewright/src/device-pool/setup.ts @@ -1,14 +1,14 @@ import { DevicePool } from './application/device-pool.js'; import { DevicePoolHttpServer } from './adapters/http-server.js'; -import { createAllocator } from './allocator-factory.js'; import { COORDINATOR_URL_ENV } from './client-factory.js'; -import { loadConfig } from '../config.js'; +import { loadConfig, resolveDriver } from '../config.js'; import type { FullConfig } from '@playwright/test'; +import type { MobilewrightDriver } from '@mobilewright/protocol'; interface ActiveCoordinator { pool: DevicePool; server: DevicePoolHttpServer; - serverProcess?: { kill: () => void }; + driver: MobilewrightDriver; } let active: ActiveCoordinator | undefined; @@ -19,17 +19,18 @@ let active: ActiveCoordinator | undefined; */ export default async function setup(playwrightConfig: FullConfig): Promise<() => Promise> { const config = await loadConfig(process.cwd(), playwrightConfig.configFile); - const { allocator, serverProcess } = await createAllocator(config); + const driver = resolveDriver(config); + await driver.setup?.(); // Use the resolved worker count from Playwright's FullConfig so CLI flags // like --workers 2 are respected, not just the value in the config file. const maxSlots = playwrightConfig.workers; - const pool = new DevicePool({ allocator, maxSlots }); + const pool = new DevicePool({ driver, maxSlots }); const server = new DevicePoolHttpServer({ pool }); const port = await server.listen(); process.env[COORDINATOR_URL_ENV] = `http://127.0.0.1:${port}`; - active = { pool, server, serverProcess }; + active = { pool, server, driver }; return async () => { if (!active) { @@ -37,9 +38,7 @@ export default async function setup(playwrightConfig: FullConfig): Promise<() => } await active.pool.shutdown(); await active.server.close(); - if (active.serverProcess) { - active.serverProcess.kill(); - } + await active.driver.teardown?.(); delete process.env[COORDINATOR_URL_ENV]; active = undefined; }; diff --git a/packages/mobilewright/src/index.ts b/packages/mobilewright/src/index.ts index 4a955d9..6fc13d6 100644 --- a/packages/mobilewright/src/index.ts +++ b/packages/mobilewright/src/index.ts @@ -8,7 +8,7 @@ export { expect } from '@mobilewright/core'; export { Device, Screen, Locator } from '@mobilewright/core'; // Configuration -export { defineConfig, loadConfig, type MobilewrightConfig, type MobilewrightProjectConfig, type MobilewrightUseOptions, type DriverConfig, type DriverConfigMobilecli, type DriverConfigMobileNext } from './config.js'; +export { defineConfig, loadConfig, resolveDriver, type MobilewrightConfig, type MobilewrightProjectConfig, type MobilewrightUseOptions, type DriverConfig, type DriverConfigMobilecli, type DriverConfigMobileNext } from './config.js'; // Errors export { MobilewrightError } from './errors.js'; diff --git a/packages/mobilewright/src/launchers.ts b/packages/mobilewright/src/launchers.ts index 63e0c4e..bcd80ba 100644 --- a/packages/mobilewright/src/launchers.ts +++ b/packages/mobilewright/src/launchers.ts @@ -1,10 +1,8 @@ import type { Platform, DeviceInfo, DeviceType, MobilewrightDriver } from '@mobilewright/protocol'; import { Device } from '@mobilewright/core'; import { MobilecliDriver, DEFAULT_URL } from '@mobilewright/driver-mobilecli'; -import { MobileNextDriver } from '@mobilewright/driver-mobilenext'; -import { ensureMobilecliReachable } from './server.js'; -import { toArray } from './config.js'; -import type { DriverConfig } from './config.js'; +import { toArray, resolveDriver } from './config.js'; +import type { DriverConfig, MobilewrightConfig } from './config.js'; export interface LaunchOptions { bundleId?: string; @@ -15,7 +13,7 @@ export interface LaunchOptions { url?: string; timeout?: number; autoStart?: boolean; - driver?: DriverConfig; + driver?: MobilewrightDriver | DriverConfig; } interface PlatformLauncher { @@ -27,7 +25,7 @@ export interface ConnectDeviceParams { platform: Platform; deviceId: string; deviceType?: DeviceType; - driverConfig?: DriverConfig; + driverConfig?: MobilewrightDriver | DriverConfig; url?: string; timeout?: number; } @@ -36,18 +34,12 @@ export interface FindDeviceParams { platform: Platform; deviceId?: string; deviceName?: RegExp; - driverConfig?: DriverConfig; + driverConfig?: MobilewrightDriver | DriverConfig; url?: string; } -export function createDriver(driverConfig?: DriverConfig, url?: string): MobilewrightDriver { - if (driverConfig?.type === 'mobilenext' || driverConfig?.type === 'mobile-use') { - return new MobileNextDriver({ - region: driverConfig.region, - apiKey: driverConfig.apiKey, - }); - } - return new MobilecliDriver({ url }); +export function createDriver(driverConfig?: MobilewrightDriver | DriverConfig, url?: string): MobilewrightDriver { + return resolveDriver({ driver: driverConfig, url } as MobilewrightConfig); } export async function connectDevice(params: ConnectDeviceParams): Promise { @@ -95,35 +87,26 @@ export async function findDevice(params: FindDeviceParams): Promise function createLauncher(platform: Platform): PlatformLauncher { return { async launch(opts: LaunchOptions = {}): Promise { - const driverConfig = opts.driver; - const url = opts.url ?? DEFAULT_URL; - - let serverProcess: { kill: () => void } | undefined; - if (!driverConfig || driverConfig.type === 'mobilecli') { - const ensured = await ensureMobilecliReachable(url, { autoStart: opts.autoStart ?? true }); - serverProcess = ensured.serverProcess ?? undefined; - } + const driver = resolveDriver({ driver: opts.driver, url: opts.url, autoStart: opts.autoStart } as MobilewrightConfig); + await driver.setup?.(); const found = await findDevice({ platform, deviceId: opts.deviceId, deviceName: opts.deviceName, - driverConfig, - url, + driverConfig: driver, + url: opts.url, }); const device = await connectDevice({ platform, deviceId: found.id, - driverConfig, - url, + driverConfig: driver, + url: opts.url, timeout: opts.timeout, }); - if (serverProcess) { - const proc = serverProcess; - device.onClose(() => Promise.resolve(proc.kill()).then(() => undefined)); - } + device.onClose(() => driver.teardown?.() ?? Promise.resolve()); await installAndLaunchApps(device, opts); return device; diff --git a/packages/protocol/src/allocator.ts b/packages/protocol/src/allocator.ts new file mode 100644 index 0000000..c98da8a --- /dev/null +++ b/packages/protocol/src/allocator.ts @@ -0,0 +1,30 @@ +import type { DeviceType, Platform } from './types.js'; + +/** + * Thrown by a driver's allocate() when no device is currently available but + * one may become available later (e.g. all matching devices are already taken). + * DevicePool treats this as a temporary condition and re-queues the waiter. + */ +export class NoDeviceAvailableError extends Error { + constructor(message: string) { + super(message); + this.name = 'NoDeviceAvailableError'; + } +} + +export interface AllocationCriteria { + platform?: Platform; + /** Serialized regex source — `RegExp.prototype.source`. The driver reconstructs `new RegExp(...)`. */ + deviceNamePattern?: string; + deviceId?: string; +} + +export interface AllocateResult { + deviceId: string; + platform: Platform; + /** Driver name — populated from driver.name. Will be removed once slots read it from the driver directly. */ + driver?: string; + model?: string; + osVersion?: string; + type?: DeviceType; +} diff --git a/packages/protocol/src/driver.ts b/packages/protocol/src/driver.ts index 581ec3d..270e841 100644 --- a/packages/protocol/src/driver.ts +++ b/packages/protocol/src/driver.ts @@ -16,9 +16,23 @@ import type { SwipeOptions, ViewNode, } from './types.js'; +import type { AllocationCriteria, AllocateResult } from './allocator.js'; export interface MobilewrightDriver { - // Connection + // Identity + readonly name: string; + + // Pool management (coordinator-side) + allocate( + criteria: AllocationCriteria, + takenDeviceIds: ReadonlySet, + signal?: AbortSignal, + ): Promise; + release(deviceId: string): Promise; + setup?(): Promise; + teardown?(): Promise; + + // Connection (worker-side) connect(config: ConnectionConfig): Promise; disconnect(): Promise; diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 6f7a166..59436fb 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,2 +1,3 @@ export type * from './types.js'; export type * from './driver.js'; +export * from './allocator.js'; diff --git a/packages/test/src/dummy-driver.test.ts b/packages/test/src/dummy-driver.test.ts new file mode 100644 index 0000000..7442c11 --- /dev/null +++ b/packages/test/src/dummy-driver.test.ts @@ -0,0 +1,52 @@ +/** + * Smoke test: DummyDriver — a third-party driver that only imports from + * @mobilewright/protocol — can be used with DevicePool end-to-end. + * + * This test proves that a custom driver author does not need to depend on + * the mobilewright framework package, only @mobilewright/protocol. + */ +import { test, expect } from '@playwright/test'; +import { DummyDriver } from './dummy-driver.js'; +import { NoDeviceAvailableError } from '@mobilewright/protocol'; + +test('DummyDriver satisfies MobilewrightDriver without importing mobilewright', () => { + const driver = new DummyDriver(); + // Only @mobilewright/protocol is imported in dummy-driver.ts — verified by inspection. + expect(driver.name).toBe('dummy'); + expect(typeof driver.allocate).toBe('function'); + expect(typeof driver.release).toBe('function'); + expect(typeof driver.connect).toBe('function'); + expect(typeof driver.tap).toBe('function'); +}); + +test('DummyDriver.allocate returns a device and release frees it', async () => { + const driver = new DummyDriver(); + await driver.setup?.(); + + const result = await driver.allocate({}, new Set()); + expect(result.deviceId).toBe('dummy-device-001'); + expect(result.platform).toBe('ios'); + + await driver.release(result.deviceId); + + // After release, same device is available again. + const second = await driver.allocate({}, new Set()); + expect(second.deviceId).toBe('dummy-device-001'); + + await driver.release(second.deviceId); +}); + +test('DummyDriver.allocate throws NoDeviceAvailableError when device is taken', async () => { + const driver = new DummyDriver(); + const takenIds = new Set(['dummy-device-001']); + + await expect(driver.allocate({}, takenIds)).rejects.toThrow(NoDeviceAvailableError); +}); + +test('DummyDriver.setup and teardown lifecycle work', async () => { + const driver = new DummyDriver(); + expect(driver.wasSetup).toBe(false); + + await driver.setup?.(); + expect(driver.wasSetup).toBe(true); +}); diff --git a/packages/test/src/dummy-driver.ts b/packages/test/src/dummy-driver.ts new file mode 100644 index 0000000..5a24026 --- /dev/null +++ b/packages/test/src/dummy-driver.ts @@ -0,0 +1,116 @@ +/** + * DummyDriver — a minimal third-party driver that only imports from + * @mobilewright/protocol. Proves that a driver author does not need to depend + * on the mobilewright framework package — only the protocol. + * + * Used in smoke tests to verify the third-party driver path end-to-end. + */ +import type { + AllocateResult, + AllocationCriteria, + AppInfo, + ConnectionConfig, + DeviceInfo, + GestureSequence, + HardwareButton, + LaunchOptions, + ListDevicesOptions, + MobilewrightDriver, + Orientation, + RecordingOptions, + RecordingResult, + ScreenSize, + ScreenshotOptions, + Session, + SwipeDirection, + SwipeOptions, + ViewNode, +} from '@mobilewright/protocol'; +import { NoDeviceAvailableError } from '@mobilewright/protocol'; + +const FAKE_DEVICE_ID = 'dummy-device-001'; +const FAKE_PLATFORM = 'ios' as const; + +export class DummyDriver implements MobilewrightDriver { + readonly name = 'dummy'; + + private allocated = new Set(); + private setupCalled = false; + private teardownCalled = false; + + // ── Lifecycle ──────────────────────────────────────────────── + + async setup(): Promise { + this.setupCalled = true; + } + + async teardown(): Promise { + this.teardownCalled = false; + } + + get wasSetup(): boolean { return this.setupCalled; } + get wasTornDown(): boolean { return this.teardownCalled; } + + // ── Pool management (coordinator-side) ────────────────────── + + async allocate( + _criteria: AllocationCriteria, + takenDeviceIds: ReadonlySet, + _signal?: AbortSignal, + ): Promise { + if (takenDeviceIds.has(FAKE_DEVICE_ID)) { + throw new NoDeviceAvailableError(`${FAKE_DEVICE_ID} is already taken`); + } + this.allocated.add(FAKE_DEVICE_ID); + return { deviceId: FAKE_DEVICE_ID, platform: FAKE_PLATFORM, driver: this.name }; + } + + async release(deviceId: string): Promise { + this.allocated.delete(deviceId); + } + + // ── Per-device control (worker-side) ──────────────────────── + + async connect(_config: ConnectionConfig): Promise { + return { deviceId: FAKE_DEVICE_ID, platform: FAKE_PLATFORM }; + } + + async disconnect(): Promise {} + + async getViewHierarchy(): Promise { return []; } + + async tap(_x: number, _y: number): Promise {} + async doubleTap(_x: number, _y: number): Promise {} + async longPress(_x: number, _y: number, _duration?: number): Promise {} + async typeText(_text: string): Promise {} + async swipe(_direction: SwipeDirection, _opts?: SwipeOptions): Promise {} + async gesture(_gestures: GestureSequence): Promise {} + async pressButton(_button: HardwareButton): Promise {} + + async screenshot(_opts?: ScreenshotOptions): Promise { + return Buffer.alloc(0); + } + + async getScreenSize(): Promise { + return { width: 390, height: 844, scale: 3 }; + } + + async getOrientation(): Promise { return 'portrait'; } + async setOrientation(_orientation: Orientation): Promise {} + + async launchApp(_bundleId: string, _opts?: LaunchOptions): Promise {} + async terminateApp(_bundleId: string): Promise {} + async listApps(): Promise { return []; } + async getForegroundApp(): Promise { return { bundleId: 'com.example.app' }; } + async installApp(_path: string): Promise {} + async uninstallApp(_bundleId: string): Promise {} + + async listDevices(_opts?: ListDevicesOptions): Promise { + return [{ id: FAKE_DEVICE_ID, name: 'Dummy iPhone', platform: FAKE_PLATFORM, type: 'simulator', state: 'online' }]; + } + + async openUrl(_url: string): Promise {} + + async startRecording(_opts: RecordingOptions): Promise {} + async stopRecording(): Promise { return { output: '' }; } +} diff --git a/packages/test/src/index.ts b/packages/test/src/index.ts index efca9c5..b655b30 100644 --- a/packages/test/src/index.ts +++ b/packages/test/src/index.ts @@ -1 +1,2 @@ export { test, expect } from './fixtures.js'; +export { DummyDriver } from './dummy-driver.js';