Skip to content

Commit d80b021

Browse files
authored
refactor(contracts): make keyboard capability facts additive (#2459)
The keyboard family's facts builder took one required cell per operation, so an operation only one owner implements still cost a hand-written denial in every other owner. The builder now takes the family's denial once, as `unsupported`, and every operation cell is optional: an owner names what it serves and omission reports that denial verbatim, with the reason and hint the owner would otherwise have repeated per cell. Omission stays a classified refusal, never an unclassified cell and never an implied success: `unsupported` is required, so a call that leaves the family blank does not compile. An owner that names every operation still states the family refusal, and it must refuse the family rather than one operation of it — whatever the owner leaves unnamed reports that cell verbatim. The shared unavailable-facts input collapses `keyboardStatus`/`keyboardDismiss`/ `keyboardEnter` into one required `keyboard` cell, so the owners that answered the family with raw keys (webdriver, vega, linux, limrun's no-session binding) now answer it through the builder, which is the family's one entry point. Facts are unchanged for every owner, so the per-platform admission assertions hold untouched. Two owner tests now assert the keyboard reason as well as the availability, because one family cell reports one reason for all three operations: the WebDriver inactive session and the stale Limrun identity. Refs #2443
1 parent bbd53d6 commit d80b021

22 files changed

Lines changed: 165 additions & 93 deletions

File tree

packages/contracts/src/keyboard-runtime.test.ts

Lines changed: 40 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,58 @@ const device = {
1717
const local = (resolveInteractor: LocalInteractorOperationResolver) =>
1818
localInteractorSource({ device, resolveInteractor });
1919

20-
test('builds the exact keyboard operation fact catalog', () => {
20+
const available = { available: true } as const;
21+
22+
test('builds the exact keyboard operation fact catalog for an owner that names every operation', () => {
23+
const familyDenial = {
24+
available: false,
25+
reason: 'unsupported-device-kind',
26+
} as const;
2127
const status = { available: true } as const;
2228
const dismiss = {
2329
available: false,
2430
reason: 'unsupported-platform-leaf',
2531
} as const;
2632
const enter = { available: true } as const;
27-
expect(keyboardRuntimeOperationFacts({ status, dismiss, enter })).toEqual({
33+
expect(
34+
keyboardRuntimeOperationFacts({ unsupported: familyDenial, status, dismiss, enter }),
35+
).toEqual({
2836
keyboardStatus: status,
2937
keyboardDismiss: dismiss,
3038
keyboardEnter: enter,
3139
});
3240
});
3341

42+
test('an operation the owner never names reports the denial the owner stated for the family, verbatim — omission is a classified refusal, never an unclassified cell and never an implied success', () => {
43+
const denial = {
44+
available: false,
45+
reason: 'unsupported-platform-leaf',
46+
hint: 'Limrun iOS direct sessions do not expose keyboard actions.',
47+
} as const;
48+
49+
expect(keyboardRuntimeOperationFacts({ unsupported: denial, dismiss: available })).toEqual({
50+
keyboardStatus: denial,
51+
keyboardDismiss: available,
52+
keyboardEnter: denial,
53+
});
54+
});
55+
56+
test('an owner serving no keyboard operation names the family denial once and still answers with the exhaustive shape', () => {
57+
const denial = {
58+
available: false,
59+
reason: 'unsupported-platform-leaf',
60+
} as const;
61+
62+
const facts = keyboardRuntimeOperationFacts({ unsupported: denial });
63+
64+
expect(facts).toEqual({
65+
keyboardStatus: denial,
66+
keyboardDismiss: denial,
67+
keyboardEnter: denial,
68+
});
69+
expect(Object.isFrozen(facts)).toBe(true);
70+
});
71+
3472
test('a local status binding drives the interactor and returns its report', async () => {
3573
const keyboardStatus = vi.fn(async () => ({ visible: true }));
3674
const resolveInteractor = vi.fn(async () => ({ keyboardStatus }) as unknown as Interactor);

packages/contracts/src/keyboard-runtime.ts

Lines changed: 27 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ import type {
66
KeyboardStatusResult,
77
RunnerContext,
88
} from './interactor-types.ts';
9-
import type { RuntimeOperationFact } from './platform-runtime.ts';
9+
import type { RuntimeOperationFact, RuntimeOperationUnavailability } from './platform-runtime.ts';
1010
import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts';
1111

1212
export type { KeyboardDismissResult, KeyboardEnterResult, KeyboardStatusResult };
@@ -43,17 +43,35 @@ export type KeyboardRuntimeOperationFacts = Readonly<{
4343
keyboardEnter: RuntimeOperationFact;
4444
}>;
4545

46+
/**
47+
* What an owner declares about the keyboard family. No operation here is one every owner serves,
48+
* and several owners serve no keyboard operation at all, so every operation is optional and
49+
* `unsupported` names the denial an omitted cell reports. An owner with no keyboard surface states
50+
* that denial once instead of writing it out per operation, with the reason and hint it would
51+
* otherwise repeat by hand.
52+
*
53+
* Omission is a classified denial, never an unclassified cell and never an implied success: the
54+
* type refuses a call that does not carry `unsupported`, so no owner can leave the family blank.
55+
* An owner that serves one operation names it — omission means "refuses", never "the same as the
56+
* neighbour" — and its `unsupported` must refuse the family, not one operation of it, because
57+
* whatever the owner leaves unnamed reports that cell verbatim.
58+
*/
59+
export type KeyboardRuntimeOperationFactsInput = Readonly<{
60+
unsupported: RuntimeOperationUnavailability;
61+
status?: RuntimeOperationFact;
62+
dismiss?: RuntimeOperationFact;
63+
enter?: RuntimeOperationFact;
64+
}>;
65+
4666
export function keyboardRuntimeOperationFacts(
47-
input: Readonly<{
48-
status: RuntimeOperationFact;
49-
dismiss: RuntimeOperationFact;
50-
enter: RuntimeOperationFact;
51-
}>,
67+
input: KeyboardRuntimeOperationFactsInput,
5268
): KeyboardRuntimeOperationFacts {
69+
const declared = (fact: RuntimeOperationFact | undefined): RuntimeOperationFact =>
70+
fact ?? input.unsupported;
5371
return Object.freeze({
54-
keyboardStatus: input.status,
55-
keyboardDismiss: input.dismiss,
56-
keyboardEnter: input.enter,
72+
keyboardStatus: declared(input.status),
73+
keyboardDismiss: declared(input.dismiss),
74+
keyboardEnter: declared(input.enter),
5775
});
5876
}
5977

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

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,9 +47,7 @@ const UNAVAILABLE_FACTS: UnavailablePlatformRuntimeFacts = {
4747
home: { available: false, reason: 'unsupported-provider-mode' },
4848
orientation: { available: false, reason: 'unsupported-provider-mode' },
4949
tvRemote: { available: false, reason: 'unsupported-provider-mode' },
50-
keyboardStatus: { available: false, reason: 'unsupported-provider-mode' },
51-
keyboardDismiss: { available: false, reason: 'unsupported-provider-mode' },
52-
keyboardEnter: { available: false, reason: 'unsupported-provider-mode' },
50+
keyboard: { available: false, reason: 'unsupported-provider-mode' },
5351
readClipboard: { available: false, reason: 'unsupported-provider-mode' },
5452
writeClipboard: { available: false, reason: 'unsupported-provider-mode' },
5553
appSwitcher: { available: false, reason: 'unsupported-provider-mode' },
@@ -92,6 +90,12 @@ test('generic unavailable binding preserves exact provider ownership and mode',
9290
available: false,
9391
reason: 'unsupported-provider-mode',
9492
});
93+
for (const operation of ['keyboardStatus', 'keyboardDismiss', 'keyboardEnter'] as const) {
94+
assert.deepEqual(binding.facts.operations[operation], {
95+
available: false,
96+
reason: 'unsupported-provider-mode',
97+
});
98+
}
9599
// `apps` is left unclassified above (an optional cell): it inherits the network gap's reason.
96100
assert.deepEqual(binding.facts.operations.listApps, {
97101
available: false,

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

Lines changed: 6 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ import { touchRuntimeOperationFacts } from './touch-runtime.ts';
3737
/**
3838
* A runtime-contract helper for provider ownership gaps. It never assigns lifecycle semantics:
3939
* the selected package/provider must classify every lifecycle operation for its exact cell.
40+
*
41+
* A family is one cell where all of its operations share one reason, and one cell per operation
42+
* only where the reasons genuinely differ per operation.
4043
*/
4144
export type UnavailablePlatformRuntimeFacts = Readonly<{
4245
appLog: RuntimeOperationUnavailability;
@@ -58,9 +61,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{
5861
home: RuntimeOperationUnavailability;
5962
orientation: RuntimeOperationUnavailability;
6063
tvRemote: RuntimeOperationUnavailability;
61-
keyboardStatus: RuntimeOperationUnavailability;
62-
keyboardDismiss: RuntimeOperationUnavailability;
63-
keyboardEnter: RuntimeOperationUnavailability;
64+
keyboard: RuntimeOperationUnavailability;
6465
readClipboard: RuntimeOperationUnavailability;
6566
writeClipboard: RuntimeOperationUnavailability;
6667
appSwitcher: RuntimeOperationUnavailability;
@@ -116,9 +117,7 @@ const UNAVAILABLE_CELLS = {
116117
home: true,
117118
orientation: true,
118119
tvRemote: true,
119-
keyboardStatus: true,
120-
keyboardDismiss: true,
121-
keyboardEnter: true,
120+
keyboard: true,
122121
readClipboard: true,
123122
writeClipboard: true,
124123
appSwitcher: true,
@@ -245,11 +244,7 @@ export function createUnavailablePlatformRuntimeFacts(
245244
...homeRuntimeOperationFacts({ home: frozen.home }),
246245
...orientationRuntimeOperationFacts({ orientation: frozen.orientation }),
247246
...tvRemoteRuntimeOperationFacts({ tvRemote: frozen.tvRemote }),
248-
...keyboardRuntimeOperationFacts({
249-
status: frozen.keyboardStatus,
250-
dismiss: frozen.keyboardDismiss,
251-
enter: frozen.keyboardEnter,
252-
}),
247+
...keyboardRuntimeOperationFacts({ unsupported: frozen.keyboard }),
253248
...clipboardRuntimeOperationFacts({
254249
read: frozen.readClipboard,
255250
write: frozen.writeClipboard,

packages/platform-android/src/runtime.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ const focusKindUnavailable = Object.freeze({
7676
reason: 'unsupported-device-kind',
7777
hint: 'focus is supported on Android emulators and physical devices.',
7878
} as const);
79+
/** adb drives keyboard actions on the same two kinds it drives everything else. */
80+
const keyboardKindUnavailable = Object.freeze({
81+
available: false,
82+
reason: 'unsupported-device-kind',
83+
hint: 'keyboard actions are supported on Android emulators and physical devices.',
84+
} as const);
7985
const hoverUnavailable = Object.freeze({
8086
available: false,
8187
reason: 'unsupported-platform-leaf',
@@ -363,6 +369,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
363369
// The only owner with a live IME status read; dismiss/enter share every other
364370
// interaction cell's kind gate (parity with the retired `keyboard` bucket).
365371
...keyboardRuntimeOperationFacts({
372+
unsupported: keyboardKindUnavailable,
366373
status: androidTouchFact(device),
367374
dismiss: androidTouchFact(device),
368375
enter: androidTouchFact(device),

packages/platform-apple/src/navigation/runtime.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -101,8 +101,11 @@ function appleTvRemoteFact(device: DeviceInfo): RuntimeOperationFact {
101101
: tvRemoteUnavailable;
102102
}
103103

104-
/** The outer keyboard cell: unavailable with no hint, matching the retired `supportsKeyboard`
105-
* capability-bucket-level rejection (which carried no hint text of its own). */
104+
/**
105+
* The outer keyboard cell, and this owner's keyboard-family refusal: unavailable with no hint,
106+
* matching the retired `supportsKeyboard` capability-bucket-level rejection (which carried no hint
107+
* text of its own).
108+
*/
106109
const keyboardCellUnavailable = Object.freeze({
107110
available: false,
108111
reason: 'unsupported-platform-leaf',
@@ -138,6 +141,7 @@ export function appleNavigationFacts(device: DeviceInfo) {
138141
...orientationRuntimeOperationFacts({ orientation: appleOrientationFact(device) }),
139142
...tvRemoteRuntimeOperationFacts({ tvRemote: appleTvRemoteFact(device) }),
140143
...keyboardRuntimeOperationFacts({
144+
unsupported: keyboardCellUnavailable,
141145
status: appleKeyboardStatusFact(device),
142146
dismiss: appleKeyboardDismissFact(device),
143147
enter: appleKeyboardEnterFact(device),

packages/platform-harmonyos/src/runtime.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -287,7 +287,9 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor
287287
}),
288288
...orientationRuntimeOperationFacts({ orientation: harmonyPlatformLeafUnavailable }),
289289
...tvRemoteRuntimeOperationFacts({ tvRemote: harmonyPlatformLeafUnavailable }),
290+
// HDC drives dismissal and the enter key; any other keyboard operation is a leaf gap.
290291
...keyboardRuntimeOperationFacts({
292+
unsupported: harmonyPlatformLeafUnavailable,
291293
status: harmonyKeyboardStatusUnavailable,
292294
dismiss: harmonyFocusFact(device),
293295
enter: harmonyFocusFact(device),

packages/platform-linux/src/runtime.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -210,9 +210,7 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts<PlatformRuntimeOperations>
210210
awaitAlert: linuxPlatformLeafUnavailable,
211211
acceptAlert: linuxPlatformLeafUnavailable,
212212
dismissAlert: linuxPlatformLeafUnavailable,
213-
keyboardStatus: linuxPlatformLeafUnavailable,
214-
keyboardDismiss: linuxPlatformLeafUnavailable,
215-
keyboardEnter: linuxPlatformLeafUnavailable,
213+
keyboard: linuxPlatformLeafUnavailable,
216214
audioProbeCapture: linuxAudioProbeUnavailable,
217215
audioProbeQuery: linuxAudioProbeUnavailable,
218216
perf: linuxPlatformLeafUnavailable,

packages/platform-vega/src/runtime.ts

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -200,9 +200,7 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts<PlatformRuntimeOperations>
200200
awaitAlert: alertUnavailable,
201201
acceptAlert: alertUnavailable,
202202
dismissAlert: alertUnavailable,
203-
keyboardStatus: keyboardUnavailable,
204-
keyboardDismiss: keyboardUnavailable,
205-
keyboardEnter: keyboardUnavailable,
203+
keyboard: keyboardUnavailable,
206204
audioProbeCapture: audioProbeUnavailable,
207205
audioProbeQuery: audioProbeUnavailable,
208206
perf: unsupportedPlatformLeaf,

packages/platform-web/src/runtime.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -413,11 +413,7 @@ function webRuntimeFacts(
413413
...homeRuntimeOperationFacts({ home: navigationUnavailable }),
414414
...orientationRuntimeOperationFacts({ orientation: navigationUnavailable }),
415415
...tvRemoteRuntimeOperationFacts({ tvRemote: navigationUnavailable }),
416-
...keyboardRuntimeOperationFacts({
417-
status: navigationUnavailable,
418-
dismiss: navigationUnavailable,
419-
enter: navigationUnavailable,
420-
}),
416+
...keyboardRuntimeOperationFacts({ unsupported: navigationUnavailable }),
421417
// The web backend never carried a `clipboard` capability bucket (`WEB_QUERY_COMMANDS`
422418
// lists `audio` alone), so no clipboard cell was ever admitted here.
423419
...clipboardRuntimeOperationFacts({

0 commit comments

Comments
 (0)