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
65 changes: 64 additions & 1 deletion src/__tests__/client-normalizers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { test } from 'vitest';
import assert from 'node:assert/strict';
import { normalizeOpenDevice } from '../client/client-normalizers.ts';
import {
normalizeDevice,
normalizeOpenDevice,
normalizeSession,
} from '../client/client-normalizers.ts';
import { PUBLIC_PLATFORMS } from '../kernel/device.ts';

test('normalizeOpenDevice accepts exactly the canonical leaf platforms', () => {
Expand Down Expand Up @@ -33,6 +37,65 @@ test('normalizeOpenDevice rejects the apple selector and unknown platforms', ()
);
});

test('normalizeDevice carries the additive appleOs discriminant when present', () => {
const ipad = normalizeDevice({
platform: 'ios',
appleOs: 'ipados',
id: 'ipad-sim-1',
name: 'iPad Pro 11-inch',
kind: 'simulator',
booted: true,
});
assert.equal(ipad.appleOs, 'ipados');
// `platform` stays the PUBLIC leaf; appleOs is additive, not a replacement.
assert.equal(ipad.platform, 'ios');
});

test('normalizeDevice omits appleOs for non-Apple and invalid values', () => {
const android = normalizeDevice({
platform: 'android',
id: 'emulator-5554',
name: 'Pixel',
kind: 'emulator',
});
assert.equal('appleOs' in android, false);

const bogus = normalizeDevice({
platform: 'ios',
appleOs: 'windowsphone',
id: 'sim-1',
name: 'iPhone',
kind: 'simulator',
});
assert.equal('appleOs' in bogus, false);

// Regression: a non-Apple platform carrying a VALID Apple OS value must still be
// dropped — appleOs is Apple-only, gated on the platform, not merely on being a
// valid AppleOS value.
const androidWithStrayAppleOs = normalizeDevice({
platform: 'android',
appleOs: 'macos',
id: 'emulator-5555',
name: 'Pixel',
kind: 'emulator',
});
assert.equal('appleOs' in androidWithStrayAppleOs, false);
});

test('normalizeSession carries the additive appleOs discriminant on the session device', () => {
const session = normalizeSession({
name: 'default',
createdAt: 1,
platform: 'ios',
appleOs: 'tvos',
id: 'tv-sim-1',
device: 'Apple TV',
target: 'tv',
});
assert.equal(session.device.appleOs, 'tvos');
assert.equal(session.device.platform, 'ios');
});

