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
81 changes: 64 additions & 17 deletions src/core/__tests__/capability-plugin-routing-parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,25 @@ import { platformDescriptors } from '../platform-descriptor/registry.ts';
import { getPlugin } from '../platform-plugin/plugin.ts';
import { registerBuiltinPlatformPlugins } from '../platform-plugin/register-builtins.ts';

// Phase 3 step (b) parity gate. Two independent oracles pin that the migration is
// Phase 3 step (b) parity gate. Independent oracles pin that the migration is
// byte-for-byte behaviorless:
// (b.1) the platform -> capability-bucket selection in `isCommandSupportedOnDevice`
// now flows through the PlatformPlugin registry instead of the
// `platformDescriptors` fold. `deriveCapabilityForPlatform(platformDescriptors,
// ...)` is kept here as the BEFORE-derivation oracle (the production fold was
// deleted), so a plugin-vs-descriptor disagreement fails this test.
// (b.2) the per-command `supports()` / `unsupportedHint()` closures stay VERBATIM on
// the command-descriptor facet (they cannot move to the plugin's per-FAMILY
// `capability.supportsByDefault` without flattening their per-command shape —
// perfect-shape §7). Independent verbatim copies below pin that the closures,
// as they flow through `deriveCapabilityMatrix` into admission, are unchanged
// across the full {platform x command x device-kind x target} matrix.
// (b.2) the per-command `supports()` / `unsupportedHint()` device closures were
// RELOCATED VERBATIM off the command-descriptor facet onto the owning
// PlatformPlugin's `capability.supportsByDefault` / `unsupportedHintByDefault`
// (perfect-shape §7: relocate, never flatten). This is faithful because every
// such closure is a no-op (returns `true` / `undefined`) on non-Apple devices,
// so consulting it only for the Apple family — the family that owns the
// relocated map — leaves admission unchanged. The independent VERBATIM copies
// below are the oracle: they pin (a) that production admission (`isCommand
// SupportedOnDevice`) and hint output (`unsupportedHintForDevice`) are unchanged
// across the full {platform x command x device-kind x target} matrix, and (b)
// that the closures now living on the Apple plugin are byte-for-byte behaviorally
// identical to the originals across the sample-device matrix.

registerBuiltinPlatformPlugins();

Expand Down Expand Up @@ -211,17 +217,58 @@ test('(b.2) unsupportedHint closures are verbatim across the full device matrix'
}
});

