diff --git a/docs/src/test/configuration.md b/docs/src/test/configuration.md index 179e5ba..b1331b4 100644 --- a/docs/src/test/configuration.md +++ b/docs/src/test/configuration.md @@ -37,10 +37,33 @@ Which device to run on and which app to drive. | `platform` | `'ios' \| 'android'` | — | Target platform | | `deviceId` | `string` | — | Specific device identifier (local drivers only) | | `deviceName` | `RegExp` | — | Match a device by name, e.g. `/iPhone 17/` | +| `deviceType` | `'simulator' \| 'emulator' \| 'real'` | — | Restrict to simulators, emulators, or real devices | +| `osVersion` | `string` | — | OS version constraint — see [OS version constraints](#os-version-constraints) | | `bundleId` | `string` | — | App bundle ID to launch | | `installApps` | `string \| string[]` | — | App paths (APK/IPA) to install before launching | | `autoAppLaunch` | `boolean` | `true` | Launch the app automatically after connecting | +### OS version constraints + +`osVersion` accepts a bare version or a comparator expression: + +| Expression | Matches | +| --- | --- | +| `'17'` | Any 17.x release (≥ 17, < 18) | +| `'26.0'` | Exactly 26.0 (26.0.1 matches, 26.1 does not) | +| `'>=17'` | 17 or newer | +| `'>=17 <19'` | 17 or 18, not 19 | + +A bare version is a prefix match. Comparator expressions combine at most one lower bound (`>=` or `>`) and one upper bound (`<` or `<=`), separated by a space. + +```ts +export default defineConfig({ + platform: 'ios', + deviceType: 'real', + osVersion: '>=17 <19', +}); +``` + ## Driver The driver decides where tests run — a local device via mobilecli, or a cloud device. Pass an @@ -162,4 +185,4 @@ export default defineConfig({ }); ``` -The project `use` block accepts `platform`, `deviceId`, `deviceName`, `bundleId`, `installApps`, `animations`, `actionTimeout`, `appLaunchTimeout`, and `installTimeout`. Projects can also override `timeout`, `testDir`, `testMatch`, `testIgnore`, `outputDir`, `retries`, `grep`, `grepInvert`, and declare `dependencies` on other projects. See [Projects](./projects.md) for the full matrix. +The project `use` block accepts `platform`, `deviceId`, `deviceName`, `deviceType`, `osVersion`, `bundleId`, `installApps`, `animations`, `actionTimeout`, `appLaunchTimeout`, and `installTimeout`. Projects can also override `timeout`, `testDir`, `testMatch`, `testIgnore`, `outputDir`, `retries`, `grep`, `grepInvert`, and declare `dependencies` on other projects. See [Projects](./projects.md) for the full matrix. diff --git a/docs/src/test/projects.md b/docs/src/test/projects.md index fa0e1f4..7a46b6c 100644 --- a/docs/src/test/projects.md +++ b/docs/src/test/projects.md @@ -59,6 +59,8 @@ The `use` block inside each project accepts the same options as the top-level co | `installApps` | APK or IPA/ZIP path(s) to install before tests run | | `deviceId` | Exact device ID to pin this project to (local drivers only) | | `deviceName` | Regex to match a specific device | +| `deviceType` | `'simulator'`, `'emulator'` or `'real'` | +| `osVersion` | OS version constraint, e.g. `'17'`, `'26.0'` or `'>=17 <19'` | A common pattern is to share `bundleId` and `timeout` at the top level, and only specify `platform` and `installApps` per project: diff --git a/packages/driver-mobilecli/src/driver.test.ts b/packages/driver-mobilecli/src/driver.test.ts index 4b2bcdb..95b9a1a 100644 --- a/packages/driver-mobilecli/src/driver.test.ts +++ b/packages/driver-mobilecli/src/driver.test.ts @@ -3,6 +3,7 @@ import { writeFileSync, mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { MobilecliDriver } from './driver.js'; +import { NoDeviceAvailableError, type DeviceInfo } from '@mobilewright/protocol'; const ZIP_MAGIC = Buffer.from([0x50, 0x4B, 0x03, 0x04]); const tmpDir = mkdtempSync(join(tmpdir(), 'mw-driver-test-')); @@ -364,3 +365,68 @@ test.describe('MobilecliDriver.applyDeviceSettings()', () => { ]); }); }); + +test.describe('MobilecliDriver.allocate()', () => { + function createDriverWithDevices(devices: Partial[]): MobilecliDriver { + const driver = new MobilecliDriver(); + driver.listDevices = async () => + devices.map((d, i) => ({ + id: d.id ?? `device-${i}`, + name: d.name ?? `Device ${i}`, + platform: d.platform ?? 'ios', + type: d.type ?? 'simulator', + state: d.state ?? 'online', + model: d.model, + osVersion: d.osVersion, + })); + return driver; + } + + test('picks a real device over a simulator when deviceType is "real"', async () => { + const driver = createDriverWithDevices([ + { id: 'sim-1', type: 'simulator' }, + { id: 'real-1', type: 'real' }, + ]); + + const allocated = await driver.allocate({ platform: 'ios', deviceType: 'real' }, new Set()); + + expect(allocated.deviceId).toBe('real-1'); + }); + + test('throws NoDeviceAvailableError when no device matches the deviceType', async () => { + const driver = createDriverWithDevices([{ id: 'sim-1', type: 'simulator' }]); + + await expect(driver.allocate({ platform: 'ios', deviceType: 'real' }, new Set())).rejects.toThrow( + NoDeviceAvailableError, + ); + }); + + test('picks the device whose OS version satisfies the expression', async () => { + const driver = createDriverWithDevices([ + { id: 'old', osVersion: '16.4' }, + { id: 'wanted', osVersion: '17.5' }, + { id: 'too-new', osVersion: '18.0' }, + ]); + + const allocated = await driver.allocate({ platform: 'ios', osVersion: '17' }, new Set()); + + expect(allocated.deviceId).toBe('wanted'); + }); + + test('a device that does not report an OS version never matches an osVersion filter', async () => { + const driver = createDriverWithDevices([{ id: 'unknown-version', osVersion: undefined }]); + + await expect(driver.allocate({ platform: 'ios', osVersion: '17' }, new Set())).rejects.toThrow( + NoDeviceAvailableError, + ); + }); +}); + +test('an invalid osVersion expression throws a parse error even when no devices are eligible', async () => { + const driver = new MobilecliDriver(); + driver.listDevices = async () => []; + + await expect(driver.allocate({ platform: 'ios', osVersion: 'latest' }, new Set())).rejects.toThrow( + /invalid OS version/, + ); +}); diff --git a/packages/driver-mobilecli/src/driver.ts b/packages/driver-mobilecli/src/driver.ts index 5885d59..c9223aa 100644 --- a/packages/driver-mobilecli/src/driver.ts +++ b/packages/driver-mobilecli/src/driver.ts @@ -31,7 +31,7 @@ import type { WebViewInfo, WebViewSession, } from '@mobilewright/protocol'; -import { NoDeviceAvailableError } from '@mobilewright/protocol'; +import { NoDeviceAvailableError, osVersionSatisfies, parseOsVersion } from '@mobilewright/protocol'; import { RpcClient } from './rpc-client.js'; import { resolveMobilecliBinary } from './resolve-binary.js'; import { ensureMobilecliReachable, type ServerHandle } from './server.js'; @@ -427,6 +427,12 @@ export class MobilecliDriver implements MobilewrightSession, DeviceAllocator { criteria: AllocationCriteria, takenDeviceIds: ReadonlySet, ): Promise { + if (criteria.osVersion) { + // Validate up front: with zero eligible devices the per-device filter below + // never parses the expression, and a malformed one would masquerade as a + // retriable NoDeviceAvailableError. + parseOsVersion(criteria.osVersion); + } const devices = await this.listDevices(criteria.platform ? { platform: criteria.platform } : undefined); const namePattern = criteria.deviceNamePattern ? new RegExp(criteria.deviceNamePattern) : undefined; @@ -436,6 +442,8 @@ export class MobilecliDriver implements MobilewrightSession, DeviceAllocator { .filter((d) => !takenDeviceIds.has(d.id)) .filter((d) => !criteria.deviceId || d.id === criteria.deviceId) .filter((d) => !namePattern || namePattern.test(d.name)) + .filter((d) => !criteria.deviceType || d.type === criteria.deviceType) + .filter((d) => !criteria.osVersion || (d.osVersion !== undefined && osVersionSatisfies(d.osVersion, criteria.osVersion))) .at(0); if (!match) { diff --git a/packages/driver-mobilenext/src/build-filters.test.ts b/packages/driver-mobilenext/src/build-filters.test.ts new file mode 100644 index 0000000..b94612d --- /dev/null +++ b/packages/driver-mobilenext/src/build-filters.test.ts @@ -0,0 +1,28 @@ +import { test, expect } from '@playwright/test'; +import { buildFilters } from './driver.js'; + +test('deviceType criteria becomes a type EQUALS filter', () => { + const filters = buildFilters({ platform: 'ios', deviceType: 'real' }); + + expect(filters).toContainEqual({ attribute: 'type', operator: 'EQUALS', value: 'real' }); +}); + +test('a bare osVersion becomes inclusive-lower and exclusive-upper version filters', () => { + const filters = buildFilters({ platform: 'ios', osVersion: '17' }); + + expect(filters).toContainEqual({ attribute: 'version', operator: 'GREATER_THAN_OR_EQUALS', value: '17' }); + expect(filters).toContainEqual({ attribute: 'version', operator: 'LESS_THAN', value: '18' }); +}); + +test('comparator osVersion expressions map onto the matching fleet operators', () => { + expect(buildFilters({ platform: 'android', osVersion: '>16' })).toContainEqual( + { attribute: 'version', operator: 'GREATER_THAN', value: '16' }, + ); + expect(buildFilters({ platform: 'android', osVersion: '<=16' })).toContainEqual( + { attribute: 'version', operator: 'LESS_THAN_OR_EQUALS', value: '16' }, + ); +}); + +test('an invalid osVersion expression throws before reaching the fleet API', () => { + expect(() => buildFilters({ platform: 'ios', osVersion: 'latest' })).toThrow(); +}); diff --git a/packages/driver-mobilenext/src/driver.ts b/packages/driver-mobilenext/src/driver.ts index 073b1c5..9e46940 100644 --- a/packages/driver-mobilenext/src/driver.ts +++ b/packages/driver-mobilenext/src/driver.ts @@ -29,6 +29,7 @@ import type { TestObserver, ViewNode, } from '@mobilewright/protocol'; +import { parseOsVersion } from '@mobilewright/protocol'; import { RpcClient } from './rpc-client.js'; import { FleetApiClient, type DeviceFilter } from './fleet-api.js'; import { MobileNextTestObserver, type MobileNextTestResultConfig } from './observer.js'; @@ -108,7 +109,7 @@ export interface MobileNextDriverOptions { uploadTimeout?: number; } -function buildFilters(criteria: AllocationCriteria): DeviceFilter[] { +export function buildFilters(criteria: AllocationCriteria): DeviceFilter[] { // The fleet API requires exactly one platform filter, so a missing platform is a caller error — // fail loudly instead of silently constraining every allocation to iOS. if (!criteria.platform) { @@ -128,6 +129,18 @@ function buildFilters(criteria: AllocationCriteria): DeviceFilter[] { if (criteria.deviceNamePattern) { filters.push({ attribute: 'name', operator: 'CONTAINS', value: criteria.deviceNamePattern }); } + if (criteria.deviceType) { + filters.push({ attribute: 'type', operator: 'EQUALS', value: criteria.deviceType }); + } + if (criteria.osVersion) { + const range = parseOsVersion(criteria.osVersion); + if (range.min) { + filters.push({ attribute: 'version', operator: range.min.inclusive ? 'GREATER_THAN_OR_EQUALS' : 'GREATER_THAN', value: range.min.version }); + } + if (range.max) { + filters.push({ attribute: 'version', operator: range.max.inclusive ? 'LESS_THAN_OR_EQUALS' : 'LESS_THAN', value: range.max.version }); + } + } return filters; } diff --git a/packages/mobilewright/src/config.ts b/packages/mobilewright/src/config.ts index 0635555..06be38e 100644 --- a/packages/mobilewright/src/config.ts +++ b/packages/mobilewright/src/config.ts @@ -25,6 +25,10 @@ export interface MobilewrightUseOptions { deviceId?: string; /** Regex to match device name. */ deviceName?: RegExp; + /** Restrict to simulators, emulators, or real devices. */ + deviceType?: 'simulator' | 'emulator' | 'real'; + /** OS version constraint, e.g. "17" (any 17.x), "26.0" (exactly 26.0) or ">=17 <19". */ + osVersion?: string; /** App bundle ID for this project. */ bundleId?: string; /** App paths (APK/IPA) to install for this project. Overrides top-level installApps. */ @@ -79,6 +83,10 @@ export interface MobilewrightConfig { deviceId?: string; /** Regex to match device name (e.g. /iPhone 17/). */ deviceName?: RegExp; + /** Restrict to simulators, emulators, or real devices. */ + deviceType?: 'simulator' | 'emulator' | 'real'; + /** OS version constraint, e.g. "17" (any 17.x), "26.0" (exactly 26.0) or ">=17 <19". */ + osVersion?: string; /** Default app bundle ID. */ bundleId?: string; /** App paths (APK/IPA) to install on the device before launching. */ 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 bb89ae2..baa91f6 100644 --- a/packages/mobilewright/src/device-pool/application/device-pool.test.ts +++ b/packages/mobilewright/src/device-pool/application/device-pool.test.ts @@ -233,3 +233,44 @@ test('allocation that exceeds allocationTimeoutMs rejects with timeout error', a await expect(pool.allocate({ platform: 'ios' })).rejects.toThrow(/timed out/i); }); + +test('a released simulator slot is not reused for a waiter demanding a real device', async () => { + const driver = makeDriver([ + { deviceId: 'sim-1', platform: 'ios', type: 'simulator' }, + { deviceId: 'real-1', platform: 'ios', type: 'real' }, + ]); + const pool = new DevicePool({ driver, maxSlots: 2 }); + + const first = await pool.allocate({ platform: 'ios', deviceType: 'simulator' }); + await pool.release(first.allocationId); + const second = await pool.allocate({ platform: 'ios', deviceType: 'real' }); + + expect(second.deviceId).toBe('real-1'); +}); + +test('a released slot is not reused for a waiter whose osVersion the device does not satisfy', async () => { + const driver = makeDriver([ + { deviceId: 'ios16', platform: 'ios', osVersion: '16.4' }, + { deviceId: 'ios17', platform: 'ios', osVersion: '17.5' }, + ]); + const pool = new DevicePool({ driver, maxSlots: 2 }); + + const first = await pool.allocate({ platform: 'ios' }); + await pool.release(first.allocationId); + const second = await pool.allocate({ platform: 'ios', osVersion: '17' }); + + expect(second.deviceId).toBe('ios17'); +}); + +test('a released slot is reused when it satisfies the deviceType and osVersion criteria', async () => { + const driver = makeDriver([ + { deviceId: 'sim-17', platform: 'ios', type: 'simulator', osVersion: '17.5' }, + ]); + const pool = new DevicePool({ driver, maxSlots: 2 }); + + const first = await pool.allocate({ platform: 'ios' }); + await pool.release(first.allocationId); + const second = await pool.allocate({ platform: 'ios', deviceType: 'simulator', osVersion: '>=17' }); + + expect(second.deviceId).toBe('sim-17'); +}); diff --git a/packages/mobilewright/src/device-pool/application/device-pool.ts b/packages/mobilewright/src/device-pool/application/device-pool.ts index 0e044e7..5ff382c 100644 --- a/packages/mobilewright/src/device-pool/application/device-pool.ts +++ b/packages/mobilewright/src/device-pool/application/device-pool.ts @@ -1,4 +1,5 @@ import type { DeviceAllocator } from '@mobilewright/protocol'; +import { osVersionSatisfies } from '@mobilewright/protocol'; import { DeviceSlot } from '../domain/device-slot.js'; import { Allocation } from '../domain/allocation.js'; import { NoDeviceAvailableError } from './ports.js'; @@ -230,5 +231,11 @@ function slotMatches(slot: DeviceSlot, criteria: AllocationCriteria): boolean { if (criteria.deviceId && slot.deviceId !== criteria.deviceId) { return false; } + if (criteria.deviceType && slot.type !== criteria.deviceType) { + return false; + } + if (criteria.osVersion && (slot.osVersion === undefined || !osVersionSatisfies(slot.osVersion, criteria.osVersion))) { + return false; + } return true; } diff --git a/packages/protocol/src/driver.ts b/packages/protocol/src/driver.ts index 33de1e9..7107dbb 100644 --- a/packages/protocol/src/driver.ts +++ b/packages/protocol/src/driver.ts @@ -39,6 +39,10 @@ export interface AllocationCriteria { /** Regex source (`RegExp.prototype.source`) matched against device name. */ deviceNamePattern?: string; deviceId?: string; + /** Restrict to simulators, emulators, or real devices. */ + deviceType?: DeviceType; + /** OS version constraint expression, e.g. "17", "26.0" or ">=17 <19". See `parseOsVersion`. */ + osVersion?: string; } export interface AllocatedDevice { diff --git a/packages/protocol/src/index.ts b/packages/protocol/src/index.ts index 4ebdcbf..03838dd 100644 --- a/packages/protocol/src/index.ts +++ b/packages/protocol/src/index.ts @@ -1,3 +1,5 @@ export type * from './types.js'; export type * from './driver.js'; export { NoDeviceAvailableError } from './driver.js'; +export { parseOsVersion, osVersionSatisfies } from './os-version.js'; +export type { OsVersionBound, OsVersionRange } from './os-version.js'; diff --git a/packages/protocol/src/os-version.test.ts b/packages/protocol/src/os-version.test.ts new file mode 100644 index 0000000..7421b46 --- /dev/null +++ b/packages/protocol/src/os-version.test.ts @@ -0,0 +1,98 @@ +import { test, expect } from '@playwright/test'; +import { parseOsVersion, osVersionSatisfies } from './os-version.js'; + +// ─── bare version = prefix match ───────────────────────────── + +test('a bare major version matches any release of that major', () => { + expect(osVersionSatisfies('17.0', '17')).toBe(true); + expect(osVersionSatisfies('17.5.1', '17')).toBe(true); + expect(osVersionSatisfies('17', '17')).toBe(true); +}); + +test('a bare major version rejects neighboring majors', () => { + expect(osVersionSatisfies('16.9', '17')).toBe(false); + expect(osVersionSatisfies('18.0', '17')).toBe(false); + expect(osVersionSatisfies('18', '17')).toBe(false); +}); + +test('a two-segment version matches only that minor release', () => { + expect(osVersionSatisfies('26.0', '26.0')).toBe(true); + expect(osVersionSatisfies('26.0.1', '26.0')).toBe(true); + expect(osVersionSatisfies('26.1', '26.0')).toBe(false); + expect(osVersionSatisfies('26.5', '26.0')).toBe(false); +}); + +// ─── comparators ───────────────────────────────────────────── + +test('>= matches the version itself and anything above', () => { + expect(osVersionSatisfies('17', '>=17')).toBe(true); + expect(osVersionSatisfies('19.2', '>=17')).toBe(true); + expect(osVersionSatisfies('16.9', '>=17')).toBe(false); +}); + +test('a >= and < pair expresses a range', () => { + expect(osVersionSatisfies('17.0', '>=17 <19')).toBe(true); + expect(osVersionSatisfies('18.4', '>=17 <19')).toBe(true); + expect(osVersionSatisfies('19.0', '>=17 <19')).toBe(false); + expect(osVersionSatisfies('16.9', '>=17 <19')).toBe(false); +}); + +test('strict > excludes the version itself', () => { + expect(osVersionSatisfies('17.0', '>17')).toBe(false); + expect(osVersionSatisfies('17.0.1', '>17')).toBe(true); +}); + +test('<= includes the version itself', () => { + expect(osVersionSatisfies('18', '<=18')).toBe(true); + expect(osVersionSatisfies('18.0.1', '<=18')).toBe(false); +}); + +// ─── numeric (not lexicographic) comparison ────────────────── + +test('segments compare numerically so 10 sorts above 9', () => { + expect(osVersionSatisfies('10.0', '>=9')).toBe(true); + expect(osVersionSatisfies('17.10', '>=17.9')).toBe(true); +}); + +// ─── parse output (used to build fleet API filters) ────────── + +test('parsing a bare version yields inclusive lower and exclusive upper bounds', () => { + expect(parseOsVersion('17')).toEqual({ + min: { version: '17', inclusive: true }, + max: { version: '18', inclusive: false }, + }); + expect(parseOsVersion('26.0')).toEqual({ + min: { version: '26.0', inclusive: true }, + max: { version: '26.1', inclusive: false }, + }); +}); + +test('parsing comparators yields only the bounds given', () => { + expect(parseOsVersion('>=17')).toEqual({ min: { version: '17', inclusive: true } }); + expect(parseOsVersion('>=17 <19')).toEqual({ + min: { version: '17', inclusive: true }, + max: { version: '19', inclusive: false }, + }); +}); + +// ─── invalid input fails loudly ────────────────────────────── + +test('malformed expressions throw', () => { + expect(() => parseOsVersion('')).toThrow(); + expect(() => parseOsVersion('abc')).toThrow(); + expect(() => parseOsVersion('>=x')).toThrow(); + expect(() => parseOsVersion('~17')).toThrow(); + expect(() => parseOsVersion('17 <19')).toThrow(); // bare version cannot mix with comparators + expect(() => parseOsVersion('>=17 >=18')).toThrow(); // duplicate lower bounds +}); + +test('contradictory ranges throw', () => { + expect(() => parseOsVersion('>=19 <17')).toThrow(); // lower above upper + expect(() => parseOsVersion('>17 <=17')).toThrow(); // equal bounds, exclusive lower + expect(() => parseOsVersion('>=17 <17')).toThrow(); // equal bounds, exclusive upper +}); + +test('an inclusive equal-bound range means exactly that version and stays valid', () => { + expect(osVersionSatisfies('17', '>=17 <=17')).toBe(true); + expect(osVersionSatisfies('17.1', '>=17 <=17')).toBe(false); +}); diff --git a/packages/protocol/src/os-version.ts b/packages/protocol/src/os-version.ts new file mode 100644 index 0000000..4dee728 --- /dev/null +++ b/packages/protocol/src/os-version.ts @@ -0,0 +1,109 @@ +/** + * OS-version constraint expressions for device allocation. + * + * Grammar: + * - bare version: "17" or "26.0" — prefix match: >= the version, < the + * version with its last given segment bumped ("17" → >=17 <18). + * - comparators: ">=17", ">=17 <19" — space-separated, at most one lower + * bound (>= or >) and one upper bound (< or <=). + */ + +export interface OsVersionBound { + version: string; + inclusive: boolean; +} + +export interface OsVersionRange { + min?: OsVersionBound; + max?: OsVersionBound; +} + +const VERSION_RE = /^\d+(\.\d+)*$/; + +function assertValidVersion(version: string): void { + if (!VERSION_RE.test(version)) { + throw new Error(`invalid OS version "${version}" — expected digits separated by dots, e.g. "17" or "26.0"`); + } +} + +/** Numeric dot-segment comparison; missing segments count as 0. */ +function compareVersions(a: string, b: string): number { + const as = a.split('.').map(Number); + const bs = b.split('.').map(Number); + const len = Math.max(as.length, bs.length); + for (let i = 0; i < len; i++) { + const diff = (as[i] ?? 0) - (bs[i] ?? 0); + if (diff !== 0) { + return diff; + } + } + return 0; +} + +/** "26.0" → "26.1", "17" → "18" — the exclusive upper bound of a prefix match. */ +function bumpLastSegment(version: string): string { + const segments = version.split('.').map(Number); + segments[segments.length - 1] += 1; + return segments.join('.'); +} + +export function parseOsVersion(expr: string): OsVersionRange { + const parts = expr.trim().split(/\s+/).filter((p) => p.length > 0); + if (parts.length === 0) { + throw new Error('empty OS version expression'); + } + + if (parts.length === 1 && VERSION_RE.test(parts[0])) { + return { + min: { version: parts[0], inclusive: true }, + max: { version: bumpLastSegment(parts[0]), inclusive: false }, + }; + } + + const range: OsVersionRange = {}; + for (const part of parts) { + const match = /^(>=|<=|>|<)(.+)$/.exec(part); + if (!match) { + throw new Error(`invalid OS version constraint "${part}" — expected ">=", ">", "<=" or "<" followed by a version, or a single bare version`); + } + const [, op, version] = match; + assertValidVersion(version); + const isLower = op === '>=' || op === '>'; + const bound: OsVersionBound = { version, inclusive: op === '>=' || op === '<=' }; + if (isLower) { + if (range.min) { + throw new Error(`duplicate lower bound in OS version expression "${expr}"`); + } + range.min = bound; + } else { + if (range.max) { + throw new Error(`duplicate upper bound in OS version expression "${expr}"`); + } + range.max = bound; + } + } + if (range.min && range.max) { + const cmp = compareVersions(range.min.version, range.max.version); + if (cmp > 0 || (cmp === 0 && !(range.min.inclusive && range.max.inclusive))) { + throw new Error(`impossible OS version range "${expr}" — no version can satisfy it`); + } + } + return range; +} + +export function osVersionSatisfies(version: string, expr: string): boolean { + const range = parseOsVersion(expr); + if (range.min) { + const cmp = compareVersions(version, range.min.version); + if (cmp < 0 || (cmp === 0 && !range.min.inclusive)) { + return false; + } + } + if (range.max) { + const cmp = compareVersions(version, range.max.version); + if (cmp > 0 || (cmp === 0 && !range.max.inclusive)) { + return false; + } + } + return true; +} diff --git a/packages/test/src/fixtures.ts b/packages/test/src/fixtures.ts index e0191c9..88f81b5 100644 --- a/packages/test/src/fixtures.ts +++ b/packages/test/src/fixtures.ts @@ -50,6 +50,8 @@ type MobilewrightTestFixtures = { platform: 'ios' | 'android' | undefined; deviceId: string | undefined; deviceName: RegExp | undefined; + deviceType: 'simulator' | 'emulator' | 'real' | undefined; + osVersion: string | undefined; installApps: string | string[] | undefined; viewTree: 'on-failure' | 'off'; device: Device; @@ -77,6 +79,8 @@ export const test = base.extend({ platform: [undefined, { option: true }], deviceId: [undefined, { option: true }], deviceName: [undefined, { option: true }], + deviceType: [undefined, { option: true }], + osVersion: [undefined, { option: true }], installApps: [undefined, { option: true }], viewTree: [async ({}, use, testInfo) => { @@ -89,7 +93,7 @@ export const test = base.extend({ await use(value); }, { option: true }], - device: async ({ platform, deviceId, deviceName, bundleId, autoAppLaunch, installApps }, use, testInfo) => { + device: async ({ platform, deviceId, deviceName, deviceType, osVersion, bundleId, autoAppLaunch, installApps }, use, testInfo) => { const config = await loadConfig(process.cwd(), testInfo.config.configFile); const project = config.projects?.find(p => p.name === testInfo.project.name); const projectUse = { ...config.use, ...project?.use }; @@ -98,6 +102,8 @@ export const test = base.extend({ ...(platform && { platform }), ...(deviceId !== undefined && { deviceId }), ...(deviceName && { deviceName }), + ...(deviceType && { deviceType }), + ...(osVersion && { osVersion }), ...(installApps !== undefined && { installApps }), use: projectUse, }; @@ -116,6 +122,8 @@ export const test = base.extend({ platform: merged.platform, deviceNamePattern: merged.deviceName?.source, deviceId: merged.deviceId, + deviceType: merged.deviceType, + osVersion: merged.osVersion, }); debug('allocated device %s', handle.deviceId);