Skip to content

Commit fd221b1

Browse files
authored
refactor(contracts): build unavailable runtime facts once (#2291)
* refactor(contracts): build unavailable runtime facts from one policy table freezeUnavailableFacts() classified each of the 34 UnavailablePlatformRuntimeFacts cells with its own Object.freeze call and its own inline comment, and createUnavailablePlatformRuntimeFacts() destructured all 38 fields off the result before spreading them into the operations map. Replace the per-field freezes with a single UNAVAILABLE_CELL_POLICY table (satisfies Record<UnavailableCellKey, 'owner-stated' | 'inherits-network'>) and a loop, so a new cell added to UnavailablePlatformRuntimeFacts must get a policy entry or the file fails to type-check. The scattered per-field comments collapse into one block comment above the table citing #1873. Drop the 39-line destructure; createUnavailablePlatformRuntimeFacts now reads the frozen record's fields directly. Add createFullyUnavailablePlatformRuntimeFacts(), an exported constructor that points every cell (lifecycle included) at one unavailability reason, for missing-owner cases that have no per-family classification of their own. Verified the table forces every cell to be classified: flipping `apps` from 'inherits-network' to 'owner-stated' (an optional cell most local runtimes still leave unclassified) is not a type error, since the table only governs runtime fallback and the field stays optional either way — but it does turn into a real test failure: packages/platform-linux/src/runtime.test.ts's `listApps` assertion goes from `{ available: false }` to `{}`, because the loop now treats an unclassified `apps` cell as owner-stated and stops falling back to the network gap. Reverted before committing. * refactor(gateway): reuse the shared fully-unavailable facts constructor unavailableProviderBinding() and unavailableProviderFacts() each hand-spelled every UnavailablePlatformRuntimeFacts cell for a provider mode with no registered module. The two lists had already drifted: the binding map omitted `readiness`, the facts map included it — harmless only because both fall back to the same 'unsupported-provider-mode' reason today, but nothing would have caught a real divergence. Both become one-liners over createFullyUnavailablePlatformRuntimeFacts(), so there is exactly one place that enumerates "every cell is this one reason", and unavailableProviderLifecycleFacts() is no longer needed. Added a regression test asserting bind() and inspectFacts() produce byte-for-byte equal facts for the same unregistered-provider device: it passes today (confirmed against the pre-refactor gateway.ts, restored temporarily to check) since the drift was reason-compatible, but it now pins that equivalence so a future cell added to only one of the two paths fails loudly instead of silently drifting again. * refactor(contracts): derive unavailable-cell fallback from optionality UNAVAILABLE_CELL_POLICY hand-duplicated which cells inherit the network gap and which are owner-stated, restating exactly what UnavailablePlatformRuntimeFacts's optional vs required properties already say. Nothing checked the two against each other, so a misclassified entry passed `satisfies Record<UnavailableCellKey, 'owner-stated' | 'inherits-network'>` and the cast at the read site turned it into `Object.freeze({ ...undefined })` = `{}` at runtime - a fact object missing `available` entirely. Replaced the two-value table with a key-only UNAVAILABLE_CELLS list and one `mapUnavailableCells` helper shared by both call sites. freezeUnavailableFacts now reads `unavailable[cell] ?? unavailable.network`, which TypeScript resolves without a cast because the union already covers the optional case - a wrong classification is impossible to express, not just checked for. Seen red once: reverted the `?? unavailable.network` fallback (kept `unavailable[cell]` alone) and reran platform-runtime-unavailable.test.ts - the new `listApps` assertion failed with the exact `{}` malformed-fact shape the policy-table bug could produce. Restored the fallback and it passes. Also added the missing inherits-network assertion itself (platform-runtime-unavailable.test.ts): the existing test only exercised owner-stated cells, so the inheriting path had no direct coverage in this file. * test(gateway): fold bind/inspect parity into the existing fixture The parity test duplicated the whole gateway construction (single apple module, inline webdriver ProviderDeviceRuntime literal) from the test directly above it instead of reusing it. Appended the inspectFacts() call and the equality assertion to that test instead, and switched toEqual to toStrictEqual: toEqual ignores undefined-valued keys, and an omitted cell is exactly how the two facts maps could drift from each other.
1 parent d23346c commit fd221b1

4 files changed

Lines changed: 178 additions & 258 deletions

File tree

packages/contracts/src/platform-runtime-unavailable.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,11 @@ test('generic unavailable binding preserves exact provider ownership and mode',
9292
available: false,
9393
reason: 'unsupported-provider-mode',
9494
});
95+
// `apps` is left unclassified above (an optional cell): it inherits the network gap's reason.
96+
assert.deepEqual(binding.facts.operations.listApps, {
97+
available: false,
98+
reason: 'owner-capability-missing',
99+
});
95100
assert.deepEqual(binding.operations, {});
96101
await binding[Symbol.asyncDispose]();
97102
});

