Skip to content

Commit 643ed2c

Browse files
authored
feat: route daemon app-log backend through PlatformPlugin appLog facet (#974) (#991)
Phase 3 b.3: add the first daemon-owned facet to `PlatformPlugin`, typed against a PLATFORM-NEUTRAL wrapper and pinned by a table-equivalence parity test before the daemon lookup routes through it. - `PlatformPlugin.appLog.resolveBackend(device): LogBackend` — a neutral string-union tag (never the iOS-simulator provider seam). Optional facet, present only on families with an app-log backend (Apple + Android). - Populate by wrapping the existing `resolveLogBackend` branch verbatim on the Apple/Android plugins; linux/web omit the facet and the daemon lookup preserves the historical `'android'` fallthrough. - Route `src/daemon/app-log.ts`'s `resolveLogBackend` through `tryGetPlugin(...).appLog?.resolveBackend(...)`; populate the registry at module load (idempotent, lazy — mirrors `core/capabilities.ts`). - Pin with `applog-plugin-routing-parity.test.ts`: the routed function is byte-identical to an independent verbatim copy of the former hand branch across the device fixtures + exhaustive platform x kind x target matrix. Deferred (not populated / not parity-added to the contract): `providers` (load-bearing scope-orchestration seam), `recording` (needs the de-iOS-naming start/stop-context redesign), and `perf` (heavy internal response-builder). Each stays the daemon branch's source of truth.
1 parent f627203 commit 643ed2c

4 files changed

Lines changed: 165 additions & 10 deletions

File tree

‎src/core/platform-plugin/plugin.ts‎

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { AppError } from '../../kernel/errors.ts';
22
import type { DeviceInfo, Platform, PlatformSelector } from '../../kernel/device.ts';
3+
import type { LogBackend } from '../../daemon/network-log.ts';
34
import type { Interactor, RunnerContext } from '../interactor-types.ts';
45
import type { DeviceInventoryRequest } from '../platform-inventory.ts';
56
import type { CapabilityBucket } from '../platform-descriptor/types.ts';
@@ -18,11 +19,14 @@ import type { CapabilityBucket } from '../platform-descriptor/types.ts';
1819
* `import()` inside `createInteractor` / `discoverDevices`, preserving the
1920
* CLI cold-start laziness that today's `getInteractor` switch relies on.
2021
*
21-
* Step-a scope: this contract intentionally contains ONLY the facets this slice
22-
* genuinely implements and parity-tests. The daemon-owned columns
23-
* (`providers` / `recording` / `appLog` / `perf`) are NOT declared here — they
24-
* arrive in step (b), typed against PLATFORM-NEUTRAL, daemon-owned wrappers
25-
* (not the iOS-simulator-shaped provider seam). See
22+
* Daemon-owned columns (step b.3, issue #974): each is declared ONLY once it is
23+
* populated by wrapping the existing daemon branch AND pinned by a table-equivalence
24+
* parity test before a real call-site routes through it. A facet's type stays
25+
* PLATFORM-NEUTRAL and daemon-owned (never the iOS-simulator-shaped provider seam):
26+
* {@link PlatformPlugin.appLog} carries the neutral {@link LogBackend} resolver
27+
* (wraps `resolveLogBackend`, pinned by the daemon app-log routing parity test).
28+
* The remaining columns (`providers` / `recording` / `perf`) stay on their daemon
29+
* branch as the source of truth until they clear the same gate. See
2630
* docs/adr/0009-apple-platform-consolidation.md (tracked in issue #974).
2731
*/
2832
export type PlatformPlugin = {
@@ -47,6 +51,17 @@ export type PlatformPlugin = {
4751
readonly bucket: CapabilityBucket;
4852
supportsByDefault?(device: DeviceInfo): boolean;
4953
};
54+
/**
55+
* The daemon app-log facet (issue #974). `resolveBackend` wraps the platform
56+
* branch of `src/daemon/app-log.ts`'s `resolveLogBackend`, returning the neutral
57+
* {@link LogBackend} tag for `device`. Present only on families that have an
58+
* app-log backend (Apple + Android); left `undefined` for linux/web, where the
59+
* hand branch historically fell through to the `'android'` default — the daemon
60+
* lookup preserves that fallthrough, and the parity test pins the equivalence.
61+
*/
62+
readonly appLog?: {
63+
resolveBackend(device: DeviceInfo): LogBackend;
64+
};
5065
};
5166

5267
// The single registry instance: leaf platform -> owning plugin. A family plugin

‎src/core/platform-plugin/register-builtins.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,16 @@ const applePlugin = {
1818
platforms: ['ios', 'macos'],
1919
familySelector: 'apple',
2020
capability: { bucket: 'apple' },
21+
// Wraps the Apple arm of `resolveLogBackend` verbatim: macOS -> 'macos';
22+
// an iOS `device` -> 'ios-device'; every other iOS kind -> 'ios-simulator'.
23+
appLog: {
24+
resolveBackend: (device: DeviceInfo) =>
25+
device.platform === 'macos'
26+
? 'macos'
27+
: device.kind === 'device'
28+
? 'ios-device'
29+
: 'ios-simulator',
30+
},
2131
createInteractor: async (device: DeviceInfo, runner: RunnerContext) => {
2232
const { createAppleInteractor } = await import('../interactors/apple.ts');
2333
return createAppleInteractor(device, runner);
@@ -41,6 +51,8 @@ const androidPlugin = {
4151
id: 'android',
4252
platforms: ['android'],
4353
capability: { bucket: 'android' },
54+
// Wraps the Android arm of `resolveLogBackend`: every Android device -> 'android'.
55+
appLog: { resolveBackend: () => 'android' },
4456
createInteractor: async (device: DeviceInfo) => {
4557
const { createAndroidInteractor } = await import('../interactors/android.ts');
4658
return createAndroidInteractor(device);
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
import assert from 'node:assert/strict';
2+
import { test } from 'vitest';
3+
import {
4+
DEVICE_TARGETS,
5+
PLATFORMS,
6+
type DeviceInfo,
7+
type DeviceKind,
8+
type DeviceTarget,
9+
} from '../../kernel/device.ts';
10+
import {
11+
ANDROID_EMULATOR,
12+
ANDROID_TV_DEVICE,
13+
IOS_DEVICE,
14+
IOS_SIMULATOR,
15+
LINUX_DEVICE,
16+
MACOS_DEVICE,
17+
TVOS_SIMULATOR,
18+
WEB_DESKTOP_DEVICE,
19+
} from '../../__tests__/test-utils/index.ts';
20+
import { getPlugin } from '../../core/platform-plugin/plugin.ts';
21+
import { registerBuiltinPlatformPlugins } from '../../core/platform-plugin/register-builtins.ts';
22+
import { resolveLogBackend } from '../app-log.ts';
23+
import type { LogBackend } from '../network-log.ts';
24+
25+
// Phase 3 step b.3 (issue #974) parity gate for the daemon app-log facet. The
26+
// per-platform branch of `resolveLogBackend` now flows through the PlatformPlugin
27+
// `appLog.resolveBackend` facet instead of a hand switch. An INDEPENDENT verbatim
28+
// copy of the former branch below is the BEFORE oracle: a plugin-vs-branch
29+
// disagreement on any sample device fails this test. (Mirrors the verbatim-copy
30+
// discipline in core/__tests__/capability-plugin-routing-parity.test.ts.)
31+
32+
registerBuiltinPlatformPlugins();
33+
34+
// --- INDEPENDENT verbatim copy of the former `resolveLogBackend` hand branch ---
35+
function resolveLogBackendByHand(device: DeviceInfo): LogBackend {
36+
if (device.platform === 'macos') return 'macos';
37+
if (device.platform === 'ios') {
38+
return device.kind === 'device' ? 'ios-device' : 'ios-simulator';
39+
}
40+
return 'android';
41+
}
42+
43+
// --- the exhaustive synthetic device matrix (every platform x kind x target) ---
44+
const DEVICE_KINDS_ALL: DeviceKind[] = ['simulator', 'emulator', 'device'];
45+
const DEVICE_TARGETS_ALL: (DeviceTarget | undefined)[] = [undefined, ...DEVICE_TARGETS];
46+
47+
function buildDeviceMatrix(): DeviceInfo[] {
48+
const devices: DeviceInfo[] = [];
49+
for (const platform of PLATFORMS) {
50+
for (const kind of DEVICE_KINDS_ALL) {
51+
for (const target of DEVICE_TARGETS_ALL) {
52+
devices.push({
53+
platform,
54+
id: `${platform}-${kind}-${target ?? 'none'}`,
55+
name: `${platform} ${kind} ${target ?? 'none'}`,
56+
kind,
57+
...(target ? { target } : {}),
58+
booted: true,
59+
});
60+
}
61+
}
62+
}
63+
return devices;
64+
}
65+
66+
// The hand-authored fixtures (the real discovery shapes) plus the exhaustive
67+
// synthetic cross-product, so every off-nominal combination is pinned too.
68+
const SAMPLE_DEVICES: DeviceInfo[] = [
69+
ANDROID_EMULATOR,
70+
ANDROID_TV_DEVICE,
71+
IOS_DEVICE,
72+
IOS_SIMULATOR,
73+
LINUX_DEVICE,
74+
MACOS_DEVICE,
75+
TVOS_SIMULATOR,
76+
WEB_DESKTOP_DEVICE,
77+
...buildDeviceMatrix(),
78+
];
79+
80+
test('resolveLogBackend routed through the plugin is byte-identical to the former hand branch', () => {
81+
for (const device of SAMPLE_DEVICES) {
82+
assert.equal(
83+
resolveLogBackend(device),
84+
resolveLogBackendByHand(device),
85+
`backend for ${device.id}`,
86+
);
87+
}
88+
});
89+
90+
test('only families with an app-log backend carry the appLog facet', () => {
91+
// Apple owns ios + macos (SAME plugin instance); Android carries its own.
92+
assert.equal(getPlugin('ios'), getPlugin('macos'));
93+
assert.ok(getPlugin('ios').appLog, 'apple plugin exposes appLog');
94+
assert.ok(getPlugin('android').appLog, 'android plugin exposes appLog');
95+
// linux/web historically fell through to the `'android'` default; they get NO
96+
// facet, and the daemon lookup preserves that fallthrough (asserted below).
97+
assert.equal(getPlugin('linux').appLog, undefined, 'linux plugin has no appLog');
98+
assert.equal(getPlugin('web').appLog, undefined, 'web plugin has no appLog');
99+
});
100+
101+
test('each populated appLog facet resolves the backend its family owns', () => {
102+
for (const device of SAMPLE_DEVICES.filter(
103+
(d) => d.platform === 'ios' || d.platform === 'macos' || d.platform === 'android',
104+
)) {
105+
assert.equal(
106+
getPlugin(device.platform).appLog?.resolveBackend(device),
107+
resolveLogBackendByHand(device),
108+
`facet backend for ${device.id}`,
109+
);
110+
}
111+
});
112+
113+
test('the factless families fall through to the historical default', () => {
114+
for (const device of SAMPLE_DEVICES.filter(
115+
(d) => d.platform === 'linux' || d.platform === 'web',
116+
)) {
117+
assert.equal(getPlugin(device.platform).appLog, undefined);
118+
assert.equal(resolveLogBackend(device), 'android', `fallthrough for ${device.id}`);
119+
}
120+
});

‎src/daemon/app-log.ts‎

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ import fs from 'node:fs';
22
import path from 'node:path';
33
import type { DeviceInfo } from '../kernel/device.ts';
44
import { AppError } from '../kernel/errors.ts';
5+
import { tryGetPlugin } from '../core/platform-plugin/plugin.ts';
6+
import { registerBuiltinPlatformPlugins } from '../core/platform-plugin/register-builtins.ts';
57
import { runCmd } from '../utils/exec.ts';
68
import { runXcrun } from '../platforms/apple/core/tool-provider.ts';
79
import { runAndroidAdb } from '../platforms/android/adb.ts';
@@ -30,6 +32,12 @@ import {
3032
type LogBackend,
3133
} from './network-log.ts';
3234

35+
// Populate the PlatformPlugin registry once at module load (idempotent; registers
36+
// only lazy closures, so no leaf code is imported and CLI cold-start is unaffected
37+
// — mirrors the same call in `core/capabilities.ts`). `resolveLogBackend` reads the
38+
// per-platform app-log facet from this registry, so it must be populated first.
39+
registerBuiltinPlatformPlugins();
40+
3341
export type { AppLogResult } from './app-log-process.ts';
3442
export type { AppLogState } from './app-log-process.ts';
3543
export { APP_LOG_PID_FILENAME, cleanupStaleAppLogProcesses } from './app-log-process.ts';
@@ -177,11 +185,11 @@ export function getAppLogPathMetadata(outPath: string): {
177185
}
178186

179187
export function resolveLogBackend(device: DeviceInfo): LogBackend {
180-
if (device.platform === 'macos') return 'macos';
181-
if (device.platform === 'ios') {
182-
return device.kind === 'device' ? 'ios-device' : 'ios-simulator';
183-
}
184-
return 'android';
188+
// Routes the platform branch through the PlatformPlugin app-log facet (issue
189+
// #974). Apple/Android carry a `resolveBackend`; linux/web (and any unregistered
190+
// platform) fall through to the historical `'android'` default. The daemon
191+
// app-log routing parity test pins this against the former hand branch.
192+
return tryGetPlugin(device.platform)?.appLog?.resolveBackend(device) ?? 'android';
185193
}
186194

187195
export async function readSessionNetworkCapture(params: {

0 commit comments

Comments
 (0)