From f06f66e31338dbfd7c431f9d060b9eaf2c307545 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Jul 2026 10:12:21 +0000 Subject: [PATCH 1/4] refactor(daemon): route perf metrics sampling body through PlatformPlugin facet Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- CONTEXT.md | 3 - src/core/interactors/register-builtins.ts | 6 +- src/core/platform-plugin/plugin.ts | 29 ++++-- .../perf-plugin-routing-parity.test.ts | 32 +++++++ src/daemon/handlers/session-perf.ts | 93 +++++++++++-------- src/platforms/apple/plugin.ts | 6 +- 6 files changed, 114 insertions(+), 55 deletions(-) diff --git a/CONTEXT.md b/CONTEXT.md index 59fe66937..f743e466a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -106,9 +106,6 @@ The refactor is substantively done; these follow-ups are intentionally deferred, - Phase 2c — narrow the ~15 remaining `Record`-typed client methods in `src/client/client-types.ts` to their existing typed contracts (a semver-relevant public-API narrowing; not yet done). -- b.3 perf sampling body — all four `PlatformPlugin` daemon facets now route through the plugin - (`appLog`, the `perf` support gate, `recording`, and `providers`; the last two via #1007). Only - the `perf` sampling body (`buildPerfResponseData`) still branches in the daemon. - Strict DAG back-edge inversion — the layering lint prevents target-spine back-edge growth, but the full zero-back-edge DAG (e.g. `commands` → `cli`/`client`) is not done. - Legacy alias drops — ~175 LOC of legacy aliases/barrels remain, gated to the next major. diff --git a/src/core/interactors/register-builtins.ts b/src/core/interactors/register-builtins.ts index dcd373f10..45b263c3f 100644 --- a/src/core/interactors/register-builtins.ts +++ b/src/core/interactors/register-builtins.ts @@ -37,8 +37,10 @@ const androidPlugin = { // 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 }, + // reports perf-metrics support. `metricsSamplerTag` wraps the Android arm of the + // former `buildPerfResponseData` sampling branch: every supported Android device + // routes to the Android `perf metrics` sampler. + perf: { supportsMetrics: () => true, metricsSamplerTag: () => 'android' }, // Wraps the Android arm of `resolveRecordingBackendForDevice`: every Android device // resolves to the android recording backend. recording: { resolveBackendTag: () => 'android' }, diff --git a/src/core/platform-plugin/plugin.ts b/src/core/platform-plugin/plugin.ts index 3f71715cd..73e70a09b 100644 --- a/src/core/platform-plugin/plugin.ts +++ b/src/core/platform-plugin/plugin.ts @@ -2,6 +2,7 @@ import { AppError } from '../../kernel/errors.ts'; import type { DeviceInfo, Platform, PlatformSelector } from '../../kernel/device.ts'; import type { LogBackend } from '../../daemon/network-log.ts'; import type { RecordingBackendTag } from '../../daemon/handlers/record-trace-recording-backends.ts'; +import type { PerfMetricsSamplerTag } from '../../daemon/handlers/session-perf.ts'; import type { PlatformGatedProviderResolverKey } from '../../daemon/request-platform-providers.ts'; import type { Interactor, RunnerContext } from '../interactor-types.ts'; import type { DeviceInventoryRequest } from '../platform-inventory.ts'; @@ -28,16 +29,18 @@ import type { CapabilityBucket } from '../platform-descriptor/types.ts'; * {@link PlatformPlugin.appLog} carries the neutral {@link LogBackend} resolver * (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); {@link PlatformPlugin.recording} carries the neutral + * (wraps `supportsPlatformPerfMetrics`) plus the neutral {@link PerfMetricsSamplerTag} + * resolver (wraps the per-platform metrics-sampling branch formerly open-coded in + * `buildPerfResponseData`), both pinned by the daemon perf routing parity test; + * {@link PlatformPlugin.recording} carries the neutral * {@link RecordingBackendTag} resolver (wraps the per-platform branch of * `resolveRecordingBackendForDevice`, pinned by the recording routing parity test); * {@link PlatformPlugin.providers} carries the per-family platform-gated request * provider resolver list (replaces the hand `device.platform === …` gate in * `request-platform-providers.ts`, pinned by the providers routing parity test). The - * rest of the `perf` facet (the sampling body `buildPerfResponseData` and the - * Android-only native-collector gate) stays on its daemon branch as the source of - * truth until it clears the same gate. See + * remaining perf work (the `perf memory`/`perf frames` bodies and the Android-only + * native-collector gate) stays on its daemon branch as the source of truth until it + * clears the same gate. See * docs/adr/0009-apple-platform-consolidation.md (tracked in issue #974). */ export type PlatformPlugin = { @@ -93,12 +96,22 @@ export type PlatformPlugin = { * 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. + * equivalence. + * + * `metricsSamplerTag` returns the neutral {@link PerfMetricsSamplerTag} naming which + * `perf metrics` sampler a device's family owns (`'apple'` / `'android'`), replacing + * the `device.platform === 'android'` sampling branch formerly open-coded in + * `buildPerfResponseData`. The daemon still OWNS the samplers and maps the tag back to + * them (`PERF_METRICS_SAMPLERS_BY_TAG`), so core/platforms never carry the daemon-owned + * sampling composition — exactly like {@link recording}'s tag. It is only ever consulted + * after `supportsMetrics` gates the platform in, so it is present on the SAME families + * (Apple + Android) and the parity test pins both to a verbatim copy of the former + * branch. The `perf memory`/`perf frames` bodies and the Android-only native-collector + * gate stay on their daemon branch until each clears the same gate. */ readonly perf?: { supportsMetrics(device: DeviceInfo): boolean; + metricsSamplerTag(device: DeviceInfo): PerfMetricsSamplerTag; }; /** * The daemon recording facet (issue #974). `resolveBackendTag` wraps the diff --git a/src/daemon/__tests__/perf-plugin-routing-parity.test.ts b/src/daemon/__tests__/perf-plugin-routing-parity.test.ts index e780c23ee..9bd111628 100644 --- a/src/daemon/__tests__/perf-plugin-routing-parity.test.ts +++ b/src/daemon/__tests__/perf-plugin-routing-parity.test.ts @@ -40,6 +40,14 @@ function supportsPlatformPerfMetricsByHand(device: DeviceInfo): boolean { return device.platform === 'android' || isIosFamily(device) || isMacOs(device); } +// --- INDEPENDENT verbatim copy of the former `buildPerfResponseData` sampling branch --- +// The old body dispatched `session.device.platform === 'android'` -> the Android sampler, +// else -> the Apple sampler, and was reached ONLY after the support gate above admitted +// the platform. So the sampler selection is defined only for supported devices. +function perfMetricsSamplerTagByHand(device: DeviceInfo): 'apple' | 'android' { + return device.platform === 'android' ? 'android' : 'apple'; +} + // --- 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]; @@ -98,6 +106,30 @@ test('only families with perf metrics carry the perf facet', () => { assert.equal(getPlugin('web').perf, undefined, 'web plugin has no perf'); }); +test('perf.metricsSamplerTag facet is byte-identical to the former sampling branch', () => { + // The sampler selection is only reached on SUPPORTED devices, so compare there; the + // unsupported families carry no facet (hence no tag), asserted separately below. + for (const device of SAMPLE_DEVICES.filter((d) => supportsPlatformPerfMetricsByHand(d))) { + assert.equal( + getPlugin(device.platform).perf?.metricsSamplerTag(device), + perfMetricsSamplerTagByHand(device), + `metricsSamplerTag for ${device.id}`, + ); + } +}); + +test('the factless families expose no metricsSamplerTag', () => { + for (const device of SAMPLE_DEVICES.filter( + (d) => d.platform === 'linux' || d.platform === 'web', + )) { + assert.equal( + getPlugin(device.platform).perf?.metricsSamplerTag, + undefined, + `no sampler tag for ${device.id}`, + ); + } +}); + 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', diff --git a/src/daemon/handlers/session-perf.ts b/src/daemon/handlers/session-perf.ts index f40a49fa6..41e2975ba 100644 --- a/src/daemon/handlers/session-perf.ts +++ b/src/daemon/handlers/session-perf.ts @@ -81,6 +81,29 @@ type BuildPerfMemoryResponseOptions = BuildPerfResponseOptions & { const RELATED_PERF_ACTION_LIMIT = 12; +/** + * The daemon-owned `perf metrics` sampler discriminant. A PLATFORM-NEUTRAL string tag + * naming which family owns a device's `perf metrics` sampler; the daemon maps it back to + * the concrete sampler via {@link PERF_METRICS_SAMPLERS_BY_TAG}. The + * {@link PlatformPlugin.perf} facet returns this tag (type-only in the plugin, exactly + * like {@link RecordingBackendTag} for recording), so core/platforms never carry the + * daemon-owned sampling composition. Only families that expose perf metrics carry the tag + * (Apple + Android); it is consulted solely after the support gate admits the platform. + */ +export type PerfMetricsSamplerTag = 'apple' | 'android'; + +type SampledPerfMetrics = { + memory: SettledMetricResult; + cpu: SettledMetricResult; + fps: SettledMetricResult; +}; + +type PerfMetricsSampler = ( + session: SessionState, + appBundleId: string, + options: BuildPerfResponseOptions, +) => Promise; + function readStartupPerfSamples(actions: SessionAction[]): StartupPerfSample[] { const samples: StartupPerfSample[] = []; for (const action of actions) { @@ -129,15 +152,25 @@ export async function buildPerfResponseData( return response; } - if (session.device.platform === 'android') { - await applyAndroidPerfMetrics(response, session, session.appBundleId, options); - return response; - } - - await applyApplePerfMetrics(response, session, session.appBundleId); + const sampler = resolvePerfMetricsSampler(session.device); + if (!sampler) return response; + const results = await sampler(session, session.appBundleId, options); + applySampledPerfMetrics(response, session, results); return response; } +// Routes the former `device.platform === 'android'` sampling branch through the +// PlatformPlugin perf facet (issue #1188): Apple/Android carry `perf.metricsSamplerTag`, +// and the daemon maps the neutral tag back to its own sampler. `buildPerfResponseData` +// consults this only after `supportsPlatformPerfMetrics` admits the platform, so the +// facet (hence the tag) is always present; the `undefined` fallthrough preserves the +// former base response for the unreachable unsupported case. The daemon perf routing +// parity test pins the tag to a verbatim copy of the former branch. +function resolvePerfMetricsSampler(device: SessionState['device']): PerfMetricsSampler | undefined { + const tag = tryGetPlugin(device.platform)?.perf?.metricsSamplerTag(device); + return tag ? PERF_METRICS_SAMPLERS_BY_TAG[tag] : undefined; +} + export async function buildPerfFramesResponseData( session: SessionState, options: BuildPerfResponseOptions = {}, @@ -276,33 +309,10 @@ function applyMissingAppPerfMetrics(response: PerfResponseData, session: Session response.metrics.cpu = { available: false, reason }; } -async function applyAndroidPerfMetrics( - response: PerfResponseData, - session: SessionState, - appBundleId: string, - options: BuildPerfResponseOptions, -): Promise { - const results = await sampleAndroidPerfResults(session, appBundleId, options); - applySampledPerfMetrics(response, session, results); -} - -async function applyApplePerfMetrics( - response: PerfResponseData, - session: SessionState, - appBundleId: string, -): Promise { - const results = await sampleApplePerfResultsForSession(session, appBundleId); - applySampledPerfMetrics(response, session, results); -} - function applySampledPerfMetrics( response: PerfResponseData, session: SessionState, - results: { - memory: SettledMetricResult; - cpu: SettledMetricResult; - fps: SettledMetricResult; - }, + results: SampledPerfMetrics, ): void { response.metrics.memory = buildMetricResult(results.memory); response.metrics.cpu = buildMetricResult(results.cpu); @@ -419,11 +429,7 @@ async function sampleAndroidPerfResults( session: SessionState, appBundleId: string, options: BuildPerfResponseOptions, -): Promise<{ - memory: SettledMetricResult; - cpu: SettledMetricResult; - fps: SettledMetricResult; -}> { +): Promise { const androidPerfOptions = { adb: options.androidAdb }; const [memory, cpu, fps] = await Promise.allSettled([ sampleAndroidMemoryPerf(session.device, appBundleId, androidPerfOptions), @@ -436,11 +442,7 @@ async function sampleAndroidPerfResults( async function sampleApplePerfResultsForSession( session: SessionState, appBundleId: string, -): Promise<{ - memory: SettledMetricResult; - cpu: SettledMetricResult; - fps: SettledMetricResult; -}> { +): Promise { const fps = await settleMetric(sampleAppleFramePerf(session.device, appBundleId)); const processSample = await settleMetric(sampleApplePerfMetrics(session.device, appBundleId)); if (processSample.status === 'fulfilled') { @@ -461,6 +463,17 @@ async function sampleApplePerfResultsForSession( }; } +// Maps the neutral {@link PerfMetricsSamplerTag} the plugin facet returns back to the +// daemon-owned `perf metrics` sampler. Exhaustive over the tag union (a compile error if +// a tag is added without a sampler), so `resolvePerfMetricsSampler` is a pure data lookup +// with no platform branch of its own. The Apple sampler ignores the trailing options bag +// (only Android threads a request-scoped adb executor), so its narrower arity is +// assignable to {@link PerfMetricsSampler}. +const PERF_METRICS_SAMPLERS_BY_TAG: Record = { + android: sampleAndroidPerfResults, + apple: sampleApplePerfResultsForSession, +}; + async function buildMemorySampleMetric( session: SessionState, options: BuildPerfResponseOptions, diff --git a/src/platforms/apple/plugin.ts b/src/platforms/apple/plugin.ts index 3403e3b6b..b7c3b730a 100644 --- a/src/platforms/apple/plugin.ts +++ b/src/platforms/apple/plugin.ts @@ -156,8 +156,10 @@ export const applePlugin = { isMacOs(device) ? 'macos' : device.kind === 'device' ? '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 }, + // (ios/macos, any kind/target) reports perf-metrics support. `metricsSamplerTag` + // wraps the else-arm of the former `buildPerfResponseData` sampling branch: every + // supported Apple device routes to the Apple `perf metrics` sampler. + perf: { supportsMetrics: () => true, metricsSamplerTag: () => 'apple' }, // Wraps the Apple arm of `resolveRecordingBackendForDevice` verbatim: macOS -> // 'macos'; an iOS `device` -> 'ios-device'; every other iOS kind (simulator, incl. // tvOS/iPadOS/visionOS) -> 'ios-simulator'. Mirrors the appLog resolveBackend shape. From 698d0c14e265e447a37397e3a7903eb63d63e1ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Jul 2026 11:27:08 +0000 Subject: [PATCH 2/4] test(daemon): cover the shipped perf sampler dispatch path via the facet Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../perf-plugin-routing-parity.test.ts | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/src/daemon/__tests__/perf-plugin-routing-parity.test.ts b/src/daemon/__tests__/perf-plugin-routing-parity.test.ts index 9bd111628..7b2feec71 100644 --- a/src/daemon/__tests__/perf-plugin-routing-parity.test.ts +++ b/src/daemon/__tests__/perf-plugin-routing-parity.test.ts @@ -1,5 +1,6 @@ import assert from 'node:assert/strict'; import { test } from 'vitest'; +import { AppError } from '../../kernel/errors.ts'; import { isIosFamily, isMacOs, @@ -9,6 +10,7 @@ import { type DeviceKind, type DeviceTarget, } from '../../kernel/device.ts'; +import type { AndroidAdbExecutor } from '../../platforms/android/adb-executor.ts'; import { ANDROID_EMULATOR, ANDROID_TV_DEVICE, @@ -161,3 +163,58 @@ test('buildPerfResponseData routes the support gate through the perf facet', asy ); } }); + +// Shipped-path routing proof for the sampling body (issue #1188). The support-gate test +// above never reaches sampler selection — it uses no `appBundleId`, so execution returns +// through `applyMissingAppPerfMetrics` before `resolvePerfMetricsSampler`. WITH an app +// bundle, execution clears that guard and hits the facet-owned sampler selection. The +// Android sampler is the ONLY arm that threads `options.androidAdb`, so a scripted adb +// executor is invoked exactly when the Android sampler was selected AND run through the +// shipped code path. Breaking the facet lookup (Android returning a non-`android` tag, or +// the resolver yielding no sampler) skips the Android sampler and fails this test — so the +// suite now covers the dispatch line itself, not only registry parity. +const SAMPLED_ADB_REASON = 'scripted adb unavailable'; +function makeThrowingAdb(): { adb: AndroidAdbExecutor; calls: () => number } { + let calls = 0; + const adb: AndroidAdbExecutor = async () => { + calls += 1; + throw new AppError('COMMAND_FAILED', SAMPLED_ADB_REASON); + }; + return { adb, calls: () => calls }; +} + +test('buildPerfResponseData dispatches the Android sampler selected by the facet', async () => { + for (const device of SAMPLE_DEVICES.filter((d) => d.platform === 'android')) { + const { adb, calls } = makeThrowingAdb(); + const session = makeSession(`perf-routed-${device.id}`, { + device, + appBundleId: 'com.example.app', + }); + const data = await buildPerfResponseData(session, { androidAdb: adb }); + + assert.ok(calls() > 0, `Android sampler reached through the shipped path for ${device.id}`); + for (const metric of ['memory', 'cpu', 'fps'] as const) { + const entry = data.metrics[metric] as { available?: boolean; reason?: string }; + assert.equal( + entry.available, + false, + `${metric} was sampled (not the base response) for ${device.id}`, + ); + assert.equal( + entry.reason, + SAMPLED_ADB_REASON, + `${metric} carries the sampler failure for ${device.id}`, + ); + } + } +}); + +// The missing-app guard precedes sampler selection, so without an `appBundleId` the facet +// sampler is never consulted and the scripted adb stays untouched. +test('buildPerfResponseData consults the sampler only past the missing-app guard', async () => { + const { adb, calls } = makeThrowingAdb(); + await buildPerfResponseData(makeSession('perf-no-bundle', { device: ANDROID_EMULATOR }), { + androidAdb: adb, + }); + assert.equal(calls(), 0, 'sampler not consulted without an app bundle'); +}); From 16b777e380850d448a57e098ec7fc8075bf6c8d9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Jul 2026 11:31:26 +0000 Subject: [PATCH 3/4] ci: re-trigger checks (flaky settle-observation integration test) Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> From 33ecb9c458491fa5b9cf78fb4cb85f3d14841cc3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Fri, 10 Jul 2026 12:54:17 +0000 Subject: [PATCH 4/4] test(daemon): pin perf sampler selection to the facet tag, not the platform Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../perf-plugin-routing-parity.test.ts | 42 +++++++++++++++---- 1 file changed, 35 insertions(+), 7 deletions(-) diff --git a/src/daemon/__tests__/perf-plugin-routing-parity.test.ts b/src/daemon/__tests__/perf-plugin-routing-parity.test.ts index 7b2feec71..870e092b5 100644 --- a/src/daemon/__tests__/perf-plugin-routing-parity.test.ts +++ b/src/daemon/__tests__/perf-plugin-routing-parity.test.ts @@ -24,7 +24,7 @@ import { } from '../../__tests__/test-utils/index.ts'; import { getPlugin } from '../../core/platform-plugin/plugin.ts'; import { registerBuiltinPlatformPlugins } from '../../core/interactors/register-builtins.ts'; -import { buildPerfResponseData } from '../handlers/session-perf.ts'; +import { buildPerfResponseData, type PerfMetricsSamplerTag } 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 @@ -167,12 +167,9 @@ test('buildPerfResponseData routes the support gate through the perf facet', asy // Shipped-path routing proof for the sampling body (issue #1188). The support-gate test // above never reaches sampler selection — it uses no `appBundleId`, so execution returns // through `applyMissingAppPerfMetrics` before `resolvePerfMetricsSampler`. WITH an app -// bundle, execution clears that guard and hits the facet-owned sampler selection. The -// Android sampler is the ONLY arm that threads `options.androidAdb`, so a scripted adb -// executor is invoked exactly when the Android sampler was selected AND run through the -// shipped code path. Breaking the facet lookup (Android returning a non-`android` tag, or -// the resolver yielding no sampler) skips the Android sampler and fails this test — so the -// suite now covers the dispatch line itself, not only registry parity. +// bundle, execution clears that guard and hits the sampler selection. The Android sampler +// is the ONLY arm that threads `options.androidAdb`, so a scripted adb executor is invoked +// exactly when the Android sampler was selected AND run through the shipped code path. const SAMPLED_ADB_REASON = 'scripted adb unavailable'; function makeThrowingAdb(): { adb: AndroidAdbExecutor; calls: () => number } { let calls = 0; @@ -209,6 +206,37 @@ test('buildPerfResponseData dispatches the Android sampler selected by the facet } }); +// Facet-vs-platform proof (re-review, issue #1188): the test above cannot distinguish the +// facet lookup from the deleted `device.platform === 'android'` branch, because on a real +// Android device both select the same Android sampler with the same options. Here the +// device is an Apple simulator but its plugin's `metricsSamplerTag` is overridden to +// `'android'`, so ONLY a facet-driven selection routes it to the Android sampler (the +// scripted adb fires). The former platform branch keyed on `device.platform`, so restoring +// it keeps the Apple device on the Apple sampler and this assertion fails — pinning the +// shipped selection to `metricsSamplerTag`, not the device platform. +test('buildPerfResponseData selects the sampler by the facet tag, not the device platform', async () => { + const perf = getPlugin('apple').perf; + assert.ok(perf, 'apple plugin exposes the perf facet'); + const mutablePerf = perf as { metricsSamplerTag: (device: DeviceInfo) => PerfMetricsSamplerTag }; + const originalTag = mutablePerf.metricsSamplerTag; + mutablePerf.metricsSamplerTag = () => 'android'; + try { + const { adb, calls } = makeThrowingAdb(); + const session = makeSession('perf-facet-tag', { + device: IOS_SIMULATOR, + appBundleId: 'com.example.app', + }); + const data = await buildPerfResponseData(session, { androidAdb: adb }); + + assert.ok(calls() > 0, 'facet tag routed the Apple device to the Android sampler'); + const cpu = data.metrics.cpu as { available?: boolean; reason?: string }; + assert.equal(cpu.available, false, 'cpu was sampled through the facet-selected sampler'); + assert.equal(cpu.reason, SAMPLED_ADB_REASON, 'cpu carries the scripted adb failure'); + } finally { + mutablePerf.metricsSamplerTag = originalTag; + } +}); + // The missing-app guard precedes sampler selection, so without an `appBundleId` the facet // sampler is never consulted and the scripted adb stays untouched. test('buildPerfResponseData consults the sampler only past the missing-app guard', async () => {