diff --git a/scripts/layering/check.ts b/scripts/layering/check.ts index 7817c3b01..1795b55f0 100644 --- a/scripts/layering/check.ts +++ b/scripts/layering/check.ts @@ -36,8 +36,8 @@ // composition file; premature implementation loading and forbidden cross-boundary edges fail (R13). // - Over COMMAND-ATOMIC RUNTIME CUTOVERS: one parametrized gate reads the migrated-command // table (appstate R22, shutdown R23, boot R20, apps R21, install/deploy R24-R27, -// lifecycle R28-R31, devices R17, logs R14, network R15, record R16, snapshot R32, diff R33) -// and proves each command keeps +// lifecycle R28-R31, devices R17, logs R14, network R15, record R16, snapshot R32, diff R33, +// viewport R34, get R36, is R37 — R35 reserved for find) and proves each command keeps // exactly one platform-execution path — retired routes, admission, modules, and widened // runtime access cannot coexist with its operation-fact-derived descriptor and handler. // - Over CONTRACTS PRODUCTION SOURCE: contracts owns vocabulary only — host, process, and timer diff --git a/scripts/layering/runtime-command-cutover-table.ts b/scripts/layering/runtime-command-cutover-table.ts index d68662217..424e45b2e 100644 --- a/scripts/layering/runtime-command-cutover-table.ts +++ b/scripts/layering/runtime-command-cutover-table.ts @@ -25,7 +25,8 @@ import { retiredDispatchProjectionViolations } from './runtime-command-cutover-d * A row id is a report heading, so it must be unique across every stack that adds rows here. * `cutoverTableDefects` rejects a duplicate; lifecycle starts at R28 after the accepted * shutdown, install/deploy, and application-lifecycle allocations. Snapshot starts at R32; - * diff follows at R33, viewport at R34, and get at R36 (R35 is reserved for find). + * diff follows at R33, viewport at R34, get at R36, and is at R37. R35 stays reserved for + * find, whose cutover is deferred behind the Wave 5 `focus`/`type` surfaces. */ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ { @@ -536,6 +537,38 @@ export const MIGRATED_COMMAND_CUTOVERS: readonly MigratedCommandCutover[] = [ }, }, }, + { + rule: 'R37 is-runtime-cutover', + command: 'is', + subject: 'element predicate', + tier: 'request-scoped', + execution: 'device-runtime', + // `is` had no legacy adapter module and no `dispatch` projection of its own: its whole legacy + // admission WAS the capability bucket plus the two static family command sets that augmented + // it. The row's automatic columns already reject the bucket, `requireCommandSupported('is', + // …)`, and any `/(?:WEB|HARMONY).*COMMANDS/` declarator that still lists it — but + // `cutoverRowDefects` requires every row to state at least one retirement form rather than + // rely on the automatic ones, so these name the augmentation entries that had to disappear, + // in the same shape R36 uses. They are names no production source may ever define: + // `addWebCommandCapabilities` throws for a web-listed command with no matrix row, so the web + // entry could not have been left behind either way. + legacyRetirement: { + routeNames: ['WEB_QUERY_COMMANDS_WITH_IS', 'HARMONYOS_IS_SUPPORT'], + }, + runtimeTypeNames: ['SnapshotRuntimeOperations'], + operations: { names: ['captureSnapshot', 'captureSnapshotWithoutActiveApp'] }, + singularExecution: { + routes: ['dispatchIsViaRuntime'], + operations: ['captureSnapshot', 'captureSnapshotWithoutActiveApp'], + // `is` executes through the shared selector seam, so its capture owners are the SAME + // selectors `snapshot`/`diff`/`get` count. It declares no operation of its own: every + // predicate answers from the resolved tree, so `readTextAtPoint` stays R36's alone. + operationOwners: { + captureSnapshot: ['selectActiveAppSnapshot'], + captureSnapshotWithoutActiveApp: ['selectSnapshotWithoutActiveApp'], + }, + }, + }, { rule: 'R34 viewport-runtime-cutover', command: 'viewport', diff --git a/src/core/__tests__/capabilities.test.ts b/src/core/__tests__/capabilities.test.ts index cf7cb2878..0b4eb5f5c 100644 --- a/src/core/__tests__/capabilities.test.ts +++ b/src/core/__tests__/capabilities.test.ts @@ -232,7 +232,6 @@ test('macOS supports the Apple runner interaction core but excludes mobile-only 'find', 'focus', 'get', - 'is', 'longpress', 'logs', 'perf', @@ -306,7 +305,6 @@ test('Linux supports desktop interaction commands and blocks mobile/unsupported 'focus', 'get', 'home', - 'is', 'longpress', 'press', 'screenshot', @@ -334,7 +332,6 @@ test('web supports only the initial browser interaction slice', () => { 'find', 'get', 'hover', - 'is', 'press', 'record', 'screenshot', diff --git a/src/core/__tests__/capability-plugin-routing-parity.test.ts b/src/core/__tests__/capability-plugin-routing-parity.test.ts index 7ed001772..cb0cb7dc1 100644 --- a/src/core/__tests__/capability-plugin-routing-parity.test.ts +++ b/src/core/__tests__/capability-plugin-routing-parity.test.ts @@ -180,11 +180,9 @@ const HARMONYOS_SUPPORTED_COMMANDS_REF = new Set([ 'fill', 'find', 'focus', - 'get', 'home', 'gesture', 'keyboard', - 'is', 'longpress', 'press', 'screenshot', @@ -271,7 +269,6 @@ test('HarmonyOS static capabilities omit runtime-backed command admissions', () 'focus', 'gesture', 'home', - 'is', 'keyboard', 'longpress', 'perf', diff --git a/src/core/capabilities.ts b/src/core/capabilities.ts index 6743b0812..48cdc760f 100644 --- a/src/core/capabilities.ts +++ b/src/core/capabilities.ts @@ -47,7 +47,6 @@ const HARMONYOS_SUPPORTED_COMMANDS = new Set([ 'home', 'gesture', 'keyboard', - 'is', 'longpress', 'press', 'screenshot', @@ -57,7 +56,7 @@ const HARMONYOS_SUPPORTED_COMMANDS = new Set([ 'type', 'wait', ]); -const WEB_QUERY_COMMANDS = ['audio', 'find', 'is', 'screenshot', 'wait'] as const; +const WEB_QUERY_COMMANDS = ['audio', 'find', 'screenshot', 'wait'] as const; const WEB_INTERACTION_COMMANDS = [ 'click', 'fill', diff --git a/src/core/command-descriptor/__tests__/parity.test.ts b/src/core/command-descriptor/__tests__/parity.test.ts index d30dd3aaf..651bf7c31 100644 --- a/src/core/command-descriptor/__tests__/parity.test.ts +++ b/src/core/command-descriptor/__tests__/parity.test.ts @@ -60,6 +60,7 @@ const NO_CAPABILITY_PUBLIC_COMMANDS = new Set([ PUBLIC_COMMANDS.get, PUBLIC_COMMANDS.install, PUBLIC_COMMANDS.installFromSource, + PUBLIC_COMMANDS.is, PUBLIC_COMMANDS.logs, PUBLIC_COMMANDS.network, PUBLIC_COMMANDS.open, diff --git a/src/core/command-descriptor/registry.ts b/src/core/command-descriptor/registry.ts index 9b55374d2..8111828cc 100644 --- a/src/core/command-descriptor/registry.ts +++ b/src/core/command-descriptor/registry.ts @@ -1213,10 +1213,9 @@ export const RAW_COMMAND_DESCRIPTORS = [ recordsSessionAction: true, recordingEffect: 'observes-app', daemon: { route: 'interaction', refFrameEffect: 'preserve' }, - capability: ALL_DEVICE_COMMAND_CAPABILITY, timeoutPolicy: postActionObservationTimeoutPolicy('is', PRESERVE_DAEMON_TIMEOUT_POLICY), batchable: true, - platformExecution: LEGACY_PLATFORM_EXECUTION, + platformExecution: { kind: 'device-runtime', uses: selectorCaptureRuntimePlanUses }, }, // -- generic (route: generic) -- diff --git a/src/daemon/__tests__/is-runtime.test.ts b/src/daemon/__tests__/is-runtime.test.ts new file mode 100644 index 000000000..ae2da08e6 --- /dev/null +++ b/src/daemon/__tests__/is-runtime.test.ts @@ -0,0 +1,241 @@ +import { beforeEach, expect, test, vi } from 'vitest'; +import type { SnapshotResult } from '@agent-device/contracts/platform'; +import { ANDROID_EMULATOR, IOS_SIMULATOR } from '../../__tests__/test-utils/device-fixtures.ts'; +import { + makeAndroidSession, + makeIosAppSession, + makeIosSession, +} from '../../__tests__/test-utils/session-factories.ts'; +import { makeSessionStore } from '../../__tests__/test-utils/store-factory.ts'; +import { withTestDeviceInventory } from '../../__tests__/test-utils/device-inventory-gateways.ts'; +import type { DaemonRequest } from '../types.ts'; +import { selectorCaptureFixture } from './selector-capture-fixture.ts'; + +const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ mockRunAppleRunnerCommand: vi.fn() })); + +vi.mock('../../platforms/apple/core/runner/runner-client.ts', async (importOriginal) => { + const actual = + await importOriginal(); + return { ...actual, runAppleRunnerCommand: mockRunAppleRunnerCommand }; +}); + +import { dispatchIsViaRuntime } from '../selector-runtime.ts'; + +beforeEach(() => { + mockRunAppleRunnerCommand.mockReset(); + mockRunAppleRunnerCommand.mockResolvedValue({}); +}); + +// `is` answers every one of its seven predicates from the resolved capture — `isCommand` never +// reaches `backend.readText`. So its whole platform execution is the request-bound capture, and +// these cases bind at `inspectFacts` / `bindDevice`, never at `core/dispatch.ts`. + +const unavailableCapture = { available: false, reason: 'unsupported-device-kind' } as const; +const activeAppRequired = { available: false, reason: 'owner-capability-missing' } as const; + +/** One resolvable button, so a predicate has something real to answer about. */ +function buttonSnapshot(): SnapshotResult { + return { + nodes: [ + { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, + { + index: 1, + parentIndex: 0, + type: 'Button', + label: 'Continue', + identifier: 'auth_continue', + rect: { x: 10, y: 20, width: 120, height: 44 }, + enabled: true, + hittable: true, + }, + ], + backend: 'android', + }; +} + +function isRequest(session: string, positionals: readonly string[]): DaemonRequest { + return { token: 't', session, command: 'is', positionals: [...positionals], flags: {} }; +} + +test('an admitted is inspects once, binds once, and answers through the bound capture', async () => { + const fixture = selectorCaptureFixture({ snapshot: () => buttonSnapshot() }); + const sessionStore = makeSessionStore(); + sessionStore.set('is-bound', makeAndroidSession('is-bound', { appBundleId: 'com.example.app' })); + + const response = await dispatchIsViaRuntime({ + req: isRequest('is-bound', ['visible', 'id=auth_continue']), + sessionName: 'is-bound', + sessionStore, + inspectFacts: fixture.inspectFacts, + bindDevice: fixture.bindDevice, + }); + + expect(response?.ok).toBe(true); + expect(fixture.inspections).toEqual([ANDROID_EMULATOR]); + expect(fixture.binds).toEqual([ANDROID_EMULATOR]); + expect(fixture.captures.length).toBeGreaterThan(0); +}); + +test('an unavailable capture fact refuses before any bind', async () => { + // The watchOS sentinel shape: capability-supported today, no snapshot backend at the owner. + const fixture = selectorCaptureFixture({ capture: unavailableCapture }); + const sessionStore = makeSessionStore(); + sessionStore.set('is-refused', makeAndroidSession('is-refused', { appBundleId: 'com.a' })); + + const response = await dispatchIsViaRuntime({ + req: isRequest('is-refused', ['visible', 'id=auth_continue']), + sessionName: 'is-refused', + sessionStore, + inspectFacts: fixture.inspectFacts, + bindDevice: fixture.bindDevice, + }); + + expect(response?.ok).toBe(false); + // The inspection is what makes this a typed admission refusal rather than a runtime failure: + // exact owner facts were read once, side-effect-free, and nothing bound or captured after. + expect(fixture.inspections).toEqual([ANDROID_EMULATOR]); + expect(fixture.binds).toEqual([]); + expect(fixture.captures).toEqual([]); +}); + +// The correctness fix this unit declares. On iOS `appBundleId` is the XCUITest attach target: +// with no tracked app the runner's own process comes to the foreground, DISPLACES the app under +// test, and the capture then answers confidently about the runner's own blank screen. Refusing +// beats displacing-and-lying. Android captures the real launcher in the same state, and the +// platform facts already encode that asymmetry — so `is` asks the facts rather than branching. +test('an iOS session with no tracked app is refused with the open hint, not answered from a displaced capture', async () => { + const fixture = selectorCaptureFixture({ + withoutActiveApp: activeAppRequired, + snapshot: () => buttonSnapshot(), + }); + const sessionStore = makeSessionStore(); + sessionStore.set('is-no-app', makeIosSession('is-no-app')); + + const response = await withTestDeviceInventory( + {}, + async () => + await dispatchIsViaRuntime({ + req: isRequest('is-no-app', ['visible', 'id=auth_continue']), + sessionName: 'is-no-app', + sessionStore, + inspectFacts: fixture.inspectFacts, + bindDevice: fixture.bindDevice, + }), + ); + + expect(response?.ok).toBe(false); + if (response?.ok === false) { + expect(response.error?.code).toBe('SESSION_NOT_FOUND'); + expect(response.error?.message).toMatch(/requires an active app session/); + } + expect(fixture.binds).toEqual([]); + expect(fixture.captures).toEqual([]); +}); + +test('an iOS session WITH a tracked app still answers, so the refusal is the plan split and not an iOS ban', async () => { + const fixture = selectorCaptureFixture({ + withoutActiveApp: activeAppRequired, + snapshot: () => buttonSnapshot(), + }); + const sessionStore = makeSessionStore(); + sessionStore.set('is-with-app', makeIosAppSession('is-with-app')); + + const response = await dispatchIsViaRuntime({ + req: isRequest('is-with-app', ['visible', 'label=Continue']), + sessionName: 'is-with-app', + sessionStore, + inspectFacts: fixture.inspectFacts, + bindDevice: fixture.bindDevice, + }); + + expect(response?.ok).toBe(true); + expect(fixture.binds).toEqual([IOS_SIMULATOR]); +}); + +test('an Android session with no tracked app proceeds, because the owner advertises the without-active-app capture', async () => { + const fixture = selectorCaptureFixture({ snapshot: () => buttonSnapshot() }); + const sessionStore = makeSessionStore(); + sessionStore.set('is-android-no-app', makeAndroidSession('is-android-no-app')); + + const response = await dispatchIsViaRuntime({ + req: isRequest('is-android-no-app', ['visible', 'id=auth_continue']), + sessionName: 'is-android-no-app', + sessionStore, + inspectFacts: fixture.inspectFacts, + bindDevice: fixture.bindDevice, + }); + + expect(response?.ok).toBe(true); + expect(fixture.binds).toEqual([ANDROID_EMULATOR]); +}); + +// ADR 0019: once `is` declares `device-runtime`, NOTHING in its request path may reach the device +// before resolve -> admit -> bind. The direct-iOS selector query is a fast path *within* an +// admitted request, never a way around exact-owner facts or the one-binding invariant. +test('the direct-iOS selector fast path cannot operate when facts refuse admission', async () => { + const fixture = selectorCaptureFixture({ capture: unavailableCapture }); + const sessionStore = makeSessionStore(); + sessionStore.set('is-direct-refused', makeIosAppSession('is-direct-refused')); + mockRunAppleRunnerCommand.mockResolvedValue({ + found: true, + nodes: [ + { + index: 0, + type: 'Button', + label: 'Pickup', + identifier: 'shipping-pickup', + selected: true, + rect: { x: 126, y: 555, width: 75, height: 38 }, + enabled: true, + hittable: true, + }, + ], + }); + + const response = await dispatchIsViaRuntime({ + req: isRequest('is-direct-refused', ['selected', 'id="shipping-pickup"']), + sessionName: 'is-direct-refused', + sessionStore, + inspectFacts: fixture.inspectFacts, + bindDevice: fixture.bindDevice, + }); + + expect(response?.ok).toBe(false); + expect(mockRunAppleRunnerCommand).not.toHaveBeenCalled(); + expect(fixture.binds).toEqual([]); +}); + +// The reorder must not cost the fast path its whole point: once admitted, a positive runner +// answer still returns without consuming a capture. +test('a direct-iOS predicate that holds still short-circuits without a capture', async () => { + const fixture = selectorCaptureFixture({ snapshot: () => buttonSnapshot() }); + const sessionStore = makeSessionStore(); + sessionStore.set('is-direct-true', makeIosAppSession('is-direct-true')); + mockRunAppleRunnerCommand.mockResolvedValue({ + found: true, + text: 'Apple Account', + nodes: [ + { + index: 0, + type: 'Button', + label: 'Apple Account', + identifier: 'account_row', + rect: { x: 10, y: 20, width: 120, height: 44 }, + enabled: true, + hittable: true, + }, + ], + }); + + const response = await dispatchIsViaRuntime({ + req: isRequest('is-direct-true', ['text', 'id=account_row', 'Apple Account']), + sessionName: 'is-direct-true', + sessionStore, + inspectFacts: fixture.inspectFacts, + bindDevice: fixture.bindDevice, + }); + + expect(response?.ok).toBe(true); + // The fast path is still a fast path: a positive answer consumes no capture. + expect(fixture.captures).toEqual([]); +}); diff --git a/src/daemon/selector-runtime-backend.ts b/src/daemon/selector-runtime-backend.ts index 1593913bd..e52f92fa5 100644 --- a/src/daemon/selector-runtime-backend.ts +++ b/src/daemon/selector-runtime-backend.ts @@ -100,6 +100,12 @@ async function resolveSelectorRuntimeDevice( * backend whose every capture goes through the bound operation. A sibling unit migrates by * naming its command here instead of passing a `capability` to {@link createSelectorRuntime}; * nothing else in this module or `selector-capture-runtime.ts` needs to change. + * + * ADR 0019 §6: a `device-runtime` command reaches the device only after resolve -> admit -> + * bind, so THIS CALL COMES FIRST in its route — ahead of every shortcut, including the + * direct-iOS selector query that answers some targets without a capture. That query is a fast + * path *within* an admitted request, never a way around exact-owner facts or the one-binding + * invariant. `get` (R36) and `is` (R37) both order it this way. */ export async function createBoundSelectorRuntime( params: SelectorRuntimeParams, @@ -127,13 +133,15 @@ export async function createBoundSelectorRuntime( } /** - * The legacy capability-admitted selector runtime, for the selector commands whose ADR 0019 - * unit has not landed. The union narrows as each one migrates, and the last selector unit - * deletes this function together with its `requireCommandSupported` call. + * The legacy capability-admitted selector runtime. `get` and `is` have migrated, so `find` is + * the only remaining caller — and `find`'s own cutover is deferred behind the Wave 5 + * `focus`/`type` surfaces (`find focus`, `find type`, and `find get text` still reach the + * device through `dispatchCommand`). This function and its `requireCommandSupported` call + * therefore retire with `find`, NOT with the last selector unit of this wave. */ export async function createSelectorRuntime( params: SelectorRuntimeParams, - options: { requireSession: boolean; capability: 'find' | 'is' }, + options: { requireSession: boolean; capability: 'find' }, ): Promise { const resolved = await resolveSelectorRuntimeDevice(params, options.requireSession); if (!resolved.ok) return resolved; diff --git a/src/daemon/selector-runtime.ts b/src/daemon/selector-runtime.ts index 0c282a072..2fdbbe187 100644 --- a/src/daemon/selector-runtime.ts +++ b/src/daemon/selector-runtime.ts @@ -167,12 +167,7 @@ export async function dispatchGetViaRuntime( // snapshot path so the post-resolution identity guard runs. const replayTargetGuard = req.internal?.replayTargetGuard; - // ADR 0019: `get` declares `device-runtime`, so NOTHING in its request path may reach the - // device before resolve -> admit -> bind. Admission runs first for every target shape, - // including the ones the direct-iOS fast path below can answer: that path is a fast path - // *within* an admitted request, never a way around exact-owner facts or the one-binding - // invariant. (The query itself is still the shared root mechanic co-owned by `is`, `wait`, - // and the Wave 5 offscreen probe — this unit orders it, it does not claim it.) + // Admit before the direct-iOS fast path below — see {@link createBoundSelectorRuntime}. const resolvedRuntime = await createBoundSelectorRuntime(params, { requireSession: true, command: 'get', @@ -236,35 +231,36 @@ export async function dispatchIsViaRuntime( checked.hint ? { hint: checked.hint } : undefined, ); } - const { predicate, expectedText } = checked; - const split = { selectorExpression: checked.selectorExpression }; + const { predicate, selectorExpression, expectedText } = checked; // ADR 0012 decision 3 / #1349: recording and a guarded replay dispatch both // require the snapshot path — evidence and the post-resolution identity // guard are computed from the resolution tree. const replayTargetGuard = req.internal?.replayTargetGuard; const recordingSession = isSessionRecording(params.sessionStore.get(params.sessionName)); + + // Admit before the direct-iOS fast path below — see {@link createBoundSelectorRuntime}. + const resolvedRuntime = await createBoundSelectorRuntime(params, { + requireSession: true, + command: 'is', + }); + if (!resolvedRuntime.ok) return resolvedRuntime.response; + if (!replayTargetGuard && !recordingSession) { const directResponse = await dispatchDirectIosSelectorIs( params, - predicate as IsPredicate, - split.selectorExpression, + predicate, + selectorExpression, expectedText, ); if (directResponse) return directResponse; } - const resolvedRuntime = await createSelectorRuntime(params, { - requireSession: true, - capability: 'is', - }); - if (!resolvedRuntime.ok) return resolvedRuntime.response; - const response = await toDaemonResponse(async () => { const result = await resolvedRuntime.runtime.selectors.is({ session: params.sessionName, requestId: req.meta?.requestId, - predicate: predicate as IsPredicate, - selector: split.selectorExpression, + predicate, + selector: selectorExpression, expectedText, expectedResolvedTarget: replayTargetGuard, });