From f5a3da4f6ffe346d0cccd34125399e843a311812 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Mon, 25 May 2026 17:17:48 +0200 Subject: [PATCH] refactor: drivers as instances --- CHANGELOG.md | 8 ++ e2e/mobilewright.config.ts | 21 +++-- packages/driver-mobile-use/src/driver.ts | 42 ++++++++- packages/driver-mobilecli/src/driver.ts | 64 +++++++++++++- packages/driver-mobilecli/src/index.ts | 1 + packages/mobilewright-core/src/expect.test.ts | 3 + .../mobilewright-core/src/locator.test.ts | 3 + packages/mobilewright/src/config.ts | 46 +++++++++- .../device-pool/adapters/http-client.test.ts | 13 +-- .../device-pool/adapters/http-server.test.ts | 19 ++-- .../adapters/mobile-use-allocator.ts | 43 --------- .../adapters/mobilecli-allocator.test.ts | 87 ------------------- .../adapters/mobilecli-allocator.ts | 54 ------------ .../src/device-pool/allocator-factory.ts | 35 -------- .../application/device-pool.test.ts | 83 ++++++++++-------- .../device-pool/application/device-pool.ts | 16 ++-- .../src/device-pool/application/ports.ts | 48 +--------- .../mobilewright/src/device-pool/setup.ts | 17 ++-- packages/mobilewright/src/index.ts | 2 +- packages/mobilewright/src/launchers.ts | 44 +++------- packages/protocol/src/driver.ts | 16 +++- packages/protocol/src/index.ts | 1 + packages/test/src/index.ts | 1 + 23 files changed, 286 insertions(+), 381 deletions(-) delete mode 100644 packages/mobilewright/src/device-pool/adapters/mobile-use-allocator.ts delete mode 100644 packages/mobilewright/src/device-pool/adapters/mobilecli-allocator.test.ts delete mode 100644 packages/mobilewright/src/device-pool/adapters/mobilecli-allocator.ts delete mode 100644 packages/mobilewright/src/device-pool/allocator-factory.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index c0024a2..d0e5664 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +## [0.0.38] (2026-05-19) +* 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 `MobileUseDriver` both implement the unified `MobilewrightDriver` interface; `MobileUseDriver` 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.37] (2026-05-16) * Feat: add installApps to per-project overrides ([#133](https://github.com/mobile-next/mobilewright/pull/133)) * Feat: export HardwareButton from @mobilewright/core and add LOCK button ([#132](https://github.com/mobile-next/mobilewright/pull/132)) diff --git a/e2e/mobilewright.config.ts b/e2e/mobilewright.config.ts index b0a089a..fb8b7b9 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 { MobileUseDriver } from '@mobilewright/driver-mobile-use'; +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['MOBILE_USE_API_KEY']) { throw new Error('MOBILE_USE_API_KEY is required for mobile-use driver'); } - - return { - type: 'mobile-use', - apiKey: process.env['MOBILE_USE_API_KEY'], - }; + return new MobileUseDriver({ apiKey: process.env['MOBILE_USE_API_KEY'] }); - case 'mobilecli': - return { type: 'mobilecli' }; + case 'mobilecli': + return new MobilecliDriver(); default: - throw new Error(`Unknown driver: ${name}. Use ['mobilecli' or 'mobile-use']`); + throw new Error(`Unknown driver: ${name}. Use 'mobilecli' or 'mobile-use'`); } } @@ -34,7 +33,7 @@ const config: MobilewrightConfig = defineConfig({ // parallel by test() instead of parallel by file fullyParallel: true, - // supports mobilecli and mobile-use drivers + // pass a driver instance — any class implementing MobilewrightDriver works driver: resolveDriver(), // filter used devices with regexp diff --git a/packages/driver-mobile-use/src/driver.ts b/packages/driver-mobile-use/src/driver.ts index 5549105..5da910b 100644 --- a/packages/driver-mobile-use/src/driver.ts +++ b/packages/driver-mobile-use/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, @@ -195,9 +197,14 @@ type DeviceFilter = const debug = createDebug('mw:driver-mobile-use'); export class MobileUseDriver implements MobilewrightDriver { + readonly name = 'mobile-use'; + private session: ActiveSession | null = null; private readonly options: MobileUseDriverOptions; 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(): MobileUseDeviceInfo | null { if (!this.session) { @@ -210,7 +217,40 @@ export class MobileUseDriver implements MobilewrightDriver { this.options = options; } - // ─── Connection ────────────────────────────────────────────── + // ─── Pool management (coordinator-side) ───────────────────── + + async allocate( + criteria: AllocationCriteria, + _takenDeviceIds: ReadonlySet, + _signal?: AbortSignal, + ): Promise { + const holder = new MobileUseDriver(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/driver-mobilecli/src/driver.ts b/packages/driver-mobilecli/src/driver.ts index fa2f015..c68b16a 100644 --- a/packages/driver-mobilecli/src/driver.ts +++ b/packages/driver-mobilecli/src/driver.ts @@ -1,6 +1,8 @@ import createDebug from 'debug'; import { execFileSync } from 'node:child_process'; import type { + AllocateResult, + AllocationCriteria, AppInfo, ConnectionConfig, DeviceInfo, @@ -22,8 +24,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'; @@ -134,14 +138,70 @@ function elementToViewNode(el: MobilecliElement): ViewNode { const debug = createDebug('mw:driver-mobilecli'); export class MobilecliDriver implements MobilewrightDriver { + readonly name = 'mobilecli'; + private session: { deviceId: string; platform: Platform; 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/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 c627372..ec5f760 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 0997e70..9adc96d 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 { MobileUseDriver } from '@mobilewright/driver-mobile-use'; 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 MobileUseDriver({ 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' : 'MobileUseDriver'}()`, + ); + } + + if (!legacyConfig || type === 'mobilecli') { + return new MobilecliDriver({ url: config.url, autoStart: config.autoStart }); + } + + if (type === 'mobile-use') { + const mobileUseConfig = legacyConfig as DriverConfigMobileUse; + return new MobileUseDriver({ region: mobileUseConfig.region, apiKey: mobileUseConfig.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/mobile-use-allocator.ts b/packages/mobilewright/src/device-pool/adapters/mobile-use-allocator.ts deleted file mode 100644 index 9b16059..0000000 --- a/packages/mobilewright/src/device-pool/adapters/mobile-use-allocator.ts +++ /dev/null @@ -1,43 +0,0 @@ -import createDebug from 'debug'; -import { MobileUseDriver } from '@mobilewright/driver-mobile-use'; -import type { MobileUseDriverOptions } from '@mobilewright/driver-mobile-use'; -import type { AllocationCriteria, AllocateResult, DeviceAllocator } from '../application/ports.js'; - -const debug = createDebug('mw:device-pool:mobile-use'); - -export interface MobileUseAllocatorOptions { - driverOptions: MobileUseDriverOptions; -} - -export class MobileUseAllocator implements DeviceAllocator { - private readonly driverOptions: MobileUseDriverOptions; - private readonly activeDrivers = new Map(); - - constructor(options: MobileUseAllocatorOptions) { - this.driverOptions = options.driverOptions; - } - - async allocate(criteria: AllocationCriteria): Promise { - debug('allocating device (criteria=%o)', criteria); - const driver = new MobileUseDriver(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: 'mobile-use', 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/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/allocator-factory.ts b/packages/mobilewright/src/device-pool/allocator-factory.ts deleted file mode 100644 index 4a6a701..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 { MobileUseAllocator } from './adapters/mobile-use-allocator.js'; -import type { MobilewrightConfig, DriverConfigMobileUse } 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 === 'mobile-use') { - const mobileUseConfig = config.driver as DriverConfigMobileUse; - const allocator = new MobileUseAllocator({ - driverOptions: { - region: mobileUseConfig.region, - apiKey: mobileUseConfig.apiKey, - }, - }); - return { allocator }; - } - - throw new Error(`Unsupported driver type: "${driverType}". Supported types: "mobilecli", "mobile-use".`); -} 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 c54c010..910d12f 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 DriverConfigMobileUse } from './config.js'; +export { defineConfig, loadConfig, resolveDriver, type MobilewrightConfig, type MobilewrightProjectConfig, type MobilewrightUseOptions, type DriverConfig, type DriverConfigMobilecli, type DriverConfigMobileUse } from './config.js'; // Errors export { MobilewrightError } from './errors.js'; diff --git a/packages/mobilewright/src/launchers.ts b/packages/mobilewright/src/launchers.ts index 13e98f3..fec57d7 100644 --- a/packages/mobilewright/src/launchers.ts +++ b/packages/mobilewright/src/launchers.ts @@ -2,9 +2,8 @@ import type { Platform, DeviceInfo, MobilewrightDriver } from '@mobilewright/pro import { Device } from '@mobilewright/core'; import { MobilecliDriver, DEFAULT_URL } from '@mobilewright/driver-mobilecli'; import { MobileUseDriver } from '@mobilewright/driver-mobile-use'; -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 +14,7 @@ export interface LaunchOptions { url?: string; timeout?: number; autoStart?: boolean; - driver?: DriverConfig; + driver?: MobilewrightDriver | DriverConfig; } interface PlatformLauncher { @@ -26,7 +25,7 @@ interface PlatformLauncher { export interface ConnectDeviceParams { platform: Platform; deviceId: string; - driverConfig?: DriverConfig; + driverConfig?: MobilewrightDriver | DriverConfig; url?: string; timeout?: number; } @@ -35,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 === 'mobile-use') { - return new MobileUseDriver({ - 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 { @@ -93,35 +86,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/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/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';