test('(b.2) every command carrying a supports closure is covered by the reference map', () => {
// Guards the SUPPORTS_REF/HINT_REF oracle against silently missing a closure: a
// command whose admission depends on a supports gate must appear in SUPPORTS_REF,
// and every hint-bearing command must appear in HINT_REF.
for (const command of listCapabilityCommands()) {
const capability = BASE_COMMAND_CAPABILITY_MATRIX[command];
if (capability?.supports) {
assert.ok(SUPPORTS_REF[command], `${command} supports closure present in reference map`);
test('(b.2) the Apple plugin carries exactly the relocated supports/hint closures', () => {
// The relocation target: `supports()` / `unsupportedHint()` now live on the Apple
// plugin (the family that owns every discriminating device). Pin the RELOCATED maps'
// key sets against the independent verbatim reference so no closure was silently
// dropped or added while moving off the command facet.
const appleCapability = getPlugin('ios').capability;
assert.deepEqual(
Object.keys(appleCapability.supportsByDefault ?? {}).sort(),
Object.keys(SUPPORTS_REF).sort(),
'supportsByDefault key set equals the verbatim reference',
);
assert.deepEqual(
Object.keys(appleCapability.unsupportedHintByDefault ?? {}).sort(),
Object.keys(HINT_REF).sort(),
'unsupportedHintByDefault key set equals the verbatim reference',
);
// ios and macos are the SAME Apple plugin instance, so both leaves read one map.
assert.equal(getPlugin('ios').capability, getPlugin('macos').capability);
});

test('(b.2) the relocated Apple closures are byte-for-byte the verbatim originals', () => {
// Closure-equivalence: for every command x sample-device, the closure now living on
// the Apple plugin returns an identical boolean / identical hint STRING to the
// independent verbatim copy of the original command-facet closure.
const appleCapability = getPlugin('ios').capability;
for (const [command, reference] of Object.entries(SUPPORTS_REF)) {
const relocated = appleCapability.supportsByDefault?.[command];
assert.ok(relocated, `${command} supports closure present on the Apple plugin`);
for (const device of SAMPLE_DEVICES) {
assert.equal(relocated(device), reference(device), `${command} supports on ${device.id}`);
}
if (capability?.unsupportedHint) {
assert.ok(HINT_REF[command], `${command} unsupportedHint closure present in reference map`);
}
for (const [command, reference] of Object.entries(HINT_REF)) {
const relocated = appleCapability.unsupportedHintByDefault?.[command];
assert.ok(relocated, `${command} hint closure present on the Apple plugin`);
for (const device of SAMPLE_DEVICES) {
assert.equal(relocated(device), reference(device), `${command} hint on ${device.id}`);
}
}
});

test('(b.2) no non-Apple family carries a relocated supports/hint closure', () => {
// The relocation is faithful only because the closures are no-ops off the Apple
// family; guard that no other plugin accidentally grew a per-command gate (which
// would change admission for android/linux/web).
for (const platform of ['android', 'linux', 'web'] as const) {
const capability = getPlugin(platform).capability;
assert.equal(capability.supportsByDefault, undefined, `${platform} has no supportsByDefault`);
assert.equal(
capability.unsupportedHintByDefault,
undefined,
`${platform} has no unsupportedHintByDefault`,
);
}
});
25 changes: 17 additions & 8 deletions src/core/capabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,6 @@ export type CommandCapability = {
android?: KindMatrix;
linux?: KindMatrix;
web?: KindMatrix;
supports?: (device: DeviceInfo) => boolean;
/** Optional actionable hint surfaced when this command is rejected at admission for `device`. */
unsupportedHint?: (device: DeviceInfo) => string | undefined;
};

const WEB_DEVICE: KindMatrix = { device: true };
Expand All @@ -52,9 +49,12 @@ const WEB_SUPPORTED_COMMANDS = new Set<string>([
]);
// Built from the additive command-descriptor registry (ADR-0008, Phase 1 step 3).
// The hand-authored literal was deleted after #906 proved deriveCapabilityMatrix is
// byte-equal to it (platform/kind buckets plus the supports/unsupportedHint closures,
// across the sample-device matrix). The registry only type-imports CommandCapability
// from here, so this value-level dependency does not form a runtime cycle.
// byte-equal to it (platform/kind buckets). The per-command `supports()` /
// `unsupportedHint()` device closures no longer live here — they were relocated onto
// the owning PlatformPlugin's `capability.supportsByDefault` / `unsupportedHintByDefault`
// in Phase 3 step b.2 (see `isCommandSupportedOnDevice` below). The registry only
// type-imports CommandCapability from here, so this value-level dependency does not
// form a runtime cycle.
export const BASE_COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> =
deriveCapabilityMatrix(commandDescriptors);

Expand Down Expand Up @@ -95,13 +95,22 @@ export function isCommandSupportedOnDevice(command: string, device: DeviceInfo):
if (!plugin) return false;
const byPlatform = capability[plugin.capability.bucket];
if (!byPlatform) return false;
if (capability.supports && !capability.supports(device)) return false;
// The per-command `supports()` gate now flows through the owning PlatformPlugin
// (ADR-0009, Phase 3 step b.2): the family that owns `device.platform` carries the
// `supports()` closure RELOCATED VERBATIM in `capability.supportsByDefault`, keyed by
// command. A family with no entry for `command` admits it unchanged — proven equal to
// the former command-facet closure across the device matrix by
// `__tests__/capability-plugin-routing-parity.test.ts`.
const supportsByDefault = plugin.capability.supportsByDefault?.[command];
if (supportsByDefault && !supportsByDefault(device)) return false;
const kind = (device.kind ?? 'unknown') as keyof KindMatrix;
return byPlatform[kind] === true;
}

export function unsupportedHintForDevice(command: string, device: DeviceInfo): string | undefined {
return COMMAND_CAPABILITY_MATRIX[command]?.unsupportedHint?.(device);
// Counterpart of the `supports()` relocation (Phase 3 step b.2): the hint closure is
// owned by the family that owns `device.platform`, keyed by command.
return tryGetPlugin(device.platform)?.capability.unsupportedHintByDefault?.[command]?.(device);
}

export function listCapabilityCommands(): string[] {
Expand Down
8 changes: 4 additions & 4 deletions src/core/command-descriptor/__tests__/parity.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -116,10 +116,10 @@ test('capability matrix holds its admission invariants', () => {
const hasPlatformBucket = Boolean(
capability.apple || capability.android || capability.linux || capability.web,
);
assert.ok(
hasPlatformBucket || typeof capability.supports === 'function',
`${command} has a platform bucket or a supports predicate`,
);
// Every capability entry is now selectable purely by its platform buckets: the
// per-command `supports()` gate was relocated onto the owning PlatformPlugin
// (`capability.supportsByDefault`) in Phase 3 step b.2, so it no longer lives here.
assert.ok(hasPlatformBucket, `${command} has a platform bucket`);
}

const covered = new Set(Object.keys(BASE_COMMAND_CAPABILITY_MATRIX));
Expand Down
50 changes: 7 additions & 43 deletions src/core/command-descriptor/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import {
} from '../../command-catalog.ts';
import type { CommandCapability } from '../capabilities.ts';
import type { DaemonRequest } from '../../daemon/types.ts';
import { isTvOsDevice, type DeviceInfo } from '../../kernel/device.ts';
import type { CommandDescriptor } from './types.ts';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -34,30 +33,16 @@ const isShardedTestRequest = (req: DaemonRequest): boolean =>
(typeof req.flags?.shardAll === 'number' || typeof req.flags?.shardSplit === 'number');

// ---------------------------------------------------------------------------
// Capability predicates / matrices — copied VERBATIM from
// Capability matrices — platform/kind buckets, copied VERBATIM from
// src/core/capabilities.ts (BASE_COMMAND_CAPABILITY_MATRIX).
//
// The per-command `supports()` / `unsupportedHint()` device closures that used to
// live here were RELOCATED VERBATIM onto the owning PlatformPlugin's
// `capability.supportsByDefault` / `unsupportedHintByDefault` in Phase 3 step b.2
// (src/core/platform-plugin/register-builtins.ts). The capability facet now carries
// platform/kind buckets only; admission reads the closure off the plugin.
// ---------------------------------------------------------------------------

const isNotMacOs = (device: DeviceInfo): boolean => device.platform !== 'macos';
const isMacOsOrAppleSimulator = (device: DeviceInfo): boolean =>
device.platform === 'macos' || device.kind === 'simulator';
const isIosMobileSimulator = (device: DeviceInfo): boolean =>
device.platform === 'ios' && device.kind === 'simulator' && !isTvOsDevice(device);
const supportsSynthesisGesture = (device: DeviceInfo): boolean =>
device.platform === 'android' || isIosMobileSimulator(device);
const supportsAndroidOrIosNonTv = (device: DeviceInfo): boolean =>
device.platform === 'android' || (device.platform === 'ios' && !isTvOsDevice(device));

const synthesisGestureUnsupportedHint = (device: DeviceInfo): string | undefined => {
if (device.platform === 'macos')
return 'macOS automation has no multi-touch input — this gesture is supported on Android and the iOS simulator only.';
if (isTvOsDevice(device))
return 'tvOS has no touch input — this gesture is supported on Android and the iOS simulator only.';
if (device.platform === 'ios' && device.kind === 'device')
return 'Two-finger gesture synthesis is iOS-simulator only — not available on physical iOS devices.';
return undefined;
};

const APPLE_SIM_AND_DEVICE = { simulator: true, device: true };
const ANDROID_ALL = { emulator: true, device: true, unknown: true };
const LINUX_DEVICE = { device: true };
Expand All @@ -78,7 +63,6 @@ const APP_INSTALL_CAPABILITY = {
apple: APPLE_SIM_AND_DEVICE,
android: ANDROID_ALL,
linux: LINUX_NONE,
supports: isNotMacOs,
} satisfies CommandCapability;

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -142,7 +126,6 @@ const RAW_COMMAND_DESCRIPTORS = [
apple: APPLE_SIM_AND_DEVICE,
android: ANDROID_ALL,
linux: LINUX_NONE,
supports: isNotMacOs,
},
batchable: true,
},
Expand Down Expand Up @@ -209,11 +192,6 @@ const RAW_COMMAND_DESCRIPTORS = [
apple: APPLE_SIM_AND_DEVICE,
android: ANDROID_ALL,
linux: LINUX_DEVICE,
supports: (device) =>
device.platform === 'android' ||
device.platform === 'linux' ||
device.platform === 'macos' ||
device.kind === 'simulator',
},
batchable: true,
},
Expand All @@ -224,7 +202,6 @@ const RAW_COMMAND_DESCRIPTORS = [
apple: APPLE_SIM_AND_DEVICE,
android: ANDROID_ALL,
linux: LINUX_NONE,
supports: supportsAndroidOrIosNonTv,
},
batchable: true,
},
Expand Down Expand Up @@ -257,7 +234,6 @@ const RAW_COMMAND_DESCRIPTORS = [
apple: { simulator: true },
android: ANDROID_ALL,
linux: LINUX_NONE,
supports: isNotMacOs,
},
batchable: true,
},
Expand Down Expand Up @@ -316,7 +292,6 @@ const RAW_COMMAND_DESCRIPTORS = [
apple: APPLE_SIM_AND_DEVICE,
android: ANDROID_ALL,
linux: LINUX_NONE,
supports: (device) => device.platform === 'android' || isMacOsOrAppleSimulator(device),
},
batchable: true,
},
Expand All @@ -327,8 +302,6 @@ const RAW_COMMAND_DESCRIPTORS = [
apple: APPLE_SIM_AND_DEVICE,
android: ANDROID_ALL,
linux: LINUX_NONE,
supports: (device) =>
device.platform === 'android' || device.platform === 'macos' || device.kind === 'simulator',
},
batchable: true,
},
Expand Down Expand Up @@ -426,7 +399,6 @@ const RAW_COMMAND_DESCRIPTORS = [
apple: APPLE_SIM_AND_DEVICE,
android: ANDROID_ALL,
linux: LINUX_DEVICE,
supports: isNotMacOs,
},
batchable: true,
},
Expand All @@ -437,7 +409,6 @@ const RAW_COMMAND_DESCRIPTORS = [
apple: APPLE_SIM_AND_DEVICE,
android: ANDROID_ALL,
linux: LINUX_NONE,
supports: supportsAndroidOrIosNonTv,
},
batchable: true,
},
Expand All @@ -460,8 +431,6 @@ const RAW_COMMAND_DESCRIPTORS = [
apple: APPLE_SIM_AND_DEVICE,
android: ANDROID_ALL,
linux: LINUX_NONE,
supports: supportsSynthesisGesture,
unsupportedHint: synthesisGestureUnsupportedHint,
},
batchable: false,
},
Expand Down Expand Up @@ -502,8 +471,6 @@ const RAW_COMMAND_DESCRIPTORS = [
apple: APPLE_SIM_AND_DEVICE,
android: ANDROID_ALL,
linux: LINUX_NONE,
supports: supportsSynthesisGesture,
unsupportedHint: synthesisGestureUnsupportedHint,
},
batchable: false,
},
Expand All @@ -514,8 +481,6 @@ const RAW_COMMAND_DESCRIPTORS = [
apple: APPLE_SIM_AND_DEVICE,
android: ANDROID_ALL,
linux: LINUX_NONE,
supports: supportsSynthesisGesture,
unsupportedHint: synthesisGestureUnsupportedHint,
},
batchable: false,
},
Expand All @@ -527,7 +492,6 @@ const RAW_COMMAND_DESCRIPTORS = [
apple: APPLE_SIM_AND_DEVICE,
android: ANDROID_ALL,
linux: LINUX_NONE,
supports: isNotMacOs,
},
batchable: true,
},
Expand Down
20 changes: 16 additions & 4 deletions src/core/platform-plugin/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,25 @@ export type PlatformPlugin = {
/**
* The capability facet. `bucket` is the {@link CapabilityBucket} this family
* reads from a `CommandCapability` (parity-checked against the existing
* `platformDescriptors` registry). `supportsByDefault` is reserved for the
* step-(b) relocation of the `supports()` device closures — left undefined
* here so those closures stay verbatim in `capabilities.ts` for now.
* `platformDescriptors` registry).
*
* `supportsByDefault` / `unsupportedHintByDefault` carry the per-command
* `supports()` / `unsupportedHint()` device closures RELOCATED VERBATIM off the
* command-descriptor facet (ADR-0009 / perfect-shape §7 step b.2: relocate, never
* flatten). They are keyed by command name and owned by the family that owns the
* device's platform; `isCommandSupportedOnDevice` / `unsupportedHintForDevice`
* consult the map for `getPlugin(device.platform)`, so a family with no entry for a
* command (the key is absent) admits it unchanged. Only the Apple family carries
* entries today — every relocated closure is a no-op (returns `true` / `undefined`)
* on non-Apple devices, proven byte-for-byte by the parity gate before the
* command-facet closures were deleted.
*/
readonly capability: {
readonly bucket: CapabilityBucket;
supportsByDefault?(device: DeviceInfo): boolean;
readonly supportsByDefault?: Readonly<Record<string, (device: DeviceInfo) => boolean>>;
readonly unsupportedHintByDefault?: Readonly<
Record<string, (device: DeviceInfo) => string | undefined>
>;
};
/**
* The daemon app-log facet (issue #974). `resolveBackend` wraps the platform
Expand Down
Loading
Loading