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
37 changes: 35 additions & 2 deletions packages/contracts/src/clipboard-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,18 +21,51 @@ const device = {
const local = (resolveInteractor: LocalInteractorOperationResolver) =>
localInteractorSource({ device, resolveInteractor });

test('builds the exact clipboard operation fact catalog', () => {
test('builds the exact clipboard operation fact catalog for an owner that names both halves', () => {
const read = { available: true } as const;
const write = {
available: false,
reason: 'owner-capability-missing',
} as const;
expect(clipboardRuntimeOperationFacts({ read, write })).toEqual({
expect(
clipboardRuntimeOperationFacts({
unsupported: write,
read,
write,
}),
).toEqual({
readClipboard: read,
writeClipboard: write,
});
});

test('a half the owner never names reports the denial the owner stated for the family, verbatim — omission is a classified refusal, never an unclassified half and never an implied success', () => {
const denial = {
available: false,
reason: 'unsupported-platform-leaf',
hint: 'clipboard is not supported on Vega OS.',
} as const;

expect(
clipboardRuntimeOperationFacts({ unsupported: denial, read: { available: true } }),
).toEqual({
readClipboard: { available: true },
writeClipboard: denial,
});
});

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

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

expect(facts).toEqual({
readClipboard: denial,
writeClipboard: denial,
});
expect(Object.isFrozen(facts)).toBe(true);
});

test('a local read binding returns the interactor pasteboard text verbatim', async () => {
const readClipboard = vi.fn(async () => 'copied\ntext');
const resolveInteractor = vi.fn(async () => ({ readClipboard }) as unknown as Interactor);
Expand Down
30 changes: 24 additions & 6 deletions packages/contracts/src/clipboard-runtime.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Interactor, 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';

/**
Expand Down Expand Up @@ -41,14 +41,32 @@ export type ClipboardRuntimeOperationFacts = Readonly<{
}>;

/**
* Read and write are separate cells because an owner can genuinely have one without the other —
* a WebDriver provider whose Appium clipboard extension exposes only a getter is the real case —
* and `clipboard read` must not be refused because the write half is missing.
* What an owner declares about the clipboard. Read and write stay separate cells because an owner
* can genuinely have one without the other — a WebDriver provider whose Appium clipboard extension
* exposes only a getter is the real case — and `clipboard read` must not be refused because the
* write half is missing.
*
* Both halves ride one shell command set on every other owner, so an owner with neither names
* `unsupported` once and a half it never names reports that denial verbatim: omission is a
* classified refusal, never an unclassified half and never an implied success. An owner states a
* half only to say something the family denial does not.
*/
export type ClipboardRuntimeOperationFactsInput = Readonly<{
unsupported: RuntimeOperationUnavailability;
read?: RuntimeOperationFact;
write?: RuntimeOperationFact;
}>;

/** Builds the exhaustive owner claims for the two clipboard operations. */
export function clipboardRuntimeOperationFacts(
input: Readonly<{ read: RuntimeOperationFact; write: RuntimeOperationFact }>,
input: ClipboardRuntimeOperationFactsInput,
): ClipboardRuntimeOperationFacts {
return Object.freeze({ readClipboard: input.read, writeClipboard: input.write });
const declared = (fact: RuntimeOperationFact | undefined): RuntimeOperationFact =>
fact ?? input.unsupported;
return Object.freeze({
readClipboard: declared(input.read),
writeClipboard: declared(input.write),
});
}

/**
Expand Down
9 changes: 7 additions & 2 deletions packages/contracts/src/platform-runtime-unavailable.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,8 +48,7 @@ const UNAVAILABLE_FACTS: UnavailablePlatformRuntimeFacts = {
orientation: { available: false, reason: 'unsupported-provider-mode' },
tvRemote: { 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' },
clipboard: { available: false, reason: 'unsupported-provider-mode' },
appSwitcher: { available: false, reason: 'unsupported-provider-mode' },
triggerAppEvent: { available: false, reason: 'unsupported-provider-mode' },
setSetting: { available: false, reason: 'unsupported-provider-mode' },
Expand Down Expand Up @@ -96,6 +95,12 @@ test('generic unavailable binding preserves exact provider ownership and mode',
reason: 'unsupported-provider-mode',
});
}
for (const operation of ['readClipboard', 'writeClipboard'] 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
11 changes: 3 additions & 8 deletions packages/contracts/src/platform-runtime-unavailable.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,8 +62,7 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{
orientation: RuntimeOperationUnavailability;
tvRemote: RuntimeOperationUnavailability;
keyboard: RuntimeOperationUnavailability;
readClipboard: RuntimeOperationUnavailability;
writeClipboard: RuntimeOperationUnavailability;
clipboard: RuntimeOperationUnavailability;
appSwitcher: RuntimeOperationUnavailability;
triggerAppEvent: RuntimeOperationUnavailability;
setSetting: RuntimeOperationUnavailability;
Expand Down Expand Up @@ -118,8 +117,7 @@ const UNAVAILABLE_CELLS = {
orientation: true,
tvRemote: true,
keyboard: true,
readClipboard: true,
writeClipboard: true,
clipboard: true,
appSwitcher: true,
triggerAppEvent: true,
setSetting: true,
Expand Down Expand Up @@ -239,10 +237,7 @@ export function createUnavailablePlatformRuntimeFacts(
...orientationRuntimeOperationFacts({ orientation: frozen.orientation }),
...tvRemoteRuntimeOperationFacts({ tvRemote: frozen.tvRemote }),
...keyboardRuntimeOperationFacts({ unsupported: frozen.keyboard }),
...clipboardRuntimeOperationFacts({
read: frozen.readClipboard,
write: frozen.writeClipboard,
}),
...clipboardRuntimeOperationFacts({ unsupported: frozen.clipboard }),
...appSwitcherRuntimeOperationFacts({ appSwitcher: frozen.appSwitcher }),
...appEventRuntimeOperationFacts({ triggerAppEvent: frozen.triggerAppEvent }),
...settingsRuntimeOperationFacts({ setSetting: frozen.setSetting }),
Expand Down
6 changes: 5 additions & 1 deletion packages/platform-android/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,11 @@ export function createAndroidPlatformRuntime(host: PlatformRuntimeHost): Platfor
}),
// Read and write share one cell: `cmd clipboard` either has a shell implementation on this
// build or it has none, and no Android build ships one half of it.
...clipboardRuntimeOperationFacts({ read: clipboardCell, write: clipboardCell }),
...clipboardRuntimeOperationFacts({
unsupported: clipboardCell.available ? clipboardShellUnavailable : clipboardCell,
read: clipboardCell,
write: clipboardCell,
}),
...audioProbeRuntimeOperationFacts({
capture: androidAudioProbeCaptureFact(device),
query: audioQueryUnavailable,
Expand Down
20 changes: 18 additions & 2 deletions packages/platform-apple/src/system/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@ import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboar
import { settingsRuntimeOperationFacts } from '@agent-device/contracts/settings-runtime';
import { bindAdmittedLocalInteractorOperations } from '@agent-device/contracts/interactor-operation-catalog';
import type { PlatformRuntimeHost } from '@agent-device/contracts/platform-runtime-operations';
import type { RuntimeOperationFact } from '@agent-device/contracts/platform-runtime';
import type {
RuntimeOperationFact,
RuntimeOperationUnavailability,
} from '@agent-device/contracts/platform-runtime';
import { resolveDeviceAppleOs, type DeviceInfo } from '@agent-device/kernel/device';

const available = Object.freeze({ available: true } as const);
Expand Down Expand Up @@ -113,14 +116,27 @@ function appleAppEventFact(device: DeviceInfo): RuntimeOperationFact {
return resolveDeviceAppleOs(device) === 'watchos' ? appleWatchOsUnavailable : available;
}

/**
* The clipboard denial this leaf reports for a half it does not name: the leaf's own refusal where
* it has one, since a leaf that serves clipboard today has no clipboard refusal to state.
*/
function appleClipboardFamilyUnavailable(device: DeviceInfo): RuntimeOperationUnavailability {
const cell = appleClipboardFact(device);
return cell.available ? clipboardLeafUnavailable : cell;
}

/** The system-surface cells: clipboard read/write, app-event delivery, settings, and alerts. */
export function appleSystemFacts(device: DeviceInfo) {
const clipboard = appleClipboardFact(device);
// The four alert legs share one cell: an Apple leaf whose backend can read an alert can also
// press its buttons, so splitting them would invent a cell no Apple owner is ever in.
const alert = appleAlertFact(device);
return Object.freeze({
...clipboardRuntimeOperationFacts({ read: clipboard, write: clipboard }),
...clipboardRuntimeOperationFacts({
unsupported: appleClipboardFamilyUnavailable(device),
read: clipboard,
write: clipboard,
}),
...alertRuntimeOperationFacts({ read: alert, wait: alert, accept: alert, dismiss: alert }),
...appEventRuntimeOperationFacts({ triggerAppEvent: appleAppEventFact(device) }),
...settingsRuntimeOperationFacts({
Expand Down
5 changes: 1 addition & 4 deletions packages/platform-harmonyos/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -296,10 +296,7 @@ export function createHarmonyPlatformRuntime(host: PlatformRuntimeHost): Platfor
enter: harmonyFocusFact(device),
}),
// HarmonyOS exposes no clipboard automation operation.
...clipboardRuntimeOperationFacts({
read: harmonyPlatformLeafUnavailable,
write: harmonyPlatformLeafUnavailable,
}),
...clipboardRuntimeOperationFacts({ unsupported: harmonyPlatformLeafUnavailable }),
...audioProbeRuntimeOperationFacts({
capture: audioProbeUnavailable,
query: audioProbeUnavailable,
Expand Down
4 changes: 2 additions & 2 deletions packages/platform-linux/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,8 +196,7 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts<PlatformRuntimeOperations>
home: homeKindUnavailable,
orientation: linuxPlatformLeafUnavailable,
tvRemote: linuxPlatformLeafUnavailable,
readClipboard: clipboardKindUnavailable,
writeClipboard: clipboardKindUnavailable,
clipboard: clipboardKindUnavailable,
// The Linux interactor's own `appSwitcher` throws unsupported, and the retired descriptor
// declared `linux: {}`, so no Linux cell was ever admitted.
appSwitcher: linuxPlatformLeafUnavailable,
Expand Down Expand Up @@ -269,6 +268,7 @@ function linuxFacts(device: DeviceInfo): RuntimeFacts<PlatformRuntimeOperations>
// Parity with the retired `clipboard` capability bucket (`{ device: true }`): wl-clipboard
// / xclip / xsel drive the desktop session's selection, and no other Linux cell has one.
...clipboardRuntimeOperationFacts({
unsupported: clipboardKindUnavailable,
read: linuxDesktopFact(device, clipboardKindUnavailable),
write: linuxDesktopFact(device, clipboardKindUnavailable),
}),
Expand Down
3 changes: 1 addition & 2 deletions packages/platform-vega/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -191,8 +191,7 @@ function vegaFacts(device: DeviceInfo): RuntimeFacts<PlatformRuntimeOperations>
home: homeUnavailable,
orientation: orientationUnavailable,
tvRemote: tvRemoteUnavailable,
readClipboard: clipboardUnavailable,
writeClipboard: clipboardUnavailable,
clipboard: clipboardUnavailable,
appSwitcher: appSwitcherUnavailable,
triggerAppEvent: appEventUnavailable,
setSetting: settingsUnavailable,
Expand Down
5 changes: 1 addition & 4 deletions packages/platform-web/src/runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -413,10 +413,7 @@ function webRuntimeFacts(
...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({
read: navigationUnavailable,
write: navigationUnavailable,
}),
...clipboardRuntimeOperationFacts({ unsupported: navigationUnavailable }),
// Parity with the retired `WEB_QUERY_COMMANDS` graft, which admitted `audio` on every web
// device; the provider that carries no probe transport still refuses at execution.
...audioProbeRuntimeOperationFacts({ capture: audioCaptureUnavailable, query: available }),
Expand Down
3 changes: 1 addition & 2 deletions packages/provider-limrun/src/app-log-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,8 +109,7 @@ export function createLimrunPlatformRuntimeOwner(
orientation: liveSessionUnavailable,
tvRemote: liveSessionUnavailable,
keyboard: liveSessionUnavailable,
readClipboard: liveSessionUnavailable,
writeClipboard: liveSessionUnavailable,
clipboard: liveSessionUnavailable,
appSwitcher: liveSessionUnavailable,
triggerAppEvent: liveSessionUnavailable,
setSetting: liveSessionUnavailable,
Expand Down
25 changes: 22 additions & 3 deletions packages/provider-limrun/src/interaction-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ const gestureUnsupportedProviderMode = Object.freeze({
available: false,
reason: 'unsupported-provider-mode',
} as const);
/** What an unnamed clipboard half reports on a live Limrun session. */
const clipboardUnsupportedProviderMode = Object.freeze({
available: false,
reason: 'unsupported-provider-mode',
} as const);
const androidTvMultiTouchUnavailable = Object.freeze({
available: false,
reason: 'unsupported-platform-leaf',
Expand Down Expand Up @@ -267,9 +272,23 @@ export function limrunClipboardOperationFacts(
device: DeviceInfo,
liveSessionUnavailable?: RuntimeOperationUnavailability,
) {
const cell =
liveSessionUnavailable ?? (device.platform === 'android' ? available : clipboardUnavailableIos);
return Object.freeze({ ...clipboardRuntimeOperationFacts({ read: cell, write: cell }) });
if (liveSessionUnavailable) {
return Object.freeze({
...clipboardRuntimeOperationFacts({ unsupported: liveSessionUnavailable }),
});
}
if (device.platform !== 'android') {
return Object.freeze({
...clipboardRuntimeOperationFacts({ unsupported: clipboardUnavailableIos }),
});
}
return Object.freeze({
...clipboardRuntimeOperationFacts({
unsupported: clipboardUnsupportedProviderMode,
read: available,
write: available,
}),
});
}

const alertUnavailableIos = Object.freeze({
Expand Down
7 changes: 3 additions & 4 deletions packages/provider-webdriver/src/platform-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -519,8 +519,7 @@ function webDriverFacts(
orientation: inactiveSession,
tvRemote: inactiveSession,
keyboard: inactiveSession,
readClipboard: inactiveSession,
writeClipboard: inactiveSession,
clipboard: inactiveSession,
appSwitcher: inactiveSession,
triggerAppEvent: inactiveSession,
setSetting: inactiveSession,
Expand Down Expand Up @@ -563,8 +562,7 @@ function webDriverFacts(
orientation: orientationUnavailable,
tvRemote: tvRemoteUnavailable,
keyboard: keyboardUnavailable,
readClipboard: clipboardUnavailable,
writeClipboard: clipboardUnavailable,
clipboard: clipboardUnavailable,
appSwitcher: appSwitcherUnavailable,
triggerAppEvent: appEventUnavailable,
setSetting: settingsUnavailable,
Expand Down Expand Up @@ -653,6 +651,7 @@ function webDriverFacts(
// Appium — which does expose the clipboard extension. The refusal moves to where it can be
// true: the interactor, per session.
...clipboardRuntimeOperationFacts({
unsupported: clipboardUnavailable,
read: declared('clipboard.read', clipboardUnavailable),
write: declared('clipboard.write', clipboardUnavailable),
}),
Expand Down
7 changes: 3 additions & 4 deletions src/__tests__/test-utils/runtime-operation-facts.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime';
import { audioProbeRuntimeOperationFacts } from '@agent-device/contracts/audio-probe-runtime';
import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime';
import { elementTextRuntimeOperationFacts } from '@agent-device/contracts/element-text-runtime';
import { gestureRuntimeOperationFacts } from '@agent-device/contracts/gesture-runtime';
import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime';
Expand Down Expand Up @@ -60,8 +61,7 @@ export const unavailableDeploymentSnapshotAndShutdownOperationFacts = Object.fre
setOrientation: unavailable,
tvRemote: unavailable,
...keyboardRuntimeOperationFacts({ unsupported: unavailable }),
readClipboard: unavailable,
writeClipboard: unavailable,
...clipboardRuntimeOperationFacts({ unsupported: unavailable }),
appSwitcher: unavailable,
triggerAppEvent: unavailable,
setSetting: unavailable,
Expand Down Expand Up @@ -119,8 +119,7 @@ export function createUnavailableRuntimeFactsForTest(
orientation: fact,
tvRemote: fact,
keyboard: fact,
readClipboard: fact,
writeClipboard: fact,
clipboard: fact,
appSwitcher: fact,
triggerAppEvent: fact,
setSetting: fact,
Expand Down
4 changes: 2 additions & 2 deletions src/daemon/handlers/__tests__/install-source.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
} from '@agent-device/contracts/platform-runtime';
import { audioProbeRuntimeOperationFacts } from '@agent-device/contracts/audio-probe-runtime';
import { perfRuntimeOperationFacts } from '@agent-device/contracts/perf-runtime';
import { clipboardRuntimeOperationFacts } from '@agent-device/contracts/clipboard-runtime';
import { gestureRuntimeOperationFacts } from '@agent-device/contracts/gesture-runtime';
import { keyboardRuntimeOperationFacts } from '@agent-device/contracts/keyboard-runtime';
import type { PlatformRuntimeOperations } from '@agent-device/contracts/platform-runtime-operations';
Expand Down Expand Up @@ -377,8 +378,7 @@ function sourceRuntimeFacts(
setOrientation: unavailable,
tvRemote: unavailable,
...keyboardRuntimeOperationFacts({ unsupported: unavailable }),
readClipboard: unavailable,
writeClipboard: unavailable,
...clipboardRuntimeOperationFacts({ unsupported: unavailable }),
appSwitcher: unavailable,
triggerAppEvent: unavailable,
setSetting: unavailable,
Expand Down
7 changes: 4 additions & 3 deletions src/daemon/handlers/__tests__/session-clipboard.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,10 @@ function harness(
const writeClipboard = vi.fn(async () => undefined);
const runtimeFacts: RuntimeFacts<PlatformRuntimeOperations> = {
device: { ...deviceShape(device), providerMode: 'local' },
operations: clipboardRuntimeOperationFacts(
facts,
) as RuntimeFacts<PlatformRuntimeOperations>['operations'],
operations: clipboardRuntimeOperationFacts({
unsupported: unavailable,
...facts,
}) as RuntimeFacts<PlatformRuntimeOperations>['operations'],
};
const binding = {
device,
Expand Down
Loading
Loading