From dd6c8f5318e578d4bbf3229ba692009d5f2fa6b9 Mon Sep 17 00:00:00 2001 From: agent Date: Wed, 19 Aug 2026 15:28:32 +0200 Subject: [PATCH] refactor: migrate is to the request-bound device runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `is` declares the shared selector capture use, admits once from exact owner facts, refuses before binding, and binds exactly once. Its capability bucket, the static HarmonyOS/Web command sets that augmented it, and `requireCommandSupported` admission for `is` are gone; `'is'` leaves the `createSelectorRuntime` capability union. Admission now runs BEFORE the direct-iOS selector fast path. ADR 0019 requires resolve -> admit -> bind before anything in a `device-runtime` command's request path reaches the device, so that query becomes a fast path *within* an admitted request rather than a way around exact-owner facts. The rule is documented once, on `createBoundSelectorRuntime`, replacing the two duplicated call-site comments `get` and `is` were each carrying. Declared behaviour change: `is` takes the active-app plan split, so the facts decide per family. 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. An iOS `is` on a session with no tracked app is now a typed SESSION_NOT_FOUND refusal carrying the `open` hint. Refusing beats displacing-and-lying. Android captures the real launcher in that state and is unchanged, which is what the platform facts already encoded. The two Apple watchOS cells move from capability-admitted-then-runner-failure to a typed unavailable refusal, the same classification snapshot, diff, and get already landed. R37 is the new parametrized cutover row. `find` keeps `createSelectorRuntime` and its `requireCommandSupported` call, so `captureData` stays optional and `captureSnapshotWithInteractor` stays: this unit is not the last selector unit. --- scripts/layering/check.ts | 4 +- .../layering/runtime-command-cutover-table.ts | 35 ++- src/core/__tests__/capabilities.test.ts | 3 - .../capability-plugin-routing-parity.test.ts | 3 - src/core/capabilities.ts | 3 +- .../__tests__/parity.test.ts | 1 + src/core/command-descriptor/registry.ts | 3 +- src/daemon/__tests__/is-runtime.test.ts | 241 ++++++++++++++++++ src/daemon/selector-runtime-backend.ts | 16 +- src/daemon/selector-runtime.ts | 32 +-- 10 files changed, 306 insertions(+), 35 deletions(-) create mode 100644 src/daemon/__tests__/is-runtime.test.ts 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, });