Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions packages/contracts/src/keyboard-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,20 +17,58 @@ const device = {
const local = (resolveInteractor: LocalInteractorOperationResolver) =>
localInteractorSource({ device, resolveInteractor });

test('builds the exact keyboard operation fact catalog', () => {
const available = { available: true } as const;

test('builds the exact keyboard operation fact catalog for an owner that names every operation', () => {
const familyDenial = {
available: false,
reason: 'unsupported-device-kind',
} as const;
const status = { available: true } as const;
const dismiss = {
available: false,
reason: 'unsupported-platform-leaf',
} as const;
const enter = { available: true } as const;
expect(keyboardRuntimeOperationFacts({ status, dismiss, enter })).toEqual({
expect(
keyboardRuntimeOperationFacts({ unsupported: familyDenial, status, dismiss, enter }),
).toEqual({
keyboardStatus: status,
keyboardDismiss: dismiss,
keyboardEnter: enter,
});
});

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', () => {
const denial = {
available: false,
reason: 'unsupported-platform-leaf',
hint: 'Limrun iOS direct sessions do not expose keyboard actions.',
} as const;

expect(keyboardRuntimeOperationFacts({ unsupported: denial, dismiss: available })).toEqual({
keyboardStatus: denial,
keyboardDismiss: available,
keyboardEnter: denial,
});
});

test('an owner serving no keyboard operation names the family denial once and still answers with the exhaustive shape', () => {
const denial = {
available: false,
reason: 'unsupported-platform-leaf',
} as const;

const facts = keyboardRuntimeOperationFacts({ unsupported: denial });

expect(facts).toEqual({
keyboardStatus: denial,
keyboardDismiss: denial,
keyboardEnter: denial,
});
expect(Object.isFrozen(facts)).toBe(true);
});

test('a local status binding drives the interactor and returns its report', async () => {
const keyboardStatus = vi.fn(async () => ({ visible: true }));
const resolveInteractor = vi.fn(async () => ({ keyboardStatus }) as unknown as Interactor);
Expand Down
36 changes: 27 additions & 9 deletions packages/contracts/src/keyboard-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type {
KeyboardStatusResult,
RunnerContext,
} from './interactor-types.ts';
import type { RuntimeOperationFact } from './platform-runtime.ts';
import type { RuntimeOperationFact, RuntimeOperationUnavailability } from './platform-runtime.ts';
import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts';

export type { KeyboardDismissResult, KeyboardEnterResult, KeyboardStatusResult };
Expand Down Expand Up @@ -43,17 +43,35 @@ export type KeyboardRuntimeOperationFacts = Readonly<{
keyboardEnter: RuntimeOperationFact;
}>;

/**
* What an owner declares about the keyboard family. No operation here is one every owner serves,
* and several owners serve no keyboard operation at all, so every operation is optional and
* `unsupported` names the denial an omitted cell reports. An owner with no keyboard surface states
* that denial once instead of writing it out per operation, with the reason and hint it would
* otherwise repeat by hand.
*
* Omission is a classified denial, never an unclassified cell and never an implied success: the
* type refuses a call that does not carry `unsupported`, so no owner can leave the family blank.
* An owner that serves one operation names it — omission means "refuses", never "the same as the
* neighbour" — and its `unsupported` must refuse the family, not one operation of it, because
* whatever the owner leaves unnamed reports that cell verbatim.
*/
export type KeyboardRuntimeOperationFactsInput = Readonly<{
unsupported: RuntimeOperationUnavailability;
status?: RuntimeOperationFact;
dismiss?: RuntimeOperationFact;
enter?: RuntimeOperationFact;
}>;

export function keyboardRuntimeOperationFacts(
input: Readonly<{
status: RuntimeOperationFact;
dismiss: RuntimeOperationFact;
enter: RuntimeOperationFact;
}>,
input: KeyboardRuntimeOperationFactsInput,
): KeyboardRuntimeOperationFacts {
const declared = (fact: RuntimeOperationFact | undefined): RuntimeOperationFact =>
fact ?? input.unsupported;
return Object.freeze({
keyboardStatus: input.status,
keyboardDismiss: input.dismiss,
keyboardEnter: input.enter,
keyboardStatus: declared(input.status),
keyboardDismiss: declared(input.dismiss),
keyboardEnter: declared(input.enter),
});
}

Expand Down
10 changes: 7 additions & 3 deletions packages/contracts/src/platform-runtime-unavailable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,9 +47,7 @@ const UNAVAILABLE_FACTS: UnavailablePlatformRuntimeFacts = {
home: { available: false, reason: 'unsupported-provider-mode' },
orientation: { available: false, reason: 'unsupported-provider-mode' },
tvRemote: { available: false, reason: 'unsupported-provider-mode' },
keyboardStatus: { available: false, reason: 'unsupported-provider-mode' },
keyboardDismiss: { available: false, reason: 'unsupported-provider-mode' },
keyboardEnter: { available: false, reason: 'unsupported-provider-mode' },
keyboard: { available: false, reason: 'unsupported-provider-mode' },
readClipboard: { available: false, reason: 'unsupported-provider-mode' },
writeClipboard: { available: false, reason: 'unsupported-provider-mode' },
appSwitcher: { available: false, reason: 'unsupported-provider-mode' },
Expand Down Expand Up @@ -92,6 +90,12 @@ test('generic unavailable binding preserves exact provider ownership and mode',
available: false,
reason: 'unsupported-provider-mode',
});
for (const operation of ['keyboardStatus', 'keyboardDismiss', 'keyboardEnter'] as const) {
assert.deepEqual(binding.facts.operations[operation], {
available: false,
reason: 'unsupported-provider-mode',
});
}
// `apps` is left unclassified above (an optional cell): it inherits the network gap's reason.
assert.deepEqual(binding.facts.operations.listApps, {
available: false,
Expand Down
17 changes: 6 additions & 11 deletions packages/contracts/src/platform-runtime-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ import { touchRuntimeOperationFacts } from './touch-runtime.ts';
/**
* A runtime-contract helper for provider ownership gaps. It never assigns lifecycle semantics:
* the selected package/provider must classify every lifecycle operation for its exact cell.
*
* A family is one cell where all of its operations share one reason, and one cell per operation
* only where the reasons genuinely differ per operation.
*/
export type UnavailablePlatformRuntimeFacts = Readonly<{
appLog: RuntimeOperationUnavailability;
Expand All @@ -58,9 +61,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{
home: RuntimeOperationUnavailability;
orientation: RuntimeOperationUnavailability;
tvRemote: RuntimeOperationUnavailability;
keyboardStatus: RuntimeOperationUnavailability;
keyboardDismiss: RuntimeOperationUnavailability;
keyboardEnter: RuntimeOperationUnavailability;
keyboard: RuntimeOperationUnavailability;
readClipboard: RuntimeOperationUnavailability;
writeClipboard: RuntimeOperationUnavailability;
appSwitcher: RuntimeOperationUnavailability;
Expand Down Expand Up @@ -116,9 +117,7 @@ const UNAVAILABLE_CELLS = {
home: true,
orientation: true,
tvRemote: true,
keyboardStatus: true,
keyboardDismiss: true,
keyboardEnter: true,
keyboard: true,
readClipboard: true,
writeClipboard: true,
appSwitcher: true,
Expand Down Expand Up @@ -245,11 +244,7 @@ export function createUnavailablePlatformRuntimeFacts(
...homeRuntimeOperationFacts({ home: frozen.home }),
...orientationRuntimeOperationFacts({ orientation: frozen.orientation }),
...tvRemoteRuntimeOperationFacts({ tvRemote: frozen.tvRemote }),
...keyboardRuntimeOperationFacts({
status: frozen.keyboardStatus,
dismiss: frozen.keyboardDismiss,
enter: frozen.keyboardEnter,
}),
...keyboardRuntimeOperationFacts({ unsupported: frozen.keyboard }),
...clipboardRuntimeOperationFacts({
read: frozen.readClipboard,
write: frozen.writeClipboard,
Expand Down
7 changes: 7 additions & 0 deletions packages/platform-android/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ const focusKindUnavailable = Object.freeze({
reason: 'unsupported-device-kind',
hint: 'focus is supported on Android emulators and physical devices.',
} as const);
/** adb drives keyboard actions on the same two kinds it drives everything else. */
const keyboardKindUnavailable = Object.freeze({
available: false,
reason: 'unsupported-device-kind',
hint: 'keyboard actions are supported on Android emulators and physical devices.',
} as const);
const hoverUnavailable = Object.freeze({
available: false,
reason: 'unsupported-platform-leaf',
Expand Down Expand Up @@ -363,6 +369,7 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
// The only owner with a live IME status read; dismiss/enter share every other
// interaction cell's kind gate (parity with the retired `keyboard` bucket).
...keyboardRuntimeOperationFacts({
unsupported: keyboardKindUnavailable,
status: androidTouchFact(device),
dismiss: androidTouchFact(device),
enter: androidTouchFact(device),
Expand Down
8 changes: 6 additions & 2 deletions packages/platform-apple/src/navigation/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,8 +101,11 @@ function appleTvRemoteFact(device: DeviceInfo): RuntimeOperationFact {
: tvRemoteUnavailable;
}

/** The outer keyboard cell: unavailable with no hint, matching the retired `supportsKeyboard`
* capability-bucket-level rejection (which carried no hint text of its own). */
/**
* The outer keyboard cell, and this owner's keyboard-family refusal: unavailable with no hint,
* matching the retired `supportsKeyboard` capability-bucket-level rejection (which carried no hint
* text of its own).
*/
const keyboardCellUnavailable = Object.freeze({
available: false,
reason: 'unsupported-platform-leaf',
Expand Down Expand Up @@ -138,6 +141,7 @@ export function appleNavigationFacts(device: DeviceInfo) {
...orientationRuntimeOperationFacts({ orientation: appleOrientationFact(device) }),
...tvRemoteRuntimeOperationFacts({ tvRemote: appleTvRemoteFact(device) }),
...keyboardRuntimeOperationFacts({
unsupported: keyboardCellUnavailable,
status: appleKeyboardStatusFact(device),
dismiss: appleKeyboardDismissFact(device),
enter: appleKeyboardEnterFact(device),
Expand Down
2 changes: 2 additions & 0 deletions packages/platform-harmonyos/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,9 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor
}),
...orientationRuntimeOperationFacts({ orientation: harmonyPlatformLeafUnavailable }),
...tvRemoteRuntimeOperationFacts({ tvRemote: harmonyPlatformLeafUnavailable }),
// HDC drives dismissal and the enter key; any other keyboard operation is a leaf gap.
...keyboardRuntimeOperationFacts({
unsupported: harmonyPlatformLeafUnavailable,
status: harmonyKeyboardStatusUnavailable,
dismiss: harmonyFocusFact(device),
enter: harmonyFocusFact(device),
Expand Down
4 changes: 1 addition & 3 deletions packages/platform-linux/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,7 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts<PlatformRuntimeOperations>
awaitAlert: linuxPlatformLeafUnavailable,
acceptAlert: linuxPlatformLeafUnavailable,
dismissAlert: linuxPlatformLeafUnavailable,
keyboardStatus: linuxPlatformLeafUnavailable,
keyboardDismiss: linuxPlatformLeafUnavailable,
keyboardEnter: linuxPlatformLeafUnavailable,
keyboard: linuxPlatformLeafUnavailable,
audioProbeCapture: linuxAudioProbeUnavailable,
audioProbeQuery: linuxAudioProbeUnavailable,
perf: linuxPlatformLeafUnavailable,
Expand Down
4 changes: 1 addition & 3 deletions packages/platform-vega/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,7 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts<PlatformRuntimeOperations>
awaitAlert: alertUnavailable,
acceptAlert: alertUnavailable,
dismissAlert: alertUnavailable,
keyboardStatus: keyboardUnavailable,
keyboardDismiss: keyboardUnavailable,
keyboardEnter: keyboardUnavailable,
keyboard: keyboardUnavailable,
audioProbeCapture: audioProbeUnavailable,
audioProbeQuery: audioProbeUnavailable,
perf: unsupportedPlatformLeaf,
Expand Down
6 changes: 1 addition & 5 deletions packages/platform-web/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,11 +413,7 @@ function webRuntimeFacts(
...homeRuntimeOperationFacts({ home: navigationUnavailable }),
...orientationRuntimeOperationFacts({ orientation: navigationUnavailable }),
...tvRemoteRuntimeOperationFacts({ tvRemote: navigationUnavailable }),
...keyboardRuntimeOperationFacts({
status: navigationUnavailable,
dismiss: navigationUnavailable,
enter: navigationUnavailable,
}),
...keyboardRuntimeOperationFacts({ unsupported: navigationUnavailable }),
// The web backend never carried a `clipboard` capability bucket (`WEB_QUERY_COMMANDS`
// lists `audio` alone), so no clipboard cell was ever admitted here.
...clipboardRuntimeOperationFacts({
Expand Down
8 changes: 8 additions & 0 deletions packages/provider-limrun/src/app-log-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -358,6 +358,14 @@ test('fails closed for a stale Android identity before exposing facts or binding
expect(facts.operations.captureScreenshot).toMatchObject({ available: false });
expect(facts.operations.focusPoint).toMatchObject({ available: false });
expect(facts.operations.typeText).toMatchObject({ available: false });
// Every keyboard operation shares the session gap, not a leaf's own refusal.
for (const operation of ['keyboardStatus', 'keyboardDismiss', 'keyboardEnter'] as const) {
expect(facts.operations[operation]).toMatchObject({
available: false,
reason: 'owner-capability-missing',
hint: 'Limrun requires a matching live provider session for this device.',
});
}
await expect(
owner.bind({ device: staleDevice, intent: { kind: 'ordinary' }, scope }),
).rejects.toMatchObject({
Expand Down
4 changes: 1 addition & 3 deletions packages/provider-limrun/src/app-log-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,9 +108,7 @@ export function createLimrunPlatformRuntimeOwner(
home: liveSessionUnavailable,
orientation: liveSessionUnavailable,
tvRemote: liveSessionUnavailable,
keyboardStatus: liveSessionUnavailable,
keyboardDismiss: liveSessionUnavailable,
keyboardEnter: liveSessionUnavailable,
keyboard: liveSessionUnavailable,
readClipboard: liveSessionUnavailable,
writeClipboard: liveSessionUnavailable,
appSwitcher: liveSessionUnavailable,
Expand Down
13 changes: 10 additions & 3 deletions packages/provider-limrun/src/interaction-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -347,9 +347,16 @@ export function limrunKeyboardOperationFacts(
device: DeviceInfo,
liveSessionUnavailable?: RuntimeOperationUnavailability,
) {
const cell =
liveSessionUnavailable ?? (device.platform === 'android' ? available : keyboardUnavailableIos);
// One denial covers the iOS leg and any session that is no longer live: neither serves a
// keyboard operation.
const unsupported = liveSessionUnavailable ?? keyboardUnavailableIos;
const androidLegServesKeyboard =
liveSessionUnavailable === undefined && device.platform === 'android';
return Object.freeze({
...keyboardRuntimeOperationFacts({ status: cell, dismiss: cell, enter: cell }),
...keyboardRuntimeOperationFacts(
androidLegServesKeyboard
? { unsupported, status: available, dismiss: available, enter: available }
: { unsupported },
),
});
}
8 changes: 8 additions & 0 deletions packages/provider-webdriver/src/platform-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,14 @@ test.each([
] as const) {
expect(facts.operations[operation].available).toBe(false);
}
// `keyboard` is one family cell, so the reason says which gap closed it: the dead session
// refuses with the session gap, a live session with no reachable interactor with this
// provider's own keyboard refusal.
for (const operation of ['keyboardStatus', 'keyboardDismiss', 'keyboardEnter'] as const) {
expect(facts.operations[operation]).toMatchObject({
reason: state.isSessionActive() ? 'unsupported-provider-mode' : 'owner-capability-missing',
});
}
if (state.isSessionActive()) {
const binding = await owner.bind({
device,
Expand Down
14 changes: 2 additions & 12 deletions packages/provider-webdriver/src/platform-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,6 @@ import { alertRuntimeOperationFacts } from '@agent-device/contracts/alert-runtim
import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime';
import { appSwitcherRuntimeOperationFacts } from '@agent-device/contracts/app-switcher-runtime';
import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime';
import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime';
import { orientationRuntimeOperationFacts } from '@agent-device/contracts/orientation-runtime';
import { tvRemoteRuntimeOperationFacts } from '@agent-device/contracts/tv-remote-runtime';
import {
Expand Down Expand Up @@ -519,9 +518,7 @@ function webDriverFacts(
home: inactiveSession,
orientation: inactiveSession,
tvRemote: inactiveSession,
keyboardStatus: inactiveSession,
keyboardDismiss: inactiveSession,
keyboardEnter: inactiveSession,
keyboard: inactiveSession,
readClipboard: inactiveSession,
writeClipboard: inactiveSession,
appSwitcher: inactiveSession,
Expand Down Expand Up @@ -565,9 +562,7 @@ function webDriverFacts(
home: homeUnavailable,
orientation: orientationUnavailable,
tvRemote: tvRemoteUnavailable,
keyboardStatus: keyboardUnavailable,
keyboardDismiss: keyboardUnavailable,
keyboardEnter: keyboardUnavailable,
keyboard: keyboardUnavailable,
readClipboard: clipboardUnavailable,
writeClipboard: clipboardUnavailable,
appSwitcher: appSwitcherUnavailable,
Expand Down Expand Up @@ -648,11 +643,6 @@ function webDriverFacts(
orientation: declared('orientation', orientationUnavailable),
}),
...tvRemoteRuntimeOperationFacts({ tvRemote: tvRemoteUnavailable }),
...keyboardRuntimeOperationFacts({
status: keyboardUnavailable,
dismiss: keyboardUnavailable,
enter: keyboardUnavailable,
}),
// Clipboard rides the same reachable interactor `back`/`home` do; the declared-capability
// gate stays inside the interactor, where it already lives.
//
Expand Down
Loading
Loading