From 2afe18d34e8136ef3dc5599b92e8103bb603aeab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 20 Sep 2026 16:03:14 +0200 Subject: [PATCH 1/9] test(coverage): recount the published summaries instead of pinning them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Each platform coverage manifest already derives its classification summary from its own rows, and the smoke test that owns the manifest then compared that derived value to a hand-incremented literal. Adding one public command therefore cost five files whose only edit was `54` becoming `55`, while the invariant those files actually protect — every public command is classified, and the published rollup is the manifest's own — was asserted nowhere. `assertCoverageClassificationSummaryDerivedFromManifest` now recounts the manifest independently of the builder, ties `total` to the public catalog's size, and checks the buckets still sum. Parity was verified against the deleted literals before removal, as ADR 0008 requires: macOS 22/15/18/55, web 42/1/12/55, Linux 29/9/17/55, tvOS 17/38/0/55, Android 14/0/41/55, all equal to the derived rollup at this head. The web lane's `liveCommands.length === 12` goes the same way: the loop beneath it already proves each live claim is executed by the smoke scenario, so a count proved only that the list was not empty. A planted mis-bucketed summary and a planted denominator drift both fail, naming the platform. --- .../smoke-android-emulator-coverage.test.ts | 18 ++++---- test/integration/smoke-linux-coverage.test.ts | 24 ++++------ test/integration/smoke-macos-coverage.test.ts | 20 ++++---- .../smoke-tvos-platform-coverage.test.ts | 13 +++--- .../smoke-web-platform-coverage.test.ts | 17 +++---- .../support/coverage-classification.ts | 46 +++++++++++++++++++ 6 files changed, 89 insertions(+), 49 deletions(-) diff --git a/test/integration/smoke-android-emulator-coverage.test.ts b/test/integration/smoke-android-emulator-coverage.test.ts index c6f3245f0b..bc3576ab07 100644 --- a/test/integration/smoke-android-emulator-coverage.test.ts +++ b/test/integration/smoke-android-emulator-coverage.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import test from 'node:test'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; +import { assertCoverageClassificationSummaryDerivedFromManifest } from './support/coverage-classification.ts'; import { ANDROID_EMULATOR_BEHAVIOR_COVERAGE } from './android-emulator-e2e/behavior-coverage.ts'; import { ANDROID_PERMISSION_PROMPT_COMMAND } from './android-emulator-e2e/live-lifecycle-scenario.ts'; import { @@ -19,8 +20,9 @@ import { selectAndroidEmulatorScenarios, } from './android-emulator-e2e/scenarios.ts'; +const publicCommands = Object.values(PUBLIC_COMMANDS).sort(); + test('Android emulator coverage exhaustively classifies the public catalog', () => { - const publicCommands = Object.values(PUBLIC_COMMANDS).sort(); assert.deepEqual(Object.keys(ANDROID_EMULATOR_E2E_COVERAGE).sort(), publicCommands); for (const command of publicCommands) { const entry = ANDROID_EMULATOR_E2E_COVERAGE[command]; @@ -38,14 +40,12 @@ test('Android emulator coverage exhaustively classifies the public catalog', () }); test('Android coverage report summary accounts for every manifest classification', () => { - const summary = ANDROID_EMULATOR_COVERAGE_CLASSIFICATION_SUMMARY; - assert.deepEqual(summary, { - contract: 14, - gap: 0, - live: 41, - total: 55, - }); - assert.equal(summary.live + summary.contract + summary.gap, summary.total); + assertCoverageClassificationSummaryDerivedFromManifest( + 'Android emulator', + ANDROID_EMULATOR_E2E_COVERAGE, + ANDROID_EMULATOR_COVERAGE_CLASSIFICATION_SUMMARY, + publicCommands, + ); }); test('Android live command ownership is structural and exhaustive', () => { diff --git a/test/integration/smoke-linux-coverage.test.ts b/test/integration/smoke-linux-coverage.test.ts index 4c1c2e829c..e0186b5a0d 100644 --- a/test/integration/smoke-linux-coverage.test.ts +++ b/test/integration/smoke-linux-coverage.test.ts @@ -5,6 +5,7 @@ import test from 'node:test'; import { parseReplayScriptDetailed } from '@agent-device/ad-script'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; +import { assertCoverageClassificationSummaryDerivedFromManifest } from './support/coverage-classification.ts'; import { LINUX_COVERAGE_GAP_ISSUE, LINUX_COMMAND_EVIDENCE, @@ -42,20 +43,15 @@ test('Linux coverage exhaustively classifies the public catalog', () => { }); test('Linux coverage report has the expected classification counts', () => { - assert.deepEqual(LINUX_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, { - // focus (#1925), click, and type remain live via the existing replay. The separate - // command-evidence lane adds nine generic-command rows without changing that replay. Artifact - // inventory remains a gap because local Linux screenshot paths are not daemon-downloadable. - // Keyboard, orientation and tv-remote were already fact-owned command-contract rows rather - // than catalog denials; R56 moves app-switcher the same way, for the same reason. - contract: 29, - gap: 9, - live: 17, - total: 55, - }); - - const { contract, gap, live, total } = LINUX_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY; - assert.equal(contract + gap + live, total); + // Focus (#1925), click, and type are live through the replay; the separate command-evidence + // lane adds generic-command rows without changing it. Artifact inventory stays a gap because + // local Linux screenshot paths are not daemon-downloadable. + assertCoverageClassificationSummaryDerivedFromManifest( + 'Linux', + LINUX_PLATFORM_COVERAGE, + LINUX_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, + publicCommands, + ); }); test('Linux live claims reference commands in the existing smoke replay', () => { diff --git a/test/integration/smoke-macos-coverage.test.ts b/test/integration/smoke-macos-coverage.test.ts index d084550299..36e13685ce 100644 --- a/test/integration/smoke-macos-coverage.test.ts +++ b/test/integration/smoke-macos-coverage.test.ts @@ -5,6 +5,7 @@ import test from 'node:test'; import { mkdtempForTest } from '../../src/__tests__/test-utils/tmp-dir.ts'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; +import { assertCoverageClassificationSummaryDerivedFromManifest } from './support/coverage-classification.ts'; import { MACOS_COVERAGE_GAP_ISSUE, MACOS_LIVE_SCENARIOS, @@ -14,8 +15,9 @@ import { } from './macos-e2e/coverage.ts'; import { writeCoverageReport } from './macos-e2e/coverage-report.ts'; +const publicCommands = Object.values(PUBLIC_COMMANDS).sort(); + test('macOS coverage exhaustively classifies the public catalog', () => { - const publicCommands = Object.values(PUBLIC_COMMANDS).sort(); assert.deepEqual(Object.keys(MACOS_PLATFORM_COVERAGE).sort(), publicCommands); for (const command of publicCommands) { @@ -38,17 +40,11 @@ test('macOS coverage exhaustively classifies the public catalog', () => { }); test('macOS coverage report counts every manifest classification', () => { - assert.deepEqual(MACOS_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, { - contract: 22, - gap: 15, - live: 18, - total: 55, - }); - assert.equal( - MACOS_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY.live + - MACOS_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY.contract + - MACOS_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY.gap, - MACOS_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY.total, + assertCoverageClassificationSummaryDerivedFromManifest( + 'macOS', + MACOS_PLATFORM_COVERAGE, + MACOS_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, + publicCommands, ); }); diff --git a/test/integration/smoke-tvos-platform-coverage.test.ts b/test/integration/smoke-tvos-platform-coverage.test.ts index 8040d8e153..815c6aef9d 100644 --- a/test/integration/smoke-tvos-platform-coverage.test.ts +++ b/test/integration/smoke-tvos-platform-coverage.test.ts @@ -5,6 +5,7 @@ import test from 'node:test'; import { TVOS_SIMULATOR } from '../../src/__tests__/test-utils/device-fixtures.ts'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; +import { assertCoverageClassificationSummaryDerivedFromManifest } from './support/coverage-classification.ts'; import { createPlatformRuntimeGateway } from '../../src/platform-runtime.ts'; import { gestureRefusalMessage } from '@agent-device/contracts/gesture-admission'; import { @@ -39,12 +40,12 @@ test('tvOS coverage exhaustively classifies the public catalog', () => { }); test('tvOS coverage report has the expected classification counts', () => { - assert.deepEqual(TVOS_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, { - contract: 17, - gap: 38, - live: 0, - total: 55, - }); + assertCoverageClassificationSummaryDerivedFromManifest( + 'tvOS', + TVOS_PLATFORM_COVERAGE, + TVOS_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, + publicCommands, + ); }); test('tvOS contract claims name existing executable evidence', () => { diff --git a/test/integration/smoke-web-platform-coverage.test.ts b/test/integration/smoke-web-platform-coverage.test.ts index 7ae0448201..933deb789c 100644 --- a/test/integration/smoke-web-platform-coverage.test.ts +++ b/test/integration/smoke-web-platform-coverage.test.ts @@ -5,6 +5,7 @@ import test from 'node:test'; import { mkdtempForTest } from '../../src/__tests__/test-utils/tmp-dir.ts'; import { PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; +import { assertCoverageClassificationSummaryDerivedFromManifest } from './support/coverage-classification.ts'; import { WEB_COVERAGE_GAP_ISSUE, WEB_PLATFORM_COVERAGE, @@ -37,13 +38,13 @@ test('web coverage exhaustively classifies the public catalog', () => { } }); -test('web coverage report has the expected classification counts', () => { - assert.deepEqual(WEB_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, { - contract: 42, - gap: 1, - live: 12, - total: 55, - }); +test('web coverage report counts every manifest classification', () => { + assertCoverageClassificationSummaryDerivedFromManifest( + 'web', + WEB_PLATFORM_COVERAGE, + WEB_PLATFORM_COVERAGE_CLASSIFICATION_SUMMARY, + publicCommands, + ); }); test('web live claims reference commands in the existing smoke scenario', () => { @@ -51,7 +52,7 @@ test('web live claims reference commands in the existing smoke scenario', () => assert.ok(smokeSource.includes(WEB_SMOKE_TEST_NAME)); const liveCommands = liveCommandsForWebSmoke(); - assert.equal(liveCommands.length, 12); + assert.ok(liveCommands.length > 0, 'web claims no live command at all'); for (const command of liveCommands) { assert.equal( smokeSource.includes(`'${command}',`), diff --git a/test/integration/support/coverage-classification.ts b/test/integration/support/coverage-classification.ts index a74594bd7e..5d56ee11c1 100644 --- a/test/integration/support/coverage-classification.ts +++ b/test/integration/support/coverage-classification.ts @@ -1,3 +1,5 @@ +import assert from 'node:assert/strict'; + export type CoverageClassificationLevel = 'live' | 'command-contract' | 'known-gap'; export type CoverageClassificationSummary = { @@ -7,6 +9,50 @@ export type CoverageClassificationSummary = { total: number; }; +type CoverageBucket = 'live' | 'contract' | 'gap'; + +const BUCKET_BY_LEVEL: Readonly> = { + live: 'live', + 'command-contract': 'contract', + 'known-gap': 'gap', +}; + +/** + * Proves a published summary is the manifest's own rollup rather than a table kept aligned by + * hand. The recount here is deliberately independent of {@link buildCoverageClassificationSummary}, + * and the denominator is the public command catalog, which the manifest's key set is asserted + * against separately. A platform that wires its summary to the wrong array, or a manifest that + * drops a command, fails on the line that owns the mistake. + */ +export function assertCoverageClassificationSummaryDerivedFromManifest( + platform: string, + manifest: Readonly>, + summary: CoverageClassificationSummary, + publicCommands: readonly string[], +): void { + const entries = Object.values(manifest); + const recounted: CoverageClassificationSummary = { + contract: 0, + gap: 0, + live: 0, + total: entries.length, + }; + for (const entry of entries) { + recounted[BUCKET_BY_LEVEL[entry.level]] += 1; + } + assert.deepEqual(summary, recounted, `${platform} coverage summary is not its manifest's rollup`); + assert.equal( + summary.total, + publicCommands.length, + `${platform} coverage summary counts ${summary.total} commands, the public catalog has ${publicCommands.length}`, + ); + assert.equal( + summary.live + summary.contract + summary.gap, + summary.total, + `${platform} coverage summary buckets do not sum to its total`, + ); +} + export function buildCoverageClassificationSummary( entries: readonly { level: CoverageClassificationLevel }[], ): CoverageClassificationSummary { From 6a48ca63a882d0a541e4a4620cc347dfd2733116 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 20 Sep 2026 16:03:19 +0200 Subject: [PATCH 2/9] test(daemon): read the generic route from the descriptor that declares it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The test enumerated eleven commands to prove that the daemon's fallthrough route is reached on purpose. Ten of those commands already declare the decision as `daemon.route: 'generic'`, which is where `getDaemonCommandRoute` reads it from, so the list was a second source of truth that every new generic command had to be added to. The expected set is now that trait, projected. What remains hand-written is the case the list was actually guarding — a catalog command that declares no daemon facet at all and lands on the `?? 'generic'` fallback — and it is asserted alone, with a message that names the declaration to add instead. Removing `viewport`'s facet locally fails with `['viewport', 'install-from-source']` against that one expected member. --- .../__tests__/request-handler-catalog.test.ts | 57 +++++++++++++------ 1 file changed, 40 insertions(+), 17 deletions(-) diff --git a/src/daemon/__tests__/request-handler-catalog.test.ts b/src/daemon/__tests__/request-handler-catalog.test.ts index 3f1c6bdd22..a4eeb596cb 100644 --- a/src/daemon/__tests__/request-handler-catalog.test.ts +++ b/src/daemon/__tests__/request-handler-catalog.test.ts @@ -4,6 +4,7 @@ import path from 'node:path'; import { test } from 'vitest'; import { withTestDeviceInventoryProvider as withTargetDeviceResolutionScope } from '../../__tests__/test-utils/device-inventory-gateways.ts'; import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '@agent-device/command-registry/catalog'; +import { commandDescriptors } from '@agent-device/command-registry/registry'; import { isRequestCanceledError, type AppError } from '@agent-device/kernel/errors'; import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; import { getDaemonCommandRoute, type DaemonCommandRoute } from '../daemon-command-registry.ts'; @@ -52,26 +53,48 @@ test('specialized daemon routes are claimed by their handler chain', async () => } }); +// The generic route is the router's fallthrough, so it has two ways in: a descriptor that declares +// `daemon.route: 'generic'`, and a catalog command that declares no daemon facet at all and lands +// on the `?? 'generic'` fallback. The first is a decision read from the declaration; the second is +// what this test exists to keep rare, so it is enumerated alone and by name. +const CATALOG_COMMANDS = [...Object.values(PUBLIC_COMMANDS), ...Object.values(INTERNAL_COMMANDS)]; + +function declaredDaemonTraitCommands(): string[] { + return commandDescriptors + .filter((descriptor) => 'daemon' in descriptor && descriptor.daemon !== undefined) + .map((descriptor) => descriptor.name); +} + +function genericByTraitCommands(): string[] { + return commandDescriptors + .filter((descriptor) => 'daemon' in descriptor && descriptor.daemon?.route === 'generic') + .map((descriptor) => descriptor.name); +} + +function localCliCommands(): string[] { + return commandDescriptors + .filter((descriptor) => 'catalog' in descriptor && descriptor.catalog?.group === 'local-cli') + .map((descriptor) => descriptor.name); +} + test('catalog commands use generic routing only when intentionally passthrough or projected', () => { + const genericByAbsence = CATALOG_COMMANDS.filter( + (command) => + !declaredDaemonTraitCommands().includes(command) && !localCliCommands().includes(command), + ); + assert.deepEqual( + genericByAbsence, + [PUBLIC_COMMANDS.installFromSource], + 'a catalog command reaches the generic route by declaring no daemon facet; declare daemon.route instead', + ); + const intentionalGenericCatalogCommands = [ - PUBLIC_COMMANDS.actionButton, - PUBLIC_COMMANDS.appSwitcher, - PUBLIC_COMMANDS.back, - PUBLIC_COMMANDS.focus, - PUBLIC_COMMANDS.home, - PUBLIC_COMMANDS.installFromSource, - PUBLIC_COMMANDS.orientation, - PUBLIC_COMMANDS.screenshot, - PUBLIC_COMMANDS.scroll, - PUBLIC_COMMANDS.tvRemote, - PUBLIC_COMMANDS.viewport, + ...genericByTraitCommands(), + ...genericByAbsence, ].sort(); - const genericCatalogCommands = [ - ...Object.values(PUBLIC_COMMANDS), - ...Object.values(INTERNAL_COMMANDS), - ] - .filter((command) => getDaemonCommandRoute(command) === 'generic') - .sort(); + const genericCatalogCommands = CATALOG_COMMANDS.filter( + (command) => getDaemonCommandRoute(command) === 'generic', + ).sort(); assert.deepEqual(genericCatalogCommands, intentionalGenericCatalogCommands); }); From d6c0d169862291c112f65bc22dc1aec107be2734 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Sun, 20 Sep 2026 16:03:19 +0200 Subject: [PATCH 3/9] test(runtime-facts): fill the fail-closed test facts from the owner's helper `createUnavailableRuntimeFactsForTest` restated all twenty-seven fail-closed cells, plus the nine lifecycle cells, to say what `createFullyUnavailablePlatformRuntimeFacts` already builds: every cell unavailable for one reason. The two forms are equal because the input's `network` cell carries the same sentinel the omitted optional cells would have inherited. A new runtime cell therefore costs nothing here, which is the point of the fail-closed default: a test that overrides a cell states its own exception, and one that does not inherits the refusal without anyone remembering to write it down. --- .../test-utils/runtime-operation-facts.ts | 49 ++++--------------- 1 file changed, 9 insertions(+), 40 deletions(-) diff --git a/src/__tests__/test-utils/runtime-operation-facts.ts b/src/__tests__/test-utils/runtime-operation-facts.ts index 7562edab06..097ed48582 100644 --- a/src/__tests__/test-utils/runtime-operation-facts.ts +++ b/src/__tests__/test-utils/runtime-operation-facts.ts @@ -8,7 +8,10 @@ import type { RuntimeOperationUnavailability, RuntimeOwnerRef, } from '@agent-device/contracts/platform-runtime'; -import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable'; +import { + createFullyUnavailablePlatformRuntimeFacts, + createUnavailablePlatformRuntimeFacts, +} from '@agent-device/contracts/platform-runtime-unavailable'; import type { DeviceInfo } from '@agent-device/kernel/device'; import { screenshotRuntimeOperationFacts } from '@agent-device/contracts/screenshot-runtime'; import { scrollRuntimeOperationFacts } from '@agent-device/contracts/scroll-runtime'; @@ -104,43 +107,9 @@ export function createUnavailableRuntimeFactsForTest( owner: RuntimeOwnerRef, fact: RuntimeOperationUnavailability = unavailable, ) { - return createUnavailablePlatformRuntimeFacts(device, owner, { - appLog: fact, - network: fact, - screenshot: fact, - viewport: fact, - focus: fact, - gesture: fact, - scroll: fact, - typeText: fact, - touch: fact, - elementText: fact, - back: fact, - home: fact, - orientation: fact, - tvRemote: fact, - keyboard: fact, - clipboard: fact, - appSwitcher: fact, - actionButton: fact, - triggerAppEvent: fact, - setSetting: fact, - readAlert: fact, - awaitAlert: fact, - acceptAlert: fact, - dismissAlert: fact, - audioProbeCapture: fact, - audioProbeQuery: fact, - lifecycle: applicationLifecycleOperationFacts({ - resolveOpenTarget: fact, - prepareApplicationOpen: fact, - openApplication: fact, - applyRuntimeHints: fact, - clearRuntimeHints: fact, - closeApplication: fact, - finalizeApplicationClose: fact, - prepareAppleRunner: fact, - configureProviderPortReverse: fact, - }), - }); + return createUnavailablePlatformRuntimeFacts( + device, + owner, + createFullyUnavailablePlatformRuntimeFacts(fact), + ); } From 1fc31828a56dc910734f0afe8a8a892138ef617d Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 20 Sep 2026 18:35:12 +0000 Subject: [PATCH 4/9] refactor(system-button): declare home, app-switcher and action-button as one button family `home`, `app-switcher` and `action-button` are the same shape end to end: one press, no arguments, nothing returned, and only the set of owners that carry the control differs. The action-button PR (#2702) paid for that shape as if it were new: a contracts group with no facts helper, a required `Interactor` member every owner had to stub, a third identical daemon module with its own dispatcher arm and conformance row, and a refusal cell restated in every owner and every fixture that enumerates `RuntimeFacts`. One `system-button-runtime` contracts module now owns the family. Its facts helper takes the keyboard family's shape (`unsupported` plus optional per-button cells), so an owner with no such control states one denial and a button added to `SYSTEM_BUTTONS` is refused there without an edit; `UnavailablePlatformRuntimeFacts` carries one `systemButton` family cell the same way it carries `keyboard`. `Interactor.actionButton` is optional like `keyboardDismiss`: facts admit the press only where the interactor implements it, and the binder fails closed on a missing member instead of resolving it as a successful no-op, so the seven throwing stubs are gone. On the daemon side one table row per command replaces the three byte-identical modules, the three dispatcher arms and the three conformance rows; the conformance table and its test derive the command list from the registry. The keyboard family keeps its private method guard: the eager-closure gate holds that entry at its merge-base closure, and importing the shared guard would grow it by one module. Refusal hints for the buttons an owner does not carry now name the family rather than the one button (`Android has no key event for this system button.`); the command name already leads the UNSUPPORTED_OPERATION message. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014x96iYiFTbAwD4YEKU3PQ1 --- .../attempt-1/replay-timing.ndjson | 12 +- .../01-flow.yaml/attempt-1/result.txt | 2 +- .../attempt-1/replay-timing.ndjson | 12 +- .../02-flow.yaml/attempt-1/result.txt | 2 +- .../attempt-1/replay-timing.ndjson | 12 +- .../01-flow.yaml/attempt-1/result.txt | 2 +- .../attempt-1/replay-timing.ndjson | 12 +- .../02-flow.yaml/attempt-1/result.txt | 2 +- packages/contracts/package.json | 12 +- .../src/app-switcher-runtime.test.ts | 39 ---- .../contracts/src/app-switcher-runtime.ts | 50 ----- packages/contracts/src/home-runtime.test.ts | 37 ---- packages/contracts/src/home-runtime.ts | 47 ----- .../src/interactor-operation-binding.ts | 18 ++ .../src/interactor-operation-catalog.ts | 48 ++--- packages/contracts/src/interactor-types.ts | 9 +- .../src/platform-runtime-operations.ts | 38 +--- .../src/platform-runtime-unavailable.test.ts | 4 +- .../src/platform-runtime-unavailable.ts | 15 +- .../src/system-button-runtime.test.ts | 73 +++++++ .../contracts/src/system-button-runtime.ts | 89 ++++++++ packages/platform-android/src/runtime.test.ts | 4 +- packages/platform-android/src/runtime.ts | 25 +-- .../interactor-runner-provider.test.ts | 2 +- .../platform-apple/src/navigation/runtime.ts | 16 +- packages/platform-harmonyos/src/runtime.ts | 15 +- packages/platform-linux/src/runtime.ts | 16 +- .../__tests__/unsupported-interactor.test.ts | 1 - packages/platform-vega/src/runtime.test.ts | 14 +- packages/platform-vega/src/runtime.ts | 19 +- .../src/unsupported-interactor.ts | 1 - packages/platform-web/src/runtime.ts | 7 +- .../provider-limrun/src/app-log-runtime.ts | 9 +- packages/provider-limrun/src/facts-runtime.ts | 9 +- .../src/interaction-operations.test.ts | 56 +++-- .../src/interaction-operations.ts | 53 +++-- packages/provider-limrun/src/ios.ts | 7 - .../src/platform-runtime.ts | 29 ++- .../src/webdriver-interactor.ts | 18 -- .../test-utils/runtime-operation-facts.ts | 9 +- src/core/interactors/android.ts | 7 - src/core/interactors/harmonyos.ts | 1 - src/core/interactors/linux.ts | 3 - .../interactors/unsupported-interactor.ts | 1 - .../__tests__/app-switcher-runtime.test.ts | 25 --- src/daemon/__tests__/home-runtime.test.ts | 142 ------------- .../__tests__/runtime-binding-conformance.ts | 43 ++-- .../__tests__/system-button-runtime.test.ts | 198 ++++++++++++++++++ src/daemon/action-button-runtime.ts | 56 ----- src/daemon/app-switcher-runtime.ts | 51 ----- src/daemon/generic-runtime-execution.ts | 29 +-- .../handlers/__tests__/install-source.test.ts | 5 +- .../handlers/__tests__/session-state.test.ts | 8 +- src/daemon/home-runtime.ts | 51 ----- src/daemon/system-button-runtime.ts | 83 ++++++++ src/platform-runtime-gateway.test.ts | 8 +- 56 files changed, 701 insertions(+), 855 deletions(-) delete mode 100644 packages/contracts/src/app-switcher-runtime.test.ts delete mode 100644 packages/contracts/src/app-switcher-runtime.ts delete mode 100644 packages/contracts/src/home-runtime.test.ts delete mode 100644 packages/contracts/src/home-runtime.ts create mode 100644 packages/contracts/src/system-button-runtime.test.ts create mode 100644 packages/contracts/src/system-button-runtime.ts delete mode 100644 src/daemon/__tests__/app-switcher-runtime.test.ts delete mode 100644 src/daemon/__tests__/home-runtime.test.ts create mode 100644 src/daemon/__tests__/system-button-runtime.test.ts delete mode 100644 src/daemon/action-button-runtime.ts delete mode 100644 src/daemon/app-switcher-runtime.ts delete mode 100644 src/daemon/home-runtime.ts create mode 100644 src/daemon/system-button-runtime.ts diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson index c3de3c93c6..b02e6b1880 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/replay-timing.ndjson @@ -1,6 +1,6 @@ -{"type":"replay_test_attempt_start","ts":"2026-09-19T14:32:46.818Z","replayPath":"/tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-pzCEKx/01-flow.yaml","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:1:01-flow.yaml:attempt:1"} -{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:32:46.819Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":2} -{"type":"replay_test_finalize_start","ts":"2026-09-19T14:32:46.819Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} -{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:32:46.820Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":1} -{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:32:46.820Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} -{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:32:46.820Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_attempt_start","ts":"2026-09-20T14:13:02.623Z","replayPath":"/tmp/agent-device-test-run-5234-CnUBwE/agent-device-maestro-remote-test-Kcz7lm/01-flow.yaml","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:1:01-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-20T14:13:02.625Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":2} +{"type":"replay_test_finalize_start","ts":"2026-09-20T14:13:02.626Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-20T14:13:02.626Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-20T14:13:02.626Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-20T14:13:02.626Z","session":"default:test:req-maestro-test-wire-true:1-01-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt index 6cb55621a0..cd08310357 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/01-flow.yaml/attempt-1/result.txt @@ -1,4 +1,4 @@ -file: /tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-pzCEKx/01-flow.yaml +file: /tmp/agent-device-test-run-5234-CnUBwE/agent-device-maestro-remote-test-Kcz7lm/01-flow.yaml session: default:test:req-maestro-test-wire-true:1-01-flow:attempt-1 attempt: 1/1 status: passed diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson index 5d0e648b70..5ad010c1f9 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/replay-timing.ndjson @@ -1,6 +1,6 @@ -{"type":"replay_test_attempt_start","ts":"2026-09-19T14:32:46.820Z","replayPath":"/tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-pzCEKx/02-flow.yaml","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:2:02-flow.yaml:attempt:1"} -{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":1} -{"type":"replay_test_finalize_start","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} -{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":0} -{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} -{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:32:46.821Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_attempt_start","ts":"2026-09-20T14:13:02.628Z","replayPath":"/tmp/agent-device-test-run-5234-CnUBwE/agent-device-maestro-remote-test-Kcz7lm/02-flow.yaml","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-true:test:2:02-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-20T14:13:02.629Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":1} +{"type":"replay_test_finalize_start","ts":"2026-09-20T14:13:02.629Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-20T14:13:02.629Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-20T14:13:02.629Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-20T14:13:02.629Z","session":"default:test:req-maestro-test-wire-true:2-02-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt index e634dd45f4..f58f787df7 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt +++ b/.agent-device/test-artifacts/req-maestro-test-wire-true/02-flow.yaml/attempt-1/result.txt @@ -1,4 +1,4 @@ -file: /tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-pzCEKx/02-flow.yaml +file: /tmp/agent-device-test-run-5234-CnUBwE/agent-device-maestro-remote-test-Kcz7lm/02-flow.yaml session: default:test:req-maestro-test-wire-true:2-02-flow:attempt-1 attempt: 1/1 status: passed diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson index 4ae708cda6..56f112463e 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/replay-timing.ndjson @@ -1,6 +1,6 @@ -{"type":"replay_test_attempt_start","ts":"2026-09-19T14:32:46.823Z","replayPath":"/tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-Wnbhbp/01-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:1:01-flow.yaml:attempt:1"} -{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} -{"type":"replay_test_finalize_start","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} -{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":0} -{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} -{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:32:46.823Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_attempt_start","ts":"2026-09-20T14:13:02.633Z","replayPath":"/tmp/agent-device-test-run-5234-CnUBwE/agent-device-maestro-remote-test-YbCFeL/01-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:1:01-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-20T14:13:02.634Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"timedOut":false,"durationMs":1} +{"type":"replay_test_finalize_start","ts":"2026-09-20T14:13:02.634Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-20T14:13:02.634Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-20T14:13:02.634Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-20T14:13:02.634Z","session":"default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt index 69b4b78571..9c7301d670 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/01-flow.yaml/attempt-1/result.txt @@ -1,4 +1,4 @@ -file: /tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-Wnbhbp/01-flow.yaml +file: /tmp/agent-device-test-run-5234-CnUBwE/agent-device-maestro-remote-test-YbCFeL/01-flow.yaml session: default:test:req-maestro-test-wire-undefined:1-01-flow:attempt-1 attempt: 1/1 status: passed diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson index 9107c923bb..ee9ef7fe37 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/replay-timing.ndjson @@ -1,6 +1,6 @@ -{"type":"replay_test_attempt_start","ts":"2026-09-19T14:32:46.824Z","replayPath":"/tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-Wnbhbp/02-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:2:02-flow.yaml:attempt:1"} -{"type":"replay_test_attempt_stop","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":0} -{"type":"replay_test_finalize_start","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} -{"type":"replay_test_finalize_stop","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} -{"type":"replay_test_cleanup_start","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} -{"type":"replay_test_cleanup_stop","ts":"2026-09-19T14:32:46.824Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_attempt_start","ts":"2026-09-20T14:13:02.635Z","replayPath":"/tmp/agent-device-test-run-5234-CnUBwE/agent-device-maestro-remote-test-YbCFeL/02-flow.yaml","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","requestId":"req-maestro-test-wire-undefined:test:2:02-flow.yaml:attempt:1"} +{"type":"replay_test_attempt_stop","ts":"2026-09-20T14:13:02.636Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"timedOut":false,"durationMs":1} +{"type":"replay_test_finalize_start","ts":"2026-09-20T14:13:02.636Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} +{"type":"replay_test_finalize_stop","ts":"2026-09-20T14:13:02.636Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} +{"type":"replay_test_cleanup_start","ts":"2026-09-20T14:13:02.636Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1"} +{"type":"replay_test_cleanup_stop","ts":"2026-09-20T14:13:02.636Z","session":"default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1","ok":true,"durationMs":0} diff --git a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt index a3bc90a611..9dcdaceb84 100644 --- a/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt +++ b/.agent-device/test-artifacts/req-maestro-test-wire-undefined/02-flow.yaml/attempt-1/result.txt @@ -1,4 +1,4 @@ -file: /tmp/agent-device-test-run-75313-0YH0p7/agent-device-maestro-remote-test-Wnbhbp/02-flow.yaml +file: /tmp/agent-device-test-run-5234-CnUBwE/agent-device-maestro-remote-test-YbCFeL/02-flow.yaml session: default:test:req-maestro-test-wire-undefined:2-02-flow:attempt-1 attempt: 1/1 status: passed diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 0e5cb206e9..45f921456c 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -64,10 +64,6 @@ "types": "./src/app-state-runtime.ts", "default": "./src/app-state-runtime.ts" }, - "./app-switcher-runtime": { - "types": "./src/app-switcher-runtime.ts", - "default": "./src/app-switcher-runtime.ts" - }, "./apple-runner-request": { "types": "./src/apple-runner-request.ts", "default": "./src/apple-runner-request.ts" @@ -220,10 +216,6 @@ "types": "./src/gesture-runtime.ts", "default": "./src/gesture-runtime.ts" }, - "./home-runtime": { - "types": "./src/home-runtime.ts", - "default": "./src/home-runtime.ts" - }, "./host-diagnostics": { "types": "./src/host-diagnostics.ts", "default": "./src/host-diagnostics.ts" @@ -452,6 +444,10 @@ "types": "./src/startup-recovery-fence.ts", "default": "./src/startup-recovery-fence.ts" }, + "./system-button-runtime": { + "types": "./src/system-button-runtime.ts", + "default": "./src/system-button-runtime.ts" + }, "./tap-keyboard-occlusion": { "types": "./src/tap-keyboard-occlusion.ts", "default": "./src/tap-keyboard-occlusion.ts" diff --git a/packages/contracts/src/app-switcher-runtime.test.ts b/packages/contracts/src/app-switcher-runtime.test.ts deleted file mode 100644 index 8f71459ba3..0000000000 --- a/packages/contracts/src/app-switcher-runtime.test.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { appSwitcherRuntimeOperationFacts, bindAppSwitcher } from './app-switcher-runtime.ts'; -import type { Interactor } from './interactor-types.ts'; -import { localInteractorSource } from './interactor-operation-binding.ts'; - -const device = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, -} as const; - -test('builds the exact app-switcher operation fact catalog', () => { - const appSwitcher = { available: true } as const; - expect(appSwitcherRuntimeOperationFacts({ appSwitcher })).toEqual({ - appSwitcher, - }); -}); - -test('a local binding drives the interactor with the request runner context', async () => { - const appSwitcher = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(async () => ({ appSwitcher }) as unknown as Interactor); - const signal = new AbortController().signal; - - const operations = bindAppSwitcher(signal, localInteractorSource({ device, resolveInteractor })); - await operations.appSwitcher({ - options: { appBundleId: 'com.example.app' }, - execution: { logPath: '/tmp/daemon.log', requestId: 'switcher-1' }, - }); - - expect(resolveInteractor).toHaveBeenCalledWith(device, { - logPath: '/tmp/daemon.log', - requestId: 'switcher-1', - appBundleId: 'com.example.app', - signal, - }); - expect(appSwitcher).toHaveBeenCalledOnce(); -}); diff --git a/packages/contracts/src/app-switcher-runtime.ts b/packages/contracts/src/app-switcher-runtime.ts deleted file mode 100644 index 0852ab5183..0000000000 --- a/packages/contracts/src/app-switcher-runtime.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { Interactor, RunnerContext } from './interactor-types.ts'; -import type { RuntimeOperationFact } from './platform-runtime.ts'; -import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; - -/** - * Neutral intent for one app-switcher reveal: no arguments, so only runner metadata travels — - * the same shape `home` carries, because it is the same springboard surface. - */ -export type AppSwitcherInput = Readonly<{ - options?: Readonly<{ appBundleId?: string }>; - /** Same runner metadata a capture needs; reuses that type rather than restating it. */ - execution?: SnapshotRuntimeExecution; -}>; - -/** The reveal returns nothing; the retired leaf discarded whatever the interactor answered. */ -export type AppSwitcherRuntimeOperations = Readonly<{ - appSwitcher(input: AppSwitcherInput): Promise; -}>; - -export type AppSwitcherRuntimeOperationFacts = Readonly<{ - appSwitcher: RuntimeOperationFact; -}>; - -export function appSwitcherRuntimeOperationFacts( - input: Readonly<{ appSwitcher: RuntimeOperationFact }>, -): AppSwitcherRuntimeOperationFacts { - return Object.freeze({ appSwitcher: input.appSwitcher }); -} - -/** - * Captures one selected owner's interactor authority for the lifetime of a request binding. The - * owner is already chosen by the time a binder is called, so each entry point supplies its own - * resolution and this holds only what both share: the runner context and the reveal itself. - */ -export function bindAppSwitcher( - signal: AbortSignal, - resolveInteractor: (runner: RunnerContext) => Promise, -): AppSwitcherRuntimeOperations { - return Object.freeze({ - appSwitcher: async (input: AppSwitcherInput) => { - signal.throwIfAborted(); - const interactor = await resolveInteractor({ - ...input.execution, - appBundleId: input.options?.appBundleId, - signal, - }); - await interactor.appSwitcher(); - }, - }); -} diff --git a/packages/contracts/src/home-runtime.test.ts b/packages/contracts/src/home-runtime.test.ts deleted file mode 100644 index 3ea5fbb4ce..0000000000 --- a/packages/contracts/src/home-runtime.test.ts +++ /dev/null @@ -1,37 +0,0 @@ -import { expect, test, vi } from 'vitest'; -import { bindHome, homeRuntimeOperationFacts } from './home-runtime.ts'; -import type { Interactor } from './interactor-types.ts'; -import { localInteractorSource } from './interactor-operation-binding.ts'; - -const device = { - platform: 'android', - id: 'emulator-5554', - name: 'Pixel', - kind: 'emulator', - booted: true, -} as const; - -test('builds the exact home operation fact catalog', () => { - const home = { available: true } as const; - expect(homeRuntimeOperationFacts({ home })).toEqual({ home }); -}); - -test('a local binding drives the interactor with no arguments', async () => { - const home = vi.fn(async () => undefined); - const resolveInteractor = vi.fn(async () => ({ home }) as unknown as Interactor); - const signal = new AbortController().signal; - - const operations = bindHome(signal, localInteractorSource({ device, resolveInteractor })); - await operations.home({ - options: { appBundleId: 'com.example.app' }, - execution: { logPath: '/tmp/daemon.log', requestId: 'home-1' }, - }); - - expect(resolveInteractor).toHaveBeenCalledWith(device, { - logPath: '/tmp/daemon.log', - requestId: 'home-1', - appBundleId: 'com.example.app', - signal, - }); - expect(home).toHaveBeenCalledWith(); -}); diff --git a/packages/contracts/src/home-runtime.ts b/packages/contracts/src/home-runtime.ts deleted file mode 100644 index 006bd2b810..0000000000 --- a/packages/contracts/src/home-runtime.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { Interactor, RunnerContext } from './interactor-types.ts'; -import type { RuntimeOperationFact } from './platform-runtime.ts'; -import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; - -/** Neutral intent for one home navigation: no arguments, so only runner metadata travels. */ -export type HomeInput = Readonly<{ - options?: Readonly<{ appBundleId?: string }>; - /** Same runner metadata a capture needs; reuses that type rather than restating it. */ - execution?: SnapshotRuntimeExecution; -}>; - -/** Home returns nothing; the legacy leaf discarded whatever the interactor answered. */ -export type HomeRuntimeOperations = Readonly<{ - home(input: HomeInput): Promise; -}>; - -export type HomeRuntimeOperationFacts = Readonly<{ - home: RuntimeOperationFact; -}>; - -export function homeRuntimeOperationFacts( - input: Readonly<{ home: RuntimeOperationFact }>, -): HomeRuntimeOperationFacts { - return Object.freeze({ home: input.home }); -} - -/** - * Captures one selected owner's interactor authority for the lifetime of a request binding. The - * owner is already chosen by the time a binder is called, so each entry point supplies its own - * resolution and this holds only what both share: the runner context and the navigation itself. - */ -export function bindHome( - signal: AbortSignal, - resolveInteractor: (runner: RunnerContext) => Promise, -): HomeRuntimeOperations { - return Object.freeze({ - home: async (input: HomeInput) => { - signal.throwIfAborted(); - const interactor = await resolveInteractor({ - ...input.execution, - appBundleId: input.options?.appBundleId, - signal, - }); - await interactor.home(); - }, - }); -} diff --git a/packages/contracts/src/interactor-operation-binding.ts b/packages/contracts/src/interactor-operation-binding.ts index 6d0bf5d6dd..6f17dc4409 100644 --- a/packages/contracts/src/interactor-operation-binding.ts +++ b/packages/contracts/src/interactor-operation-binding.ts @@ -16,6 +16,24 @@ export type LocalInteractorOperationResolver = ( export type ProviderInteractorOperationResolver = (runner: RunnerContext) => Interactor | undefined; +/** + * Optional `Interactor` members (keyboard, hover, the hardware buttons only some owners carry) + * are left undefined by a platform with no such concept. Facts admit an operation only for owners + * whose interactor implements it, so a missing method at bind time is a runtime-contract error, + * not a normal refusal — and never a no-op that reports success. + */ +export function requireInteractorMethod( + method: Method | undefined, + operation: string, +): NonNullable { + if (method) return method as NonNullable; + throw new AppError( + 'COMMAND_FAILED', + `${operation} was admitted but its bound interactor has no implementation.`, + { reason: 'interactor-method-missing' }, + ); +} + /** Resolves the already-selected local owner's interactor for one bound operation. */ export function localInteractorSource( params: Readonly<{ device: DeviceInfo; resolveInteractor: LocalInteractorOperationResolver }>, diff --git a/packages/contracts/src/interactor-operation-catalog.ts b/packages/contracts/src/interactor-operation-catalog.ts index 932162f317..ccbf84ed81 100644 --- a/packages/contracts/src/interactor-operation-catalog.ts +++ b/packages/contracts/src/interactor-operation-catalog.ts @@ -1,10 +1,8 @@ import type { DeviceInfo } from '@agent-device/kernel/device'; import { bindAlertLeg } from './alert-runtime.ts'; import { bindAppEvent } from './app-event-runtime.ts'; -import { bindAppSwitcher } from './app-switcher-runtime.ts'; import { bindBack } from './back-runtime.ts'; import { bindClipboardRead, bindClipboardWrite } from './clipboard-runtime.ts'; -import { bindHome } from './home-runtime.ts'; import { KEYBOARD_ACTION_LABELS, bindKeyboardAction } from './keyboard-runtime.ts'; import { bindOrientation } from './orientation-runtime.ts'; import { bindSetSetting } from './settings-runtime.ts'; @@ -16,35 +14,9 @@ import { type ProviderInteractorOperationResolver, } from './interactor-operation-binding.ts'; import type { Interactor, RunnerContext } from './interactor-types.ts'; -import type { - NoArgumentInteractorOperations, - PlatformRuntimeOperations, -} from './platform-runtime-operations.ts'; +import type { PlatformRuntimeOperations } from './platform-runtime-operations.ts'; import type { RuntimeOperationFact } from './platform-runtime.ts'; - -/** - * Binds the zero-argument interactor group declared by `NoArgumentInteractorOperations`: one fact - * admits each member, one interactor call performs it, and the only thing that travels is runner - * metadata. Every other catalog row imports a binder from the module that owns that operation's - * contract; this group has no operation-specific mechanism to own, so binding it here is what keeps - * it one row rather than a module, an export subpath, and a facts helper. - */ -function bindNoArgumentInteractorOperations( - signal: AbortSignal, - resolveInteractor: (runner: RunnerContext) => Promise, -): NoArgumentInteractorOperations { - return Object.freeze({ - actionButton: async (input) => { - signal.throwIfAborted(); - const interactor = await resolveInteractor({ - ...input.execution, - appBundleId: input.options?.appBundleId, - signal, - }); - await interactor.actionButton(); - }, - }); -} +import { bindSystemButton, SYSTEM_BUTTON_LABELS } from './system-button-runtime.ts'; /** * How a facet turns one resolved interactor into its own typed operations. Every catalog member @@ -80,7 +52,11 @@ type InteractorOperationDefinition = Readonly<{ */ export const INTERACTOR_OPERATIONS = [ { operation: 'back', label: 'back', bind: bindBack }, - { operation: 'home', label: 'home', bind: bindHome }, + { + operation: 'home', + label: SYSTEM_BUTTON_LABELS.home, + bind: (signal, resolve) => bindSystemButton('home', signal, resolve), + }, { operation: 'setOrientation', label: 'orientation', bind: bindOrientation }, { operation: 'tvRemote', label: 'tv-remote', bind: bindTvRemote }, { @@ -100,11 +76,15 @@ export const INTERACTOR_OPERATIONS = [ }, { operation: 'readClipboard', label: 'clipboard read', bind: bindClipboardRead }, { operation: 'writeClipboard', label: 'clipboard write', bind: bindClipboardWrite }, - { operation: 'appSwitcher', label: 'app-switcher', bind: bindAppSwitcher }, + { + operation: 'appSwitcher', + label: SYSTEM_BUTTON_LABELS.appSwitcher, + bind: (signal, resolve) => bindSystemButton('appSwitcher', signal, resolve), + }, { operation: 'actionButton', - label: 'action-button', - bind: bindNoArgumentInteractorOperations, + label: SYSTEM_BUTTON_LABELS.actionButton, + bind: (signal, resolve) => bindSystemButton('actionButton', signal, resolve), }, { operation: 'triggerAppEvent', label: 'trigger-app-event', bind: bindAppEvent }, { operation: 'setSetting', label: 'settings', bind: bindSetSetting }, diff --git a/packages/contracts/src/interactor-types.ts b/packages/contracts/src/interactor-types.ts index 3943364b6a..8a4d3f962d 100644 --- a/packages/contracts/src/interactor-types.ts +++ b/packages/contracts/src/interactor-types.ts @@ -364,11 +364,12 @@ export type Interactor = { appSwitcher(): Promise; tvRemote(button: TvRemoteButton, durationMs?: number): Promise; /** - * Presses the iPhone Action Button. Required rather than optional for the same reason `tvRemote` - * is: an absent member would let an advertised press resolve as a no-op that reports success. - * Owners without the button throw `UNSUPPORTED_OPERATION`. + * Optional (parity with `keyboardDismiss`): presses the iPhone/iPad Action Button, hardware only + * the Apple owner carries. An owner without the button leaves it undefined; its fact refuses the + * press before binding, and the system-button binder fails closed rather than resolving an + * absent member as a successful no-op. */ - actionButton(): Promise; + actionButton?(): Promise; /** Optional: only Android implements a live status read (see {@link KeyboardStatusResult}). */ keyboardStatus?(): Promise; /** Optional: platforms with no keyboard-dismiss concept leave it undefined. */ diff --git a/packages/contracts/src/platform-runtime-operations.ts b/packages/contracts/src/platform-runtime-operations.ts index 33cf5192a3..f3627735c1 100644 --- a/packages/contracts/src/platform-runtime-operations.ts +++ b/packages/contracts/src/platform-runtime-operations.ts @@ -10,11 +10,7 @@ import type { NetworkRuntimeHost, NetworkRuntimeOperations } from './network-run import type { ScreenRecordingRuntimeHost } from './screen-recording-runtime-host.ts'; import type { ScreenRecordingRuntimeOperations } from './screen-recording-runtime.ts'; import type { ScreenshotRuntimeOperations } from './screenshot-runtime.ts'; -import type { - SnapshotRuntimeExecution, - SnapshotRuntimeHost, - SnapshotRuntimeOperations, -} from './snapshot-runtime.ts'; +import type { SnapshotRuntimeHost, SnapshotRuntimeOperations } from './snapshot-runtime.ts'; import type { SelectorObservationRuntimeOperations } from './selector-observation-runtime.ts'; import type { ViewportRuntimeOperations } from './viewport-runtime.ts'; import type { FocusRuntimeOperations } from './focus-runtime.ts'; @@ -25,12 +21,11 @@ import type { ScrollRuntimeOperations } from './scroll-runtime.ts'; import type { TypeTextRuntimeOperations } from './type-text-runtime.ts'; import type { ElementTextRuntimeOperations } from './element-text-runtime.ts'; import type { BackRuntimeOperations } from './back-runtime.ts'; -import type { HomeRuntimeOperations } from './home-runtime.ts'; import type { OrientationRuntimeOperations } from './orientation-runtime.ts'; import type { TvRemoteRuntimeOperations } from './tv-remote-runtime.ts'; import type { KeyboardRuntimeOperations } from './keyboard-runtime.ts'; import type { ClipboardRuntimeOperations } from './clipboard-runtime.ts'; -import type { AppSwitcherRuntimeOperations } from './app-switcher-runtime.ts'; +import type { SystemButtonRuntimeOperations } from './system-button-runtime.ts'; import type { AppEventRuntimeOperations } from './app-event-runtime.ts'; import type { SettingsRuntimeOperations } from './settings-runtime.ts'; import type { AlertRuntimeOperations } from './alert-runtime.ts'; @@ -62,31 +57,6 @@ import { import { runtimeUse } from './platform-runtime-use.ts'; import type { AndroidToolHost } from './platform-runtime-host.ts'; -/** - * The intent one zero-argument interactor operation carries: there are no arguments, so only runner - * metadata travels. `home` and `app-switcher` restate this shape in their own modules; the group - * below is the version of it that needs no module of its own. - */ -export type NoArgumentInteractorInput = Readonly<{ - options?: Readonly<{ appBundleId?: string }>; - /** Same runner metadata a capture needs; reuses that type rather than restating it. */ - execution?: SnapshotRuntimeExecution; -}>; - -/** - * The zero-argument interactor operations, bound as one group by - * `bindNoArgumentInteractorOperations`. - * - * `actionButton` presses the iPhone/iPad Action Button. Its member is required rather than optional - * even though one owner can perform it today, which is how `tvRemote` handles a control only some - * owners have: an optional member would turn a fact that advertises the press without an interactor - * that performs it into a successful-looking no-op. Owners without the hardware declare the refusal - * on the interactor, and the fact is what keeps that throw off every supported path. - */ -export type NoArgumentInteractorOperations = Readonly<{ - actionButton(input: NoArgumentInteractorInput): Promise; -}>; - export type PlatformRuntimeOperations = AppLogRuntimeOperations & AppInventoryRuntimeOperations & AppDeploymentRuntimeOperations & @@ -103,13 +73,11 @@ export type PlatformRuntimeOperations = AppLogRuntimeOperations & TypeTextRuntimeOperations & ElementTextRuntimeOperations & BackRuntimeOperations & - HomeRuntimeOperations & OrientationRuntimeOperations & TvRemoteRuntimeOperations & KeyboardRuntimeOperations & ClipboardRuntimeOperations & - AppSwitcherRuntimeOperations & - NoArgumentInteractorOperations & + SystemButtonRuntimeOperations & AppEventRuntimeOperations & SettingsRuntimeOperations & AlertRuntimeOperations & diff --git a/packages/contracts/src/platform-runtime-unavailable.test.ts b/packages/contracts/src/platform-runtime-unavailable.test.ts index 7034639035..b5745a1e2d 100644 --- a/packages/contracts/src/platform-runtime-unavailable.test.ts +++ b/packages/contracts/src/platform-runtime-unavailable.test.ts @@ -44,13 +44,11 @@ const UNAVAILABLE_FACTS: UnavailablePlatformRuntimeFacts = { touch: { available: false, reason: 'unsupported-provider-mode' }, elementText: { available: false, reason: 'unsupported-provider-mode' }, back: { available: false, reason: 'unsupported-provider-mode' }, - home: { available: false, reason: 'unsupported-provider-mode' }, orientation: { available: false, reason: 'unsupported-provider-mode' }, tvRemote: { available: false, reason: 'unsupported-provider-mode' }, keyboard: { available: false, reason: 'unsupported-provider-mode' }, clipboard: { available: false, reason: 'unsupported-provider-mode' }, - appSwitcher: { available: false, reason: 'unsupported-provider-mode' }, - actionButton: { available: false, reason: 'unsupported-provider-mode' }, + systemButton: { available: false, reason: 'unsupported-provider-mode' }, triggerAppEvent: { available: false, reason: 'unsupported-provider-mode' }, setSetting: { available: false, reason: 'unsupported-provider-mode' }, readAlert: { available: false, reason: 'unsupported-provider-mode' }, diff --git a/packages/contracts/src/platform-runtime-unavailable.ts b/packages/contracts/src/platform-runtime-unavailable.ts index 4d2499d08c..aa57d5ded9 100644 --- a/packages/contracts/src/platform-runtime-unavailable.ts +++ b/packages/contracts/src/platform-runtime-unavailable.ts @@ -21,12 +21,11 @@ import { scrollRuntimeOperationFacts } from './scroll-runtime.ts'; import { typeTextRuntimeOperationFacts } from './type-text-runtime.ts'; import { elementTextRuntimeOperationFacts } from './element-text-runtime.ts'; import { backRuntimeOperationFacts } from './back-runtime.ts'; -import { homeRuntimeOperationFacts } from './home-runtime.ts'; import { orientationRuntimeOperationFacts } from './orientation-runtime.ts'; import { tvRemoteRuntimeOperationFacts } from './tv-remote-runtime.ts'; import { keyboardRuntimeOperationFacts } from './keyboard-runtime.ts'; import { clipboardRuntimeOperationFacts } from './clipboard-runtime.ts'; -import { appSwitcherRuntimeOperationFacts } from './app-switcher-runtime.ts'; +import { systemButtonRuntimeOperationFacts } from './system-button-runtime.ts'; import { appEventRuntimeOperationFacts } from './app-event-runtime.ts'; import { settingsRuntimeOperationFacts } from './settings-runtime.ts'; import { alertRuntimeOperationFacts } from './alert-runtime.ts'; @@ -58,13 +57,11 @@ export type UnavailablePlatformRuntimeFacts = Readonly<{ touch: RuntimeOperationUnavailability; elementText: RuntimeOperationUnavailability; back: RuntimeOperationUnavailability; - home: RuntimeOperationUnavailability; orientation: RuntimeOperationUnavailability; tvRemote: RuntimeOperationUnavailability; keyboard: RuntimeOperationUnavailability; clipboard: RuntimeOperationUnavailability; - appSwitcher: RuntimeOperationUnavailability; - actionButton: RuntimeOperationUnavailability; + systemButton: RuntimeOperationUnavailability; triggerAppEvent: RuntimeOperationUnavailability; setSetting: RuntimeOperationUnavailability; readAlert: RuntimeOperationUnavailability; @@ -114,13 +111,11 @@ const UNAVAILABLE_CELLS = { touch: true, elementText: true, back: true, - home: true, orientation: true, tvRemote: true, keyboard: true, clipboard: true, - appSwitcher: true, - actionButton: true, + systemButton: true, triggerAppEvent: true, setSetting: true, readAlert: true, @@ -235,13 +230,11 @@ export function createUnavailablePlatformRuntimeFacts( }), ...elementTextRuntimeOperationFacts({ readTextAtPoint: frozen.elementText }), ...backRuntimeOperationFacts({ back: frozen.back }), - ...homeRuntimeOperationFacts({ home: frozen.home }), ...orientationRuntimeOperationFacts({ orientation: frozen.orientation }), ...tvRemoteRuntimeOperationFacts({ tvRemote: frozen.tvRemote }), ...keyboardRuntimeOperationFacts({ unsupported: frozen.keyboard }), ...clipboardRuntimeOperationFacts({ unsupported: frozen.clipboard }), - ...appSwitcherRuntimeOperationFacts({ appSwitcher: frozen.appSwitcher }), - actionButton: frozen.actionButton, + ...systemButtonRuntimeOperationFacts({ unsupported: frozen.systemButton }), ...appEventRuntimeOperationFacts({ triggerAppEvent: frozen.triggerAppEvent }), ...settingsRuntimeOperationFacts({ setSetting: frozen.setSetting }), ...alertRuntimeOperationFacts({ diff --git a/packages/contracts/src/system-button-runtime.test.ts b/packages/contracts/src/system-button-runtime.test.ts new file mode 100644 index 0000000000..13468254d1 --- /dev/null +++ b/packages/contracts/src/system-button-runtime.test.ts @@ -0,0 +1,73 @@ +import { expect, test, vi } from 'vitest'; +import type { Interactor } from './interactor-types.ts'; +import { localInteractorSource } from './interactor-operation-binding.ts'; +import { + SYSTEM_BUTTONS, + bindSystemButton, + systemButtonRuntimeOperationFacts, +} from './system-button-runtime.ts'; + +const device = { + platform: 'android', + id: 'emulator-5554', + name: 'Pixel', + kind: 'emulator', + booted: true, +} as const; +const available = { available: true } as const; +const unsupported = { available: false, reason: 'unsupported-platform-leaf' } as const; + +test('an omitted button reports the family denial, a named one its own cell', () => { + expect(systemButtonRuntimeOperationFacts({ unsupported, home: available })).toEqual({ + home: available, + appSwitcher: unsupported, + actionButton: unsupported, + }); + expect(systemButtonRuntimeOperationFacts({ unsupported })).toEqual({ + home: unsupported, + appSwitcher: unsupported, + actionButton: unsupported, + }); +}); + +test.each(SYSTEM_BUTTONS)( + 'a local %s binding presses the interactor once with no arguments', + async (button) => { + const press = vi.fn(async () => undefined); + const resolveInteractor = vi.fn(async () => ({ [button]: press }) as unknown as Interactor); + const signal = new AbortController().signal; + + const operations = bindSystemButton( + button, + signal, + localInteractorSource({ device, resolveInteractor }), + ); + await operations[button]({ + options: { appBundleId: 'com.example.app' }, + execution: { logPath: '/tmp/daemon.log', requestId: `${button}-1` }, + }); + + expect(resolveInteractor).toHaveBeenCalledWith(device, { + logPath: '/tmp/daemon.log', + requestId: `${button}-1`, + appBundleId: 'com.example.app', + signal, + }); + expect(press).toHaveBeenCalledExactlyOnceWith(); + }, +); + +test('an admitted button whose interactor lacks the member fails closed instead of no-op success', async () => { + const resolveInteractor = vi.fn(async () => ({}) as unknown as Interactor); + const operations = bindSystemButton( + 'actionButton', + new AbortController().signal, + localInteractorSource({ device, resolveInteractor }), + ); + + await expect(operations.actionButton({})).rejects.toMatchObject({ + code: 'COMMAND_FAILED', + message: 'action-button was admitted but its bound interactor has no implementation.', + details: { reason: 'interactor-method-missing' }, + }); +}); diff --git a/packages/contracts/src/system-button-runtime.ts b/packages/contracts/src/system-button-runtime.ts new file mode 100644 index 0000000000..9b17bd4dfd --- /dev/null +++ b/packages/contracts/src/system-button-runtime.ts @@ -0,0 +1,89 @@ +import { requireInteractorMethod } from './interactor-operation-binding.ts'; +import type { Interactor, RunnerContext } from './interactor-types.ts'; +import type { RuntimeOperationFact, RuntimeOperationUnavailability } from './platform-runtime.ts'; +import type { SnapshotRuntimeExecution } from './snapshot-runtime.ts'; + +/** + * The system buttons: one press each, no arguments, nothing returned. `home` and `appSwitcher` + * reach a springboard or recents surface; `actionButton` presses iPhone/iPad hardware. What + * varies between them is which owners carry the control, and that is the fact table's job, not + * a per-button module's: a button joins this list and its owners state a cell. + */ +export const SYSTEM_BUTTONS = ['home', 'appSwitcher', 'actionButton'] as const; + +export type SystemButton = (typeof SYSTEM_BUTTONS)[number]; + +/** How a provider fail-closed refusal names each button to the caller. */ +export const SYSTEM_BUTTON_LABELS = { + home: 'home', + appSwitcher: 'app-switcher', + actionButton: 'action-button', +} as const satisfies Record; + +/** Neutral intent for one press: no arguments, so only runner metadata travels. */ +export type SystemButtonInput = Readonly<{ + options?: Readonly<{ appBundleId?: string }>; + /** Same runner metadata a capture needs; reuses that type rather than restating it. */ + execution?: SnapshotRuntimeExecution; +}>; + +/** A press returns nothing; every leaf discarded whatever the interactor answered. */ +export type SystemButtonRuntimeOperations = Readonly<{ + [Button in SystemButton]: (input: SystemButtonInput) => Promise; +}>; + +export type SystemButtonRuntimeOperationFacts = Readonly<{ + [Button in SystemButton]: RuntimeOperationFact; +}>; + +/** + * What an owner declares about the system buttons. No button is one every owner carries, so + * every cell is optional and `unsupported` names the denial an omitted cell reports: an owner + * with no such control states that denial once instead of once per button, and a button added + * to {@link SYSTEM_BUTTONS} is refused by every owner that has not named it. + * + * 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 carries one button names it — omission means "refuses", never "the same as the + * neighbour". + */ +export type SystemButtonRuntimeOperationFactsInput = Readonly< + { unsupported: RuntimeOperationUnavailability } & { + [Button in SystemButton]?: RuntimeOperationFact; + } +>; + +export function systemButtonRuntimeOperationFacts( + input: SystemButtonRuntimeOperationFactsInput, +): SystemButtonRuntimeOperationFacts { + const facts = {} as Record; + for (const button of SYSTEM_BUTTONS) facts[button] = input[button] ?? input.unsupported; + return Object.freeze(facts); +} + +/** + * Captures one selected owner's interactor authority for the lifetime of a request binding and + * presses one button through it. `Interactor` members for hardware only some owners carry are + * optional; facts admit a button only for owners whose interactor implements it, so a missing + * method at press time is a runtime-contract error, not a normal refusal. + */ +export function bindSystemButton