packages/contracts/src/platform-runtime-unavailable.ts

Lines changed: 156 additions & 164 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,89 @@ type FrozenUnavailablePlatformRuntimeFacts = Readonly<
8787
Readonly<{ lifecycle: ApplicationLifecycleOperationFacts }>
8888
>;
8989

90+
type UnavailableCellKey = keyof Omit<UnavailablePlatformRuntimeFacts, 'lifecycle'>;
91+
92+
/**
93+
* Every cell name, for iteration. Whether a cell left unclassified by its owner falls back to the
94+
* caller's network gap, or must be stated by the owner itself, is decided by
95+
* `UnavailablePlatformRuntimeFacts` alone: an optional property inherits `network`, a required one
96+
* is owner-stated (#1873) and always present. No second table restates that split — a misclassified
97+
* cell there would fail to type-check rather than silently produce a malformed fact.
98+
*/
99+
const UNAVAILABLE_CELLS = {
100+
appLog: true,
101+
apps: true,
102+
appDeployment: true,
103+
appState: true,
104+
network: true,
105+
screenRecording: true,
106+
screenshot: true,
107+
snapshot: true,
108+
viewport: true,
109+
focus: true,
110+
gesture: true,
111+
scroll: true,
112+
typeText: true,
113+
touch: true,
114+
elementText: true,
115+
back: true,
116+
home: true,
117+
orientation: true,
118+
tvRemote: true,
119+
keyboardStatus: true,
120+
keyboardDismiss: true,
121+
keyboardEnter: true,
122+
readClipboard: true,
123+
writeClipboard: true,
124+
appSwitcher: true,
125+
triggerAppEvent: true,
126+
setSetting: true,
127+
readAlert: true,
128+
awaitAlert: true,
129+
acceptAlert: true,
130+
dismissAlert: true,
131+
audioProbeCapture: true,
132+
audioProbeQuery: true,
133+
perf: true,
134+
readiness: true,
135+
shutdown: true,
136+
} satisfies Record<UnavailableCellKey, true>;
137+
138+
/** Fills every cell name through `fn`, in the one place a cell record is assembled by key. */
139+
function mapUnavailableCells<Value>(
140+
fn: (cell: UnavailableCellKey) => Value,
141+
): Record<UnavailableCellKey, Value> {
142+
const result = {} as Record<UnavailableCellKey, Value>;
143+
for (const cell of Object.keys(UNAVAILABLE_CELLS) as UnavailableCellKey[]) {
144+
result[cell] = fn(cell);
145+
}
146+
return result;
147+
}
148+
149+
/**
150+
* A complete facts value for one unavailability reason, for owners with no runtime module at all:
151+
* every cell, lifecycle included, reports the same reason, so a missing owner cannot leave a cell
152+
* unclassified by omission.
153+
*/
154+
export function createFullyUnavailablePlatformRuntimeFacts(
155+
unavailable: RuntimeOperationUnavailability,
156+
): UnavailablePlatformRuntimeFacts {
157+
return Object.freeze({
158+
...mapUnavailableCells(() => unavailable),
159+
lifecycle: applicationLifecycleOperationFacts({
160+
resolveOpenTarget: unavailable,
161+
prepareApplicationOpen: unavailable,
162+
openApplication: unavailable,
163+
applyRuntimeHints: unavailable,
164+
clearRuntimeHints: unavailable,
165+
closeApplication: unavailable,
166+
finalizeApplicationClose: unavailable,
167+
prepareAppleRunner: unavailable,
168+
configureProviderPortReverse: unavailable,
169+
}),
170+
});
171+
}
172+
90173
export function createUnavailablePlatformRuntimeBinding(
91174
device: DeviceInfo,
92175
owner: RuntimeOwnerRef,
@@ -106,136 +189,101 @@ export function createUnavailablePlatformRuntimeFacts(
106189
owner: RuntimeOwnerRef,
107190
unavailable: UnavailablePlatformRuntimeFacts,
108191
): RuntimeFacts<PlatformRuntimeOperations> {
109-
const {
110-
appLog,
111-
apps,
112-
appDeployment,
113-
appState,
114-
network,
115-
screenRecording,
116-
screenshot,
117-
snapshot,
118-
viewport,
119-
focus,
120-
gesture,
121-
scroll,
122-
typeText,
123-
touch,
124-
elementText,
125-
back,
126-
home,
127-
orientation,
128-
tvRemote,
129-
keyboardStatus,
130-
keyboardDismiss,
131-
keyboardEnter,
132-
readClipboard,
133-
writeClipboard,
134-
appSwitcher,
135-
triggerAppEvent,
136-
setSetting,
137-
readAlert,
138-
awaitAlert,
139-
acceptAlert,
140-
dismissAlert,
141-
audioProbeCapture,
142-
audioProbeQuery,
143-
perf,
144-
readiness,
145-
shutdown,
146-
lifecycle,
147-
} = freezeUnavailableFacts(unavailable);
192+
const frozen = freezeUnavailableFacts(unavailable);
148193
return Object.freeze({
149194
device: {
150195
...deviceShape(device),
151196
providerMode: providerModeForOwner(owner),
152197
},
153198
operations: {
154-
appLogInspect: appLog,
155-
appLogDoctor: appLog,
156-
appLogStart: appLog,
157-
appLogReattach: appLog,
158-
appLogCleanup: appLog,
159-
listApps: apps,
160-
deployApp: appDeployment,
161-
materializeAppSource: appDeployment,
162-
deployMaterializedApp: appDeployment,
163-
sendPushNotification: appDeployment,
164-
appState,
165-
networkDump: network,
166-
screenRecordingStart: screenRecording,
167-
screenRecordingReattach: screenRecording,
168-
screenRecordingCleanup: screenRecording,
169-
...screenshotRuntimeOperationFacts({ capture: screenshot }),
199+
appLogInspect: frozen.appLog,
200+
appLogDoctor: frozen.appLog,
201+
appLogStart: frozen.appLog,
202+
appLogReattach: frozen.appLog,
203+
appLogCleanup: frozen.appLog,
204+
listApps: frozen.apps,
205+
deployApp: frozen.appDeployment,
206+
materializeAppSource: frozen.appDeployment,
207+
deployMaterializedApp: frozen.appDeployment,
208+
sendPushNotification: frozen.appDeployment,
209+
appState: frozen.appState,
210+
networkDump: frozen.network,
211+
screenRecordingStart: frozen.screenRecording,
212+
screenRecordingReattach: frozen.screenRecording,
213+
screenRecordingCleanup: frozen.screenRecording,
214+
...screenshotRuntimeOperationFacts({ capture: frozen.screenshot }),
170215
...snapshotRuntimeOperationFacts({
171-
capture: snapshot,
172-
customActions: snapshot,
173-
withoutActiveApp: snapshot,
216+
capture: frozen.snapshot,
217+
customActions: frozen.snapshot,
218+
withoutActiveApp: frozen.snapshot,
174219
}),
175220
// The preferred text reading starts unavailable for every family, on the same sentinel as
176221
// capture: an owner that has a native reading declares it explicitly, and one that does not
177222
// sends every text wait to the canonical tree.
178223
...selectorObservationRuntimeOperationFacts({
179-
findText: snapshot,
180-
findSelector: snapshot,
224+
findText: frozen.snapshot,
225+
findSelector: frozen.snapshot,
181226
}),
182-
...viewportRuntimeOperationFacts({ setViewport: viewport }),
183-
...focusRuntimeOperationFacts({ focus }),
227+
...viewportRuntimeOperationFacts({ setViewport: frozen.viewport }),
228+
...focusRuntimeOperationFacts({ focus: frozen.focus }),
184229
...gestureRuntimeOperationFacts({
185-
plan: gesture,
186-
directionalFling: gesture,
187-
multiTouch: gesture,
188-
targetAuthoredDrag: gesture,
189-
viewport: gesture,
230+
plan: frozen.gesture,
231+
directionalFling: frozen.gesture,
232+
multiTouch: frozen.gesture,
233+
targetAuthoredDrag: frozen.gesture,
234+
viewport: frozen.gesture,
190235
}),
191-
...scrollRuntimeOperationFacts({ scroll }),
192-
...typeTextRuntimeOperationFacts({ type: typeText }),
236+
...scrollRuntimeOperationFacts({ scroll: frozen.scroll }),
237+
...typeTextRuntimeOperationFacts({ type: frozen.typeText }),
193238
...touchRuntimeOperationFacts({
194-
tap: touch,
195-
tapRef: touch,
196-
longPress: touch,
197-
hover: touch,
198-
hoverRef: touch,
199-
fill: touch,
200-
fillRef: touch,
201-
tapElementSelector: touch,
239+
tap: frozen.touch,
240+
tapRef: frozen.touch,
241+
longPress: frozen.touch,
242+
hover: frozen.touch,
243+
hoverRef: frozen.touch,
244+
fill: frozen.touch,
245+
fillRef: frozen.touch,
246+
tapElementSelector: frozen.touch,
202247
}),
203-
...elementTextRuntimeOperationFacts({ readTextAtPoint: elementText }),
204-
...backRuntimeOperationFacts({ back }),
205-
...homeRuntimeOperationFacts({ home }),
206-
...orientationRuntimeOperationFacts({ orientation }),
207-
...tvRemoteRuntimeOperationFacts({ tvRemote }),
248+
...elementTextRuntimeOperationFacts({ readTextAtPoint: frozen.elementText }),
249+
...backRuntimeOperationFacts({ back: frozen.back }),
250+
...homeRuntimeOperationFacts({ home: frozen.home }),
251+
...orientationRuntimeOperationFacts({ orientation: frozen.orientation }),
252+
...tvRemoteRuntimeOperationFacts({ tvRemote: frozen.tvRemote }),
208253
...keyboardRuntimeOperationFacts({
209-
status: keyboardStatus,
210-
dismiss: keyboardDismiss,
211-
enter: keyboardEnter,
254+
status: frozen.keyboardStatus,
255+
dismiss: frozen.keyboardDismiss,
256+
enter: frozen.keyboardEnter,
257+
}),
258+
...clipboardRuntimeOperationFacts({
259+
read: frozen.readClipboard,
260+
write: frozen.writeClipboard,
212261
}),
213-
...clipboardRuntimeOperationFacts({ read: readClipboard, write: writeClipboard }),
214-
...appSwitcherRuntimeOperationFacts({ appSwitcher }),
215-
...appEventRuntimeOperationFacts({ triggerAppEvent }),
216-
...settingsRuntimeOperationFacts({ setSetting }),
262+
...appSwitcherRuntimeOperationFacts({ appSwitcher: frozen.appSwitcher }),
263+
...appEventRuntimeOperationFacts({ triggerAppEvent: frozen.triggerAppEvent }),
264+
...settingsRuntimeOperationFacts({ setSetting: frozen.setSetting }),
217265
...alertRuntimeOperationFacts({
218-
read: readAlert,
219-
wait: awaitAlert,
220-
accept: acceptAlert,
221-
dismiss: dismissAlert,
266+
read: frozen.readAlert,
267+
wait: frozen.awaitAlert,
268+
accept: frozen.acceptAlert,
269+
dismiss: frozen.dismissAlert,
222270
}),
223271
...audioProbeRuntimeOperationFacts({
224-
capture: audioProbeCapture,
225-
query: audioProbeQuery,
272+
capture: frozen.audioProbeCapture,
273+
query: frozen.audioProbeQuery,
226274
}),
227275
...perfRuntimeOperationFacts({
228-
frames: perf,
229-
memorySample: perf,
230-
memorySnapshot: perf,
231-
nativeCapture: perf,
232-
profileReport: perf,
276+
frames: frozen.perf,
277+
memorySample: frozen.perf,
278+
memorySnapshot: frozen.perf,
279+
nativeCapture: frozen.perf,
280+
profileReport: frozen.perf,
233281
}),
234-
ensureReady: readiness,
235-
bootTarget: readiness,
236-
bootTargetHeadless: readiness,
237-
shutdownTarget: shutdown,
238-
...lifecycle,
282+
ensureReady: frozen.readiness,
283+
bootTarget: frozen.readiness,
284+
bootTargetHeadless: frozen.readiness,
285+
shutdownTarget: frozen.shutdown,
286+
...frozen.lifecycle,
239287
},
240288
});
241289
}
@@ -254,66 +302,10 @@ function providerModeForOwner(owner: RuntimeOwnerRef): RuntimeProviderMode {
254302
function freezeUnavailableFacts(
255303
unavailable: UnavailablePlatformRuntimeFacts,
256304
): FrozenUnavailablePlatformRuntimeFacts {
257-
// Every optional cell falls back to the caller's network gap: an owner that did not classify a
258-
// family has, by construction, the same reason its transport does.
259-
const orNetwork = (fact: RuntimeOperationUnavailability | undefined) =>
260-
Object.freeze({ ...(fact ?? unavailable.network) });
261305
return Object.freeze({
262-
appLog: Object.freeze({ ...unavailable.appLog }),
263-
apps: orNetwork(unavailable.apps),
264-
appDeployment: orNetwork(unavailable.appDeployment),
265-
appState: orNetwork(unavailable.appState),
266-
network: Object.freeze({ ...unavailable.network }),
267-
screenRecording: orNetwork(unavailable.screenRecording),
268-
// Capture cells are stated by their owner, never inherited from the transport gap (#1873).
269-
screenshot: Object.freeze({ ...unavailable.screenshot }),
270-
snapshot: orNetwork(unavailable.snapshot),
271-
viewport: Object.freeze({ ...unavailable.viewport }),
272-
// Interaction cells are stated by their owner: a family that can drive touch says so for its
273-
// exact kinds, and one that cannot must say why rather than inherit a transport gap.
274-
focus: Object.freeze({ ...unavailable.focus }),
275-
gesture: Object.freeze({ ...unavailable.gesture }),
276-
scroll: Object.freeze({ ...unavailable.scroll }),
277-
typeText: Object.freeze({ ...unavailable.typeText }),
278-
touch: Object.freeze({ ...unavailable.touch }),
279-
readiness: orNetwork(unavailable.readiness),
280-
shutdown: orNetwork(unavailable.shutdown),
281-
elementText: Object.freeze({ ...unavailable.elementText }),
282-
// Navigation and keyboard cells are stated by their owner too: each differs by family
283-
// (harmonyos drives back/home but not orientation/tvRemote; android alone answers a keyboard
284-
// status read), so none of them may inherit a sibling's gap.
285-
back: Object.freeze({ ...unavailable.back }),
286-
home: Object.freeze({ ...unavailable.home }),
287-
orientation: Object.freeze({ ...unavailable.orientation }),
288-
tvRemote: Object.freeze({ ...unavailable.tvRemote }),
289-
keyboardStatus: Object.freeze({ ...unavailable.keyboardStatus }),
290-
keyboardDismiss: Object.freeze({ ...unavailable.keyboardDismiss }),
291-
keyboardEnter: Object.freeze({ ...unavailable.keyboardEnter }),
292-
// Clipboard cells are stated by their owner for the same reason: the surface differs by leaf
293-
// and kind (an Apple simulator has one, a physical non-macOS Apple device does not), and read
294-
// and write can diverge on a provider whose extension exposes only one half.
295-
readClipboard: Object.freeze({ ...unavailable.readClipboard }),
296-
writeClipboard: Object.freeze({ ...unavailable.writeClipboard }),
297-
// The app switcher is the springboard surface `home` drives, and differs by owner the same
298-
// way: an owner states it for its exact leaf rather than inheriting a sibling's gap.
299-
appSwitcher: Object.freeze({ ...unavailable.appSwitcher }),
300-
// App-event delivery opens a URL on the device, which is not something a transport gap can
301-
// speak for: each owner states whether it can open one at all.
302-
triggerAppEvent: Object.freeze({ ...unavailable.triggerAppEvent }),
303-
// Device settings differ by leaf and kind the way the pasteboard does, and a provider can
304-
// own a device without exposing any settings API at all, so each owner states its own cell.
305-
setSetting: Object.freeze({ ...unavailable.setSetting }),
306-
readAlert: Object.freeze({ ...unavailable.readAlert }),
307-
awaitAlert: Object.freeze({ ...unavailable.awaitAlert }),
308-
acceptAlert: Object.freeze({ ...unavailable.acceptAlert }),
309-
dismissAlert: Object.freeze({ ...unavailable.dismissAlert }),
310-
// Audio cells are stated by their owner (#1873): host capture and the page probe live on
311-
// different families entirely, so neither may inherit a transport gap.
312-
audioProbeCapture: Object.freeze({ ...unavailable.audioProbeCapture }),
313-
audioProbeQuery: Object.freeze({ ...unavailable.audioProbeQuery }),
314-
// Perf starts native tools and may create a durable capture. Every exact owner states the
315-
// gap rather than inheriting a transport failure that could imply local-tool fallthrough.
316-
perf: orNetwork(unavailable.perf),
306+
...mapUnavailableCells((cell) =>
307+
Object.freeze({ ...(unavailable[cell] ?? unavailable.network) }),
308+
),
317309
lifecycle: applicationLifecycleOperationFacts(unavailable.lifecycle),
318310
});
319311
}

src/platform-runtime-gateway.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -514,6 +514,10 @@ describe('composed platform runtime gateway', () => {
514514
});
515515
expect(hostLoad).not.toHaveBeenCalled();
516516
expect(localLoad).not.toHaveBeenCalled();
517+
const inspected = await runtimeGateway.inspectFacts(device);
518+
// The bind and inspect arms build this fact set independently; both must derive from the same
519+
// construction path rather than two hand-maintained maps that can drift from each other.
520+
expect(binding.facts).toStrictEqual(inspected);
517521
});
518522

519523
test.each(LIFECYCLE_FACETS)(

0 commit comments

Comments
 (0)