test('normalizeOpenDevice preserves per-platform identifier shaping', () => {
const ios = normalizeOpenDevice({
platform: 'ios',
Expand Down
14 changes: 13 additions & 1 deletion src/client/client-normalizers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import type { DaemonRequest, SessionRuntimeHints } from '../daemon/types.ts';
import { AppError, type NormalizedError } from '../kernel/errors.ts';
import type { SnapshotNode } from '../kernel/snapshot.ts';
import { buildAppIdentifiers, buildDeviceIdentifiers } from './client-shared.ts';
import { isPublicPlatform } from '../kernel/device.ts';
import { isAppleOs, isApplePlatform, isPublicPlatform, type AppleOS } from '../kernel/device.ts';
import {
leaseScopeFromOptions,
leaseScopeToCommandFlags,
Expand Down Expand Up @@ -94,13 +94,17 @@ export function normalizeMaterializationReleaseResult(

export function normalizeDevice(value: unknown): AgentDeviceDevice {
const { record, platform, id, name, target } = readClientDeviceIdentity(value, 'name');
const appleOs = readAppleOs(record);
return {
platform,
target,
kind: readRequiredDeviceKind(record, 'kind'),
id,
name,
booted: typeof record.booted === 'boolean' ? record.booted : undefined,
// Additive Apple-OS discriminant; Apple platforms only — gate on the platform so
// a non-Apple record with a stray appleOs value is not preserved.
...(isApplePlatform(platform) && appleOs ? { appleOs } : {}),
identifiers: buildDeviceIdentifiers(platform, id, name),
...buildClientDevicePlatformFields(platform, id),
};
Expand All @@ -109,6 +113,7 @@ export function normalizeDevice(value: unknown): AgentDeviceDevice {
export function normalizeSession(value: unknown): AgentDeviceSession {
const { record, platform, id, name, target } = readClientDeviceIdentity(value, 'name');
const deviceName = readRequiredString(record, 'device');
const appleOs = readAppleOs(record);
const identifiers = {
session: name,
...buildDeviceIdentifiers(platform, id, deviceName),
Expand All @@ -123,6 +128,8 @@ export function normalizeSession(value: unknown): AgentDeviceSession {
target,
id,
name: deviceName,
// Additive Apple-OS discriminant; present only when the daemon emits it (Apple devices).
...(appleOs ? { appleOs } : {}),
identifiers,
...buildClientDevicePlatformFields(
platform,
Expand All @@ -134,6 +141,11 @@ export function normalizeSession(value: unknown): AgentDeviceSession {
};
}

function readAppleOs(record: Record<string, unknown>): AppleOS | undefined {
const value = record.appleOs;
return isAppleOs(value) ? value : undefined;
}

function readClientDeviceIdentity(value: unknown, nameField: string) {
const record = asRecord(value);
return {
Expand Down
12 changes: 12 additions & 0 deletions src/client/client-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import type {
SessionRuntimeHints,
} from '../kernel/contracts.ts';
import type {
AppleOS,
DeviceKind,
DeviceTarget,
PublicPlatform,
Expand Down Expand Up @@ -61,6 +62,7 @@ export type { CompanionTunnelScope, MetroBridgeScope } from './client-companion-
export type { AppsFilter } from '../contracts/app-inventory.ts';
export type { AlertAction, AlertInfo, AlertPlatform, AlertSource } from '../alert-contract.ts';
export type { DebugSymbolsOptions, DebugSymbolsResult } from '../contracts/debug-symbols.ts';
export type { AppleOS } from '../kernel/device.ts';
export type { BootCommandResult, ShutdownCommandResult } from '../contracts/device.ts';
export type { ViewportCommandResult } from '../contracts/viewport.ts';
export type {
Expand Down Expand Up @@ -162,6 +164,11 @@ export type AgentDeviceDevice = {
id: string;
name: string;
booted?: boolean;
/**
* Additive Apple-OS discriminant (iPhone/iPad/tvOS/visionOS/macOS). Present only for
* Apple devices; `platform` still carries the leaf (`ios`/`macos`).
*/
appleOs?: AppleOS;
identifiers: AgentDeviceIdentifiers;
ios?: {
udid: string;
Expand All @@ -176,6 +183,11 @@ export type AgentDeviceSessionDevice = {
target: DeviceTarget;
id: string;
name: string;
/**
* Additive Apple-OS discriminant (iPhone/iPad/tvOS/visionOS/macOS). Present only for
* Apple devices; `platform` still carries the leaf (`ios`/`macos`).
*/
appleOs?: AppleOS;
identifiers: AgentDeviceIdentifiers;
ios?: {
udid: string;
Expand Down
23 changes: 18 additions & 5 deletions src/contracts/device.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import type { DeviceKind, DeviceTarget, PublicPlatform } from '../kernel/device.ts';
import type { AppleOS, DeviceKind, DeviceTarget, PublicPlatform } from '../kernel/device.ts';
import type { TargetShutdownResult } from '../target-shutdown-contract.ts';

/**
* Closed result of the `boot` command. Mirrors the daemon handler's only
* success return EXACTLY (src/daemon/handlers/session-state.ts) — the fixed
* object literal `{ platform, target, device, id, kind, booted }`. The handler
* spreads nothing, so this shape is intentionally closed.
* object literal `{ platform, target, device, id, kind, booted }` plus the
* additive `appleOs` discriminant, emitted only for Apple devices.
*/
export type BootCommandResult = {
platform: PublicPlatform;
Expand All @@ -17,13 +17,20 @@ export type BootCommandResult = {
kind: DeviceKind;
/** Always `true` on the success path. */
booted: true;
/**
* Additive Apple-OS discriminant (`device.appleOs`): iPhone/iPad/tvOS/visionOS/macOS.
* Present only for Apple devices; absent for non-Apple platforms. `platform` stays the
* leaf (`ios`/`macos`) — this is an extra field, not a replacement.
*/
appleOs?: AppleOS;
};

/**
* Closed result of the `shutdown` command. Mirrors the daemon handler's success
* return EXACTLY (src/daemon/handlers/session-state.ts) — the fixed object
* literal `{ platform, target, device, id, kind, shutdown }`. The `shutdown`
* field is the raw {@link TargetShutdownResult} from `shutdownDeviceTarget`.
* literal `{ platform, target, device, id, kind, shutdown }` plus the additive
* `appleOs` discriminant (Apple devices only). The `shutdown` field is the raw
* {@link TargetShutdownResult} from `shutdownDeviceTarget`.
*/
export type ShutdownCommandResult = {
platform: PublicPlatform;
Expand All @@ -34,4 +41,10 @@ export type ShutdownCommandResult = {
id: string;
kind: DeviceKind;
shutdown: TargetShutdownResult;
/**
* Additive Apple-OS discriminant (`device.appleOs`): iPhone/iPad/tvOS/visionOS/macOS.
* Present only for Apple devices; absent for non-Apple platforms. `platform` stays the
* leaf (`ios`/`macos`) — this is an extra field, not a replacement.
*/
appleOs?: AppleOS;
};
105 changes: 105 additions & 0 deletions src/daemon/handlers/__tests__/session-inventory-appleos.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import { test, expect, vi, beforeEach } from 'vitest';

// The `devices` handler resolves its inventory through listDeviceInventory; mocking it
// lets us drive the additive `appleOs` projection off the shared device fixtures without
// touching real local discovery.
vi.mock('../../../core/dispatch-resolve.ts', async (importOriginal) => {
const actual = await importOriginal<typeof import('../../../core/dispatch-resolve.ts')>();
return { ...actual, listDeviceInventory: vi.fn(async () => []) };
});

import { handleSessionInventoryCommands } from '../session-inventory.ts';
import { listDeviceInventory } from '../../../core/dispatch-resolve.ts';
import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts';
import type { DaemonRequest, DaemonResponse } from '../../types.ts';
import type { AppleOS, DeviceInfo } from '../../../kernel/device.ts';
import {
ANDROID_EMULATOR,
IOS_SIMULATOR,
IPADOS_SIMULATOR,
MACOS_DEVICE,
TVOS_SIMULATOR,
VISIONOS_SIMULATOR,
} from '../../../__tests__/test-utils/device-fixtures.ts';

const mockListDeviceInventory = vi.mocked(listDeviceInventory);

beforeEach(() => {
mockListDeviceInventory.mockReset();
});

type PublicDevice = { id: string; platform: string; appleOs?: AppleOS };

async function runDevices(): Promise<DaemonResponse | null> {
const req: DaemonRequest = {
token: 't',
session: 'default',
command: 'devices',
positionals: [],
flags: {},
};
return handleSessionInventoryCommands({
req,
sessionName: 'default',
sessionStore: makeSessionStore('agent-device-inventory-appleos-'),
});
}

async function listPublicDevices(inventory: DeviceInfo[]): Promise<PublicDevice[]> {
mockListDeviceInventory.mockResolvedValue(inventory);
const response = await runDevices();
expect(response?.ok).toBe(true);
if (!response?.ok) throw new Error('expected devices to succeed');
return response.data?.devices as PublicDevice[];
}

test('devices surfaces the appleOs discriminant per Apple fixture', async () => {
const devices = await listPublicDevices([
IOS_SIMULATOR,
IPADOS_SIMULATOR,
TVOS_SIMULATOR,
VISIONOS_SIMULATOR,
MACOS_DEVICE,
]);

const byId = new Map(devices.map((device) => [device.id, device]));
const expected: Array<[string, AppleOS, string]> = [
[IOS_SIMULATOR.id, 'ios', 'ios'],
[IPADOS_SIMULATOR.id, 'ipados', 'ios'],
[TVOS_SIMULATOR.id, 'tvos', 'ios'],
[VISIONOS_SIMULATOR.id, 'visionos', 'ios'],
[MACOS_DEVICE.id, 'macos', 'macos'],
];

for (const [id, appleOs, leaf] of expected) {
const device = byId.get(id);
expect(device, `expected device ${id} in output`).toBeTruthy();
// The additive `appleOs` carries the specific Apple OS ...
expect(device?.appleOs).toBe(appleOs);
// ... while `platform` stays the PUBLIC leaf (never the internal `apple`).
expect(device?.platform).toBe(leaf);
expect(device?.platform).not.toBe('apple');
}
});

test('devices omits appleOs for non-Apple devices', async () => {
const devices = await listPublicDevices([ANDROID_EMULATOR, IOS_SIMULATOR]);

const android = devices.find((device) => device.id === ANDROID_EMULATOR.id);
expect(android?.platform).toBe('android');
expect(android && 'appleOs' in android).toBe(false);

const ios = devices.find((device) => device.id === IOS_SIMULATOR.id);
expect(ios?.appleOs).toBe('ios');
});

test('devices drops a stray appleOs on a non-Apple device (gated to Apple platforms)', async () => {
// Regression: appleOs is Apple-only. A malformed/legacy NON-Apple record carrying a
// valid Apple OS value must NOT surface it — the projection gates on the platform,
// not merely on field presence.
const androidWithStrayAppleOs: DeviceInfo = { ...ANDROID_EMULATOR, appleOs: 'macos' };
const devices = await listPublicDevices([androidWithStrayAppleOs]);
const android = devices.find((device) => device.id === ANDROID_EMULATOR.id);
expect(android?.platform).toBe('android');
expect(android && 'appleOs' in android).toBe(false);
});
8 changes: 6 additions & 2 deletions src/daemon/handlers/__tests__/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -318,7 +318,7 @@ test('devices filters Apple-family platform selectors', async () => {
}
});

test('devices omits internal appleOs from the public inventory projection', async () => {
test('devices surfaces appleOs additively while keeping platform the public leaf', async () => {
const sessionStore = makeSessionStore();
mockListAndroidDevices.mockResolvedValue([]);
mockListAppleDevices.mockResolvedValue([
Expand Down Expand Up @@ -352,7 +352,11 @@ test('devices omits internal appleOs from the public inventory projection', asyn
if (response?.ok) {
const devices = response.data?.devices as Array<Record<string, unknown>> | undefined;
expect(devices).toHaveLength(1);
expect(devices?.[0]).not.toHaveProperty('appleOs');
// appleOs is now surfaced additively (iPad -> ipados) ...
expect(devices?.[0]?.appleOs).toBe('ipados');
// ... while `platform` stays the PUBLIC leaf (never the internal `apple`).
expect(devices?.[0]?.platform).toBe('ios');
// The internal-only simulator set path is still stripped from the public shape.
expect(devices?.[0]).not.toHaveProperty('simulatorSetPath');
expect(devices?.[0]?.id).toBe('sim-1');
}
Expand Down
17 changes: 13 additions & 4 deletions src/daemon/handlers/session-inventory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,12 @@ export async function handleSessionInventoryCommands(params: {
runnerLogPath: resolveSessionRunnerLogPath(sessionStateDir),
// approach (b): emit the PUBLIC leaf platform (ios/macos), not `apple`.
platform: publicPlatformString(session.device),
// Additive Apple-OS discriminant; Apple devices only. Gate on the
// platform (not just field presence) so a non-Apple record carrying a
// stray appleOs value never surfaces it.
...(isApplePlatform(session.device.platform) && session.device.appleOs
? { appleOs: session.device.appleOs }
: {}),
target: session.device.target ?? 'mobile',
surface: session.surface ?? 'app',
device: session.device.name,
Expand Down Expand Up @@ -92,14 +98,17 @@ export async function handleSessionInventoryCommands(params: {
const filtered = req.flags?.target
? platformFiltered.filter((device) => (device.target ?? 'mobile') === req.flags?.target)
: platformFiltered;
// Keep appleOs internal-only for now: it is discovery groundwork and the
// public `devices` shape is not yet meant to expose it. Surfacing it (so
// agents can tell iPad from iPhone) should be a deliberate later change.
// approach (b): project `platform` back to the PUBLIC leaf (ios/macos).
// Surface the `appleOs` discriminant additively so consumers can distinguish
// iPhone/iPad/tvOS/visionOS/macOS instead of only the leaf `ios`/`macos`. It is
// emitted ONLY for Apple devices (non-Apple platforms carry no `appleOs`), and
// `platform` stays the PUBLIC leaf via `publicPlatformString` (approach b). The
// internal-only `simulatorSetPath` is still stripped. `appleOs` values never equal
// the internal `apple` token, so this does not affect the apple-leak guard.
const publicDevices = filtered.map(
({ simulatorSetPath: _simulatorSetPath, appleOs, ...device }) => ({
...device,
platform: publicPlatformString({ platform: device.platform, appleOs }),
...(isApplePlatform(device.platform) && appleOs ? { appleOs } : {}),
}),
);
return { ok: true, data: { devices: publicDevices } };
Expand Down
Loading
Loading