diff --git a/src/core/platform-plugin/plugin.ts b/src/core/platform-plugin/plugin.ts index a0864c345..19168c4a4 100644 --- a/src/core/platform-plugin/plugin.ts +++ b/src/core/platform-plugin/plugin.ts @@ -24,9 +24,13 @@ import type { CapabilityBucket } from '../platform-descriptor/types.ts'; * parity test before a real call-site routes through it. A facet's type stays * PLATFORM-NEUTRAL and daemon-owned (never the iOS-simulator-shaped provider seam): * {@link PlatformPlugin.appLog} carries the neutral {@link LogBackend} resolver - * (wraps `resolveLogBackend`, pinned by the daemon app-log routing parity test). - * The remaining columns (`providers` / `recording` / `perf`) stay on their daemon - * branch as the source of truth until they clear the same gate. See + * (wraps `resolveLogBackend`, pinned by the daemon app-log routing parity test); + * {@link PlatformPlugin.perf} carries the neutral perf-metrics support predicate + * (wraps `supportsPlatformPerfMetrics`, pinned by the daemon perf routing parity + * test). The remaining columns (`providers` / `recording`) — and the rest of the + * `perf` facet (the sampling body `buildPerfResponseData` and the Android-only + * native-collector gate) — stay on their daemon branch as the source of truth + * until each clears the same gate. See * docs/adr/0009-apple-platform-consolidation.md (tracked in issue #974). */ export type PlatformPlugin = { @@ -74,6 +78,21 @@ export type PlatformPlugin = { readonly appLog?: { resolveBackend(device: DeviceInfo): LogBackend; }; + /** + * The daemon perf facet (issue #974). `supportsMetrics` wraps the platform + * predicate `supportsPlatformPerfMetrics` in + * `src/daemon/handlers/session-perf.ts`, reporting whether `device`'s platform + * can produce session perf metrics (startup/fps/memory/cpu). Present only on + * families that expose perf metrics (Apple + Android); left `undefined` for + * linux/web, where the hand predicate returned `false` — the daemon lookup + * preserves that fallthrough, and the daemon perf routing parity test pins the + * equivalence. Only the support gate is routed today; the perf sampling body + * (`buildPerfResponseData`) and the Android-only native-collector gate stay on + * their daemon branch until each clears the same gate. + */ + readonly perf?: { + supportsMetrics(device: DeviceInfo): boolean; + }; }; // The single registry instance: leaf platform -> owning plugin. A family plugin diff --git a/src/core/platform-plugin/register-builtins.ts b/src/core/platform-plugin/register-builtins.ts index ddcb10d5a..62f3ae283 100644 --- a/src/core/platform-plugin/register-builtins.ts +++ b/src/core/platform-plugin/register-builtins.ts @@ -99,6 +99,9 @@ const applePlugin = { ? 'ios-device' : 'ios-simulator', }, + // Wraps the Apple arm of `supportsPlatformPerfMetrics`: every Apple device + // (ios/macos, any kind/target) reports perf-metrics support. + perf: { supportsMetrics: () => true }, createInteractor: async (device: DeviceInfo, runner: RunnerContext) => { const { createAppleInteractor } = await import('../interactors/apple.ts'); return createAppleInteractor(device, runner); @@ -124,6 +127,9 @@ const androidPlugin = { capability: { bucket: 'android' }, // Wraps the Android arm of `resolveLogBackend`: every Android device -> 'android'. appLog: { resolveBackend: () => 'android' }, + // Wraps the Android arm of `supportsPlatformPerfMetrics`: every Android device + // reports perf-metrics support. + perf: { supportsMetrics: () => true }, createInteractor: async (device: DeviceInfo) => { const { createAndroidInteractor } = await import('../interactors/android.ts'); return createAndroidInteractor(device); diff --git a/src/daemon/__tests__/perf-plugin-routing-parity.test.ts b/src/daemon/__tests__/perf-plugin-routing-parity.test.ts new file mode 100644 index 000000000..734203afe --- /dev/null +++ b/src/daemon/__tests__/perf-plugin-routing-parity.test.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { + DEVICE_TARGETS, + PLATFORMS, + type DeviceInfo, + type DeviceKind, + type DeviceTarget, +} from '../../kernel/device.ts'; +import { + ANDROID_EMULATOR, + ANDROID_TV_DEVICE, + IOS_DEVICE, + IOS_SIMULATOR, + LINUX_DEVICE, + MACOS_DEVICE, + makeSession, + TVOS_SIMULATOR, + WEB_DESKTOP_DEVICE, +} from '../../__tests__/test-utils/index.ts'; +import { getPlugin } from '../../core/platform-plugin/plugin.ts'; +import { registerBuiltinPlatformPlugins } from '../../core/platform-plugin/register-builtins.ts'; +import { buildPerfResponseData } from '../handlers/session-perf.ts'; +import { PERF_UNAVAILABLE_REASON } from '../handlers/session-startup-metrics.ts'; + +// Phase 3 step b.3 (issue #974) parity gate for the daemon perf facet. The +// per-platform gate of `supportsPlatformPerfMetrics` now flows through the +// PlatformPlugin `perf.supportsMetrics` facet instead of a hand disjunction. An +// INDEPENDENT verbatim copy of the former predicate below is the BEFORE oracle: a +// plugin-vs-branch disagreement on any sample device fails this test. (Mirrors the +// verbatim-copy discipline in daemon/__tests__/applog-plugin-routing-parity.test.ts +// and core/__tests__/capability-plugin-routing-parity.test.ts.) + +registerBuiltinPlatformPlugins(); + +// --- INDEPENDENT verbatim copy of the former `supportsPlatformPerfMetrics` branch --- +function supportsPlatformPerfMetricsByHand(device: DeviceInfo): boolean { + return device.platform === 'android' || device.platform === 'ios' || device.platform === 'macos'; +} + +// --- the exhaustive synthetic device matrix (every platform x kind x target) --- +const DEVICE_KINDS_ALL: DeviceKind[] = ['simulator', 'emulator', 'device']; +const DEVICE_TARGETS_ALL: (DeviceTarget | undefined)[] = [undefined, ...DEVICE_TARGETS]; + +function buildDeviceMatrix(): DeviceInfo[] { + const devices: DeviceInfo[] = []; + for (const platform of PLATFORMS) { + for (const kind of DEVICE_KINDS_ALL) { + for (const target of DEVICE_TARGETS_ALL) { + devices.push({ + platform, + id: `${platform}-${kind}-${target ?? 'none'}`, + name: `${platform} ${kind} ${target ?? 'none'}`, + kind, + ...(target ? { target } : {}), + booted: true, + }); + } + } + } + return devices; +} + +// The hand-authored fixtures (the real discovery shapes) plus the exhaustive +// synthetic cross-product, so every off-nominal combination is pinned too. +const SAMPLE_DEVICES: DeviceInfo[] = [ + ANDROID_EMULATOR, + ANDROID_TV_DEVICE, + IOS_DEVICE, + IOS_SIMULATOR, + LINUX_DEVICE, + MACOS_DEVICE, + TVOS_SIMULATOR, + WEB_DESKTOP_DEVICE, + ...buildDeviceMatrix(), +]; + +test('perf.supportsMetrics facet is byte-identical to the former hand predicate', () => { + for (const device of SAMPLE_DEVICES) { + assert.equal( + getPlugin(device.platform).perf?.supportsMetrics(device) ?? false, + supportsPlatformPerfMetricsByHand(device), + `supportsMetrics for ${device.id}`, + ); + } +}); + +test('only families with perf metrics carry the perf facet', () => { + // Apple owns ios + macos (SAME plugin instance); Android carries its own. + assert.equal(getPlugin('ios'), getPlugin('macos')); + assert.ok(getPlugin('ios').perf, 'apple plugin exposes perf'); + assert.ok(getPlugin('android').perf, 'android plugin exposes perf'); + // linux/web historically returned `false`; they get NO facet, and the daemon + // lookup preserves that fallthrough (asserted below). + assert.equal(getPlugin('linux').perf, undefined, 'linux plugin has no perf'); + assert.equal(getPlugin('web').perf, undefined, 'web plugin has no perf'); +}); + +test('the factless families fall through to the historical `false` default', () => { + for (const device of SAMPLE_DEVICES.filter( + (d) => d.platform === 'linux' || d.platform === 'web', + )) { + assert.equal(getPlugin(device.platform).perf, undefined); + assert.equal( + getPlugin(device.platform).perf?.supportsMetrics(device) ?? false, + false, + `fallthrough for ${device.id}`, + ); + } +}); + +// End-to-end routing proof: `buildPerfResponseData` consults the perf facet via the +// (private) `supportsPlatformPerfMetrics`. For a session with NO app bundle it does +// no device I/O — an UNSUPPORTED platform returns the default-unavailable base +// response (cpu reason `PERF_UNAVAILABLE_REASON`), while a SUPPORTED platform fills +// the missing-app reason instead. So the routed cpu reason discriminates support, +// and breaking the facet would flip the classification and fail this test. +test('buildPerfResponseData routes the support gate through the perf facet', async () => { + for (const device of SAMPLE_DEVICES) { + const data = await buildPerfResponseData(makeSession(`perf-${device.id}`, { device })); + const cpu = data.metrics.cpu as { reason?: string }; + const routedSupportsMetrics = cpu.reason !== PERF_UNAVAILABLE_REASON; + assert.equal( + routedSupportsMetrics, + supportsPlatformPerfMetricsByHand(device), + `routed support for ${device.id}`, + ); + } +}); diff --git a/src/daemon/handlers/session-perf.ts b/src/daemon/handlers/session-perf.ts index eac208bfa..8813f375a 100644 --- a/src/daemon/handlers/session-perf.ts +++ b/src/daemon/handlers/session-perf.ts @@ -2,6 +2,8 @@ import path from 'node:path'; import type { SessionAction, SessionState } from '../types.ts'; import { AppError, normalizeError } from '../../kernel/errors.ts'; import { isApplePlatform } from '../../kernel/device.ts'; +import { tryGetPlugin } from '../../core/platform-plugin/plugin.ts'; +import { registerBuiltinPlatformPlugins } from '../../core/platform-plugin/register-builtins.ts'; import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; import { ANDROID_HPROF_SNAPSHOT_DESCRIPTION, @@ -37,6 +39,12 @@ import { type StartupPerfSample, } from './session-startup-metrics.ts'; +// Populate the PlatformPlugin registry once at module load (idempotent; registers +// only lazy closures, so no leaf code is imported and CLI cold-start is unaffected +// — mirrors the same call in `daemon/app-log.ts`). `supportsPlatformPerfMetrics` +// reads the per-platform perf facet from this registry, so it must be populated first. +registerBuiltinPlatformPlugins(); + type SettledMetricResult = PromiseSettledResult>; type MetricResult = | ({ available: true } & Record) @@ -322,11 +330,11 @@ async function applyFramePerfMetric( } function supportsPlatformPerfMetrics(session: SessionState): boolean { - return ( - session.device.platform === 'android' || - session.device.platform === 'ios' || - session.device.platform === 'macos' - ); + // Routes the platform gate through the PlatformPlugin perf facet (issue #974). + // Apple/Android carry `perf.supportsMetrics`; linux/web (and any unregistered + // platform) fall through to `false`, matching the former hand predicate. The + // daemon perf routing parity test pins this equivalence. + return tryGetPlugin(session.device.platform)?.perf?.supportsMetrics(session.device) ?? false; } function buildMissingAppPerfReason(session: SessionState): string {