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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 10 additions & 148 deletions packages/driver-mobilenext/src/driver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,8 +93,6 @@ interface MobileNextDevicesResponse {
export interface MobileNextDriverOptions {
region?: string;
apiKey?: string;
/** Timeout waiting for cloud device allocation in ms. Default: 300000 (5 min). */
allocationTimeout?: number;
}

const VALID_PLATFORMS = new Set<string>(['ios', 'android']);
Expand Down Expand Up @@ -154,36 +152,6 @@ function sanitizeFilename(name: string): string {
return name.replace(/[^0-9a-zA-Z_.]/g, '_');
}

interface FleetAllocateResponse {
sessionId: string;
provisionId?: string;
state?: string;
device?: {
id: string;
name: string;
platform: string;
state: string;
model: string;
type: string;
version: string;
};
}

interface DevicesListDevice {
id: string;
name: string;
platform: string;
state: string;
model: string;
type: string;
version: string;
provider?: { type: string; sessionId?: string };
}

interface DevicesListResponse {
devices: DevicesListDevice[];
}

interface UploadCreateResponse {
uploadId: string;
uploadUrl: string;
Expand All @@ -193,154 +161,48 @@ interface ActiveSession {
deviceId: string;
platform: Platform;
rpc: RpcClient;
model?: string;
osVersion?: string;
type?: DeviceType;
}

export interface MobileNextDeviceInfo {
model?: string;
osVersion?: string;
type?: DeviceType;
}

type DeviceFilter =
| { attribute: 'platform'; operator: 'EQUALS'; value: 'ios' | 'android' }
| { attribute: 'type'; operator: 'EQUALS'; value: 'real' }
| { attribute: 'name'; operator: 'EQUALS' | 'STARTS_WITH' | 'CONTAINS'; value: string }
| { attribute: 'version'; operator: 'EQUALS' | 'GREATER_THAN' | 'GREATER_THAN_OR_EQUALS' | 'LESS_THAN' | 'LESS_THAN_OR_EQUALS'; value: string };

const debug = createDebug('mw:driver-mobilenext');

export class MobileNextDriver implements MobilewrightDriver {
private session: ActiveSession | null = null;
private readonly options: MobileNextDriverOptions;
private ownsLease = false;

get deviceInfo(): MobileNextDeviceInfo | null {
if (!this.session) {
return null;
}
return { model: this.session.model, osVersion: this.session.osVersion, type: this.session.type };
}

constructor(options: MobileNextDriverOptions = {}) {
this.options = options;
}

// ─── Connection ──────────────────────────────────────────────

// The device must already be allocated via the fleet sessions API (see FleetApiClient); this
// driver only opens the RPC channel to drive it. deviceId is the physical serial the device
// tools accept.
async connect(config: ConnectionConfig): Promise<Session> {
if (!config.deviceId) {
throw new Error('MobileNextDriver.connect requires a deviceId — allocate a device via the fleet sessions API first');
}

const baseUrl = config.url ?? DEFAULT_URL;
const url = this.options.apiKey
? appendQueryParam(baseUrl, 'token', this.options.apiKey)
: baseUrl;
debug('connecting to %s', baseUrl);
debug('connecting to %s (device=%s)', baseUrl, config.deviceId);
const rpc = new RpcClient(url, config.timeout);
await rpc.connect();
debug('websocket connected');

const platform = config.platform;

// When deviceId is provided the device is already allocated by the pool
// coordinator. Connect directly without fleet.allocate; this instance
// does not own the lease and must not call fleet.release on disconnect.
if (config.deviceId) {
debug('reusing pre-allocated device %s', config.deviceId);
this.ownsLease = false;
this.session = { deviceId: config.deviceId, platform, rpc };
return { deviceId: config.deviceId, platform };
}

this.ownsLease = true;

const filters = this.buildFilters(config);
debug('allocating device with filters %o', filters);
const result = await rpc.call<FleetAllocateResponse>('fleet.allocate', { filters });

let deviceId: string;
let model: string | undefined;
let osVersion: string | undefined;
let type: DeviceType | undefined;
if (result?.state === 'allocating' && result.sessionId) {
debug('device is provisioning, waiting for allocation (session=%s)', result.sessionId);
const device = await this.waitForAllocation(rpc, result.sessionId);
debug('allocated device %s (session=%s, model=%s)', device.id, result.sessionId, device.model);
deviceId = device.id;
model = device.model;
osVersion = device.version;
type = toDeviceType(device.type);
} else if (result?.device?.id) {
debug('allocated device %s (session=%s, model=%s)', result.device.id, result.sessionId, result.device.model);
deviceId = result.device.id;
model = result.device.model;
osVersion = result.device.version;
type = toDeviceType(result.device.type);
} else {
throw new Error(`Device allocation failed: ${JSON.stringify(result)}`);
}

this.session = { deviceId, platform, rpc, model, osVersion, type };
return { deviceId, platform };
}

private async waitForAllocation(
rpc: RpcClient,
sessionId: string,
): Promise<DevicesListDevice> {
const timeout = this.options.allocationTimeout ?? 300_000;
const pollInterval = 5_000;
const deadline = Date.now() + timeout;

while (Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, pollInterval));
const elapsed = Math.round((timeout - (deadline - Date.now())) / 1000);
debug('waiting for device allocation, session=%s (%ds elapsed)', sessionId, elapsed);

const result = await rpc.call<DevicesListResponse>('devices.list', {});
const device = result.devices.find((d) => d.provider?.sessionId === sessionId);
if (!device) {
continue;
}
if (device.state !== 'allocating') {
return device;
}
}

throw new Error(`Timed out waiting for device allocation after ${timeout / 1000}s (session=${sessionId})`);
this.session = { deviceId: config.deviceId, platform: config.platform, rpc };
return { deviceId: config.deviceId, platform: config.platform };
}

async disconnect(): Promise<void> {
const session = this.requireSession();
if (this.ownsLease) {
debug('releasing device %s', session.deviceId);
await session.rpc.call('fleet.release', { deviceId: session.deviceId });
}
await session.rpc.disconnect();
this.session = null;
this.ownsLease = false;
debug('disconnected');
}

private buildFilters(config: ConnectionConfig): DeviceFilter[] {
const filters: DeviceFilter[] = [
{ attribute: 'platform', operator: 'EQUALS', value: config.platform },
];

if (config.deviceName) {
const name = typeof config.deviceName === 'string'
? config.deviceName
: config.deviceName.source;
filters.push({ attribute: 'name', operator: 'CONTAINS', value: name });
}

if (config.osVersion) {
filters.push({ attribute: 'version', operator: 'EQUALS', value: config.osVersion });
}

return filters;
}

// ─── UI hierarchy ───────────────────────────────────────────

async getViewHierarchy(): Promise<ViewNode[]> {
Expand Down
160 changes: 160 additions & 0 deletions packages/driver-mobilenext/src/fleet-api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
import { test, expect } from '@playwright/test';
import { FleetApiClient, type SessionDevice } from './fleet-api.js';

interface RecordedCall {
method: string;
path: string;
body: unknown;
authorization: string | null;
}

interface StubResponse {
status?: number;
json: unknown;
}

// A fetch stub that records every request and replays the given responses in order. The last
// response repeats once exhausted, so pollers that call the same endpoint N times keep working.
function stubFetch(responses: StubResponse[]): { fetchFn: typeof fetch; calls: RecordedCall[] } {
const calls: RecordedCall[] = [];
let index = 0;

const fetchFn = (async (url: string, init: RequestInit) => {
const headers = init.headers as Record<string, string>;
calls.push({
method: init.method ?? 'GET',
path: new URL(url).pathname,
body: init.body ? JSON.parse(init.body as string) : undefined,
authorization: headers?.['Authorization'] ?? null,
});
const chosen = responses[Math.min(index, responses.length - 1)];
index += 1;
const status = chosen.status ?? 200;
return {
ok: status >= 200 && status < 300,
status,
json: async () => chosen.json,
} as Response;
}) as unknown as typeof fetch;

return { fetchFn, calls };
}

function readyDevice(serial: string): SessionDevice {
return {
id: 'alloc-1',
status: 'in_use',
info: { platform: 'ios', type: 'real', name: 'iPhone 15', osVersion: '17.0', serial },
createdAt: '2026-01-01T00:00:00Z',
};
}

function provisioningDevice(): SessionDevice {
return {
id: 'alloc-1',
status: 'provisioning',
info: { platform: 'ios', type: 'real', name: 'iPhone 15', osVersion: '17.0' },
createdAt: '2026-01-01T00:00:00Z',
};
}

test('createSession posts to the sessions endpoint with a bearer token', async () => {
const { fetchFn, calls } = stubFetch([{ status: 201, json: { id: 'sess-1' } }]);
const client = new FleetApiClient({ apiKey: 'mob_test', fetchFn });

const sessionId = await client.createSession();

expect(sessionId).toBe('sess-1');
expect(calls[0].method).toBe('POST');
expect(calls[0].path).toBe('/api/v1/sessions');
expect(calls[0].authorization).toBe('Bearer mob_test');
});

test('allocateDevice returns immediately for a pre-booted device (serial is device.id)', async () => {
// The 201 response is the legacy fleet.allocate shape: the ready device lands inline under
// `device`, and its `id` is the physical serial the driver drives with.
const { fetchFn, calls } = stubFetch([
{
status: 201,
json: {
allocationId: 'alloc-1',
device: { id: 'SERIAL-A', name: 'iPhone 15', model: 'iPhone15,2', platform: 'ios', type: 'real', version: '17.0', state: 'online' },
},
},
]);
const client = new FleetApiClient({ apiKey: 'mob_test', fetchFn });

const device = await client.allocateDevice('sess-1', [
{ attribute: 'platform', operator: 'EQUALS', value: 'ios' },
]);

expect(device.info.serial).toBe('SERIAL-A');
expect(device.info.osVersion).toBe('17.0');
// No polling was needed, so only the allocate POST happened.
expect(calls).toHaveLength(1);
expect(calls[0].path).toBe('/api/v1/sessions/sess-1/devices');
expect(calls[0].body).toEqual({ filters: [{ attribute: 'platform', operator: 'EQUALS', value: 'ios' }] });
});

test('allocateDevice polls the device list, matching its allocationId, until the device lands', async () => {
const readyB = readyDevice('SERIAL-B'); // its `id` is the allocation id the POST returned
const { fetchFn, calls } = stubFetch([
{ status: 202, json: { allocationId: 'alloc-1', state: 'allocating' } }, // on-demand POST
{ status: 200, json: { object: 'list', data: [provisioningDevice()] } }, // first poll — not ready
{ status: 200, json: { object: 'list', data: [readyB] } }, // second poll — ready
]);
const client = new FleetApiClient({ apiKey: 'mob_test', fetchFn });

const device = await client.allocateDevice('sess-1', [
{ attribute: 'platform', operator: 'EQUALS', value: 'ios' },
]);

expect(device.info.serial).toBe('SERIAL-B');
expect(calls[1].method).toBe('GET');
expect(calls[1].path).toBe('/api/v1/sessions/sess-1/devices');
});

test('releaseDevice targets the device by serial', async () => {
const { fetchFn, calls } = stubFetch([{ status: 202, json: readyDevice('SERIAL-A') }]);
const client = new FleetApiClient({ apiKey: 'mob_test', fetchFn });

await client.releaseDevice('sess-1', 'SERIAL-A');

expect(calls[0].method).toBe('POST');
expect(calls[0].path).toBe('/api/v1/sessions/sess-1/devices/SERIAL-A/release');
});

// A fetch that never resolves on its own; it only settles by rejecting when its signal aborts —
// which is exactly how a real stalled connection behaves once the client's controller fires.
function stallingFetch(): typeof fetch {
return (async (_url: string, init: RequestInit) =>
new Promise<Response>((_resolve, reject) => {
const signal = init.signal as AbortSignal;
signal.addEventListener('abort', () => reject(new Error('aborted')));
})) as unknown as typeof fetch;
}

test('a stalled request aborts and is reported as a timeout', async () => {
const client = new FleetApiClient({ apiKey: 'mob_test', fetchFn: stallingFetch(), requestTimeout: 20 });

await expect(client.createSession()).rejects.toThrow(/timed out after 20ms/);
});

test('an external abort signal cancels an in-flight allocation', async () => {
const controller = new AbortController();
const client = new FleetApiClient({ apiKey: 'mob_test', fetchFn: stallingFetch() });

const pending = client.allocateDevice('sess-1', [{ attribute: 'platform', operator: 'EQUALS', value: 'ios' }], controller.signal);
controller.abort();

await expect(pending).rejects.toThrow(/aborted/);
});

test('a failed request surfaces the API error code and message', async () => {
const { fetchFn } = stubFetch([
{ status: 402, json: { error: { code: 'insufficient_credits', message: 'Not enough credits' } } },
]);
const client = new FleetApiClient({ apiKey: 'mob_test', fetchFn });

await expect(client.createSession()).rejects.toThrow(/insufficient_credits — Not enough credits/);
});
Loading