diff --git a/packages/daemon/package.json b/packages/daemon/package.json index d3059b0bc7..02692edfbb 100644 --- a/packages/daemon/package.json +++ b/packages/daemon/package.json @@ -308,10 +308,6 @@ "types": "./src/sessions/session-runtime.ts", "default": "./src/sessions/session-runtime.ts" }, - "./testing/session-script-writer": { - "types": "./src/core/session-script-writer.ts", - "default": "./src/core/session-script-writer.ts" - }, "./testing/session-snapshot": { "types": "./src/execution/session-snapshot.ts", "default": "./src/execution/session-snapshot.ts" diff --git a/packages/daemon/src/__tests__/support/target-evidence-fixture.ts b/packages/daemon/src/__tests__/support/target-evidence-fixture.ts new file mode 100644 index 0000000000..52690d3bc1 --- /dev/null +++ b/packages/daemon/src/__tests__/support/target-evidence-fixture.ts @@ -0,0 +1,44 @@ +import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot'; +import type { SessionState } from '../../core/session-state.ts'; +import { authoringPublication, makeIosSession } from './session-factories.ts'; + +export const SAVE_BUTTON_NODES: RawSnapshotNode[] = [ + { + index: 0, + type: 'XCUIElementTypeButton', + identifier: 'save', + label: 'Save', + rect: { x: 10, y: 20, width: 100, height: 40 }, + enabled: true, + hittable: true, + }, +]; + +/** The `target-v1` evidence the single Save button records. */ +export const SAVE_BUTTON_EVIDENCE = { + id: 'save', + role: 'button', + label: 'Save', + ancestry: [], + sibling: 0, + viewportOrder: 0, + rect: { x: 10, y: 20, width: 100, height: 40 }, + verification: 'verified', +} as const; + +/** An iOS app session whose stored snapshot is the Save button, recording or not. */ +export function makeSessionWithSaveButtonSnapshot( + sessionName: string, + options: { recording: boolean }, +): SessionState { + const session = makeIosSession(sessionName, { + appBundleId: 'com.example.app', + ...(options.recording ? { scriptPublication: authoringPublication('armed') } : {}), + }); + session.snapshot = { + nodes: attachRefs(SAVE_BUTTON_NODES), + createdAt: Date.now(), + backend: 'xctest', + }; + return session; +} diff --git a/packages/daemon/src/execution/interaction/internal/interaction-target-evidence.test.ts b/packages/daemon/src/execution/interaction/internal/interaction-target-evidence.test.ts new file mode 100644 index 0000000000..ee50b93c73 --- /dev/null +++ b/packages/daemon/src/execution/interaction/internal/interaction-target-evidence.test.ts @@ -0,0 +1,247 @@ +import type { CommandFlags } from '@agent-device/contracts/command'; +import { legacyDispatchCapture } from '../../../__tests__/support/legacy-snapshot-capture-fixture.ts'; +import { test, expect, beforeEach } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { handleInteractionCommands } from '../index.ts'; +import { centerOfRect, type RawSnapshotNode } from '@agent-device/kernel/snapshot'; +import { makeSessionStore } from '../../../__tests__/support/store-factory.ts'; +import { makeAuthoringSession } from '../../../__tests__/support/session-factories.ts'; +import { SessionScriptWriter } from '../../../core/session-script-writer.ts'; +import type { SessionState } from '../../../core/session-state.ts'; +import { mkdtempForTestSync } from '@agent-device/test-kit'; +import { installLegacyCaptureInteractorResolution } from '../../../__tests__/support/legacy-capture-interactor-resolution.ts'; +import { captureAndStoreSnapshot } from '../../../__tests__/support/command-surface-override-fake.ts'; +import { + SAVE_BUTTON_EVIDENCE, + makeSessionWithSaveButtonSnapshot, +} from '../../../__tests__/support/target-evidence-fixture.ts'; +import { touchForwardingCreateCommandSurface } from '../../../__tests__/support/touch-forwarding-surface-fake.ts'; +import { + getRuntimeBindings, + resetGetRuntimeFixture, +} from '../../__tests__/interaction-get-runtime-fixture.ts'; + +// ADR 0012 decision 3, daemon-routed recording of the touch path (finalizeTouchInteraction): +// target-v1 evidence is computed only while the session is being recorded, lands on the recorded +// action (never the public response), and the raw resolved node/tree reaches neither the client nor +// session history. The get/is recording path is pinned in +// execution/selector-runtime-target-evidence.test.ts. + +installLegacyCaptureInteractorResolution(); + +const contextFromFlags = (_flags: CommandFlags | undefined) => ({}); + +beforeEach(() => { + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); + resetGetRuntimeFixture(); +}); + +async function runCommand( + sessionStore: ReturnType, + sessionName: string, + command: string, + positionals: string[], + flags: CommandFlags = {}, + createCommandSurface = touchForwardingCreateCommandSurface(), +) { + return await handleInteractionCommands({ + createCommandSurface, + req: { token: 't', session: sessionName, command, positionals, flags }, + sessionName, + sessionStore, + contextFromFlags, + ...getRuntimeBindings(), + }); +} + +test('press @ref while recording attaches target-v1 evidence to the recorded action, never to the public response', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'recording-press'; + sessionStore.publish( + sessionName, + makeSessionWithSaveButtonSnapshot(sessionName, { recording: true }), + ); + + const response = await runCommand(sessionStore, sessionName, 'press', ['@e1'], { verify: true }); + + expect(response?.ok).toBe(true); + if (response?.ok) { + expect(response.data).not.toHaveProperty('node'); + expect(response.data).not.toHaveProperty('preAction'); + expect(response.data).not.toHaveProperty('targetEvidence'); + } + + const recordedAction = sessionStore.get(sessionName)?.actions[0]; + expect(recordedAction?.targetEvidence).toEqual(SAVE_BUTTON_EVIDENCE); + expect(recordedAction?.result).not.toHaveProperty('node'); + expect(recordedAction?.result).not.toHaveProperty('preAction'); +}); + +test('press @ref without recording never computes target-v1 evidence', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'non-recording-press'; + sessionStore.publish( + sessionName, + makeSessionWithSaveButtonSnapshot(sessionName, { recording: false }), + ); + + const response = await runCommand(sessionStore, sessionName, 'press', ['@e1'], { verify: true }); + + expect(response?.ok).toBe(true); + const recordedAction = sessionStore.get(sessionName)?.actions[0]; + expect(recordedAction?.targetEvidence).toBeUndefined(); + expect(recordedAction?.result).not.toHaveProperty('node'); + expect(recordedAction?.result).not.toHaveProperty('preAction'); +}); + +// --------------------------------------------------------------------------- +// #1280 (ADR 0012 decision 3 amendment), the recording-boundary split: a +// press on an identity-empty container retargets ONLY what gets recorded. +// The daemon RESPONSE stays entirely container-based (chain, hittability, no +// side-channel leak), while the recorded action entry (the .ad writer's +// source) and its target-v1 evidence are descendant-based. +// --------------------------------------------------------------------------- + +const IDENTITY_EMPTY_ROW_NODES: RawSnapshotNode[] = [ + { + index: 0, + depth: 0, + type: 'FrameLayout', + rect: { x: 0, y: 0, width: 400, height: 800 }, + enabled: true, + hittable: true, + }, + { + index: 1, + depth: 1, + parentIndex: 0, + type: 'LinearLayout', + rect: { x: 0, y: 100, width: 300, height: 48 }, + enabled: true, + hittable: true, + }, + { + index: 2, + depth: 2, + parentIndex: 1, + type: 'TextView', + label: 'Connected devices', + rect: { x: 0, y: 100, width: 300, height: 48 }, + enabled: true, + hittable: false, + }, +]; + +/** + * The command family's answer for a press on that container: the response describes the dispatched + * container, and the labeled descendant rides the recording-only `recordingTarget` side channel. + */ +const containerPressSurface = touchForwardingCreateCommandSurface({ + members: (config) => ({ + interactions: { + press: async (target, options) => { + const { nodes } = await captureAndStoreSnapshot(config, { + session: options?.session, + interactiveOnly: true, + includeRects: true, + }); + const container = nodes[1]!; + const point = centerOfRect(container.rect!); + await config.backend.tap?.({ session: options?.session }, point); + return { + kind: 'selector', + point, + target: target as { kind: 'selector'; selector: string }, + node: container, + selectorChain: ['role="linearlayout"'], + preAction: { nodes }, + recordingTarget: { + node: nodes[2]!, + selectorChain: [ + 'role="textview" label="Connected devices"', + 'label="Connected devices"', + ], + refLabel: 'Connected devices', + }, + }; + }, + }, + }), +}); + +// The wire response describes the dispatched CONTAINER, and the +// recording-only side channel never leaks into it. +function expectContainerBasedResponse(data: Record): void { + expect(data.selectorChain).toEqual(['role="linearlayout"']); + expect(data.targetHittable).toBeUndefined(); + expect(data).not.toHaveProperty('recordingTarget'); + expect(data).not.toHaveProperty('node'); + expect(data).not.toHaveProperty('preAction'); +} + +// The recorded action entry — the .ad writer's source — carries the +// DESCENDANT chain/ref-label, and its target-v1 evidence names the same +// descendant (role+label; the container has no selective identity). +function expectDescendantBasedRecording(session: SessionState): void { + const recordedAction = session.actions[0]; + if (!recordedAction) throw new Error('expected a recorded action'); + expect(recordedAction.result?.selectorChain).toEqual([ + 'role="textview" label="Connected devices"', + 'label="Connected devices"', + ]); + expect(recordedAction.result?.refLabel).toBe('Connected devices'); + expect(recordedAction.result).not.toHaveProperty('recordingTarget'); + expect(recordedAction.targetEvidence).toMatchObject({ + role: 'textview', + label: 'Connected devices', + verification: 'verified', + }); + expect(recordedAction.targetEvidence?.id).toBeUndefined(); +} + +function writeSessionScript(session: SessionState): string { + const root = mkdtempForTestSync('agent-device-press-retarget-'); + const writer = new SessionScriptWriter(path.join(root, 'sessions')); + const written = writer.write(session); + if (!written.written) throw new Error('expected the script to be written'); + return fs.readFileSync(written.path, 'utf8'); +} + +test('press on an identity-empty container: container-based daemon response, descendant-based recorded entry + .ad script', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'recording-press-retarget'; + const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); + sessionStore.publish(sessionName, session); + legacyDispatchCapture.mockImplementation(async (_device, command) => { + if (command === 'snapshot') { + return { backend: 'xctest', nodes: IDENTITY_EMPTY_ROW_NODES }; + } + return {}; + }); + + const response = await runCommand( + sessionStore, + sessionName, + 'press', + ['role=linearlayout'], + {}, + containerPressSurface, + ); + + expect(response?.ok).toBe(true); + if (!response?.ok || !response.data) throw new Error('expected an ok response with data'); + expectContainerBasedResponse(response.data); + + const recordedSession = sessionStore.get(sessionName); + if (!recordedSession) throw new Error('expected the session to persist'); + expectDescendantBasedRecording(recordedSession); + + // And the WRITTEN .ad line re-resolves the descendant, not the container. + const script = writeSessionScript(recordedSession); + expect(script).toContain( + String.raw`press "role=\"textview\" label=\"Connected devices\" || label=\"Connected devices\""`, + ); + expect(script).toContain('"role":"textview","label":"Connected devices"'); +}); diff --git a/packages/daemon/src/execution/selector-runtime-target-evidence.test.ts b/packages/daemon/src/execution/selector-runtime-target-evidence.test.ts new file mode 100644 index 0000000000..f6a4241d36 --- /dev/null +++ b/packages/daemon/src/execution/selector-runtime-target-evidence.test.ts @@ -0,0 +1,332 @@ +import type { CommandFlags } from '@agent-device/contracts/command'; +import type { + DaemonGetResult, + DaemonIsResult, +} from '@agent-device/contracts/daemon-command-surface'; +import { buildSelectorChainForNode } from '@agent-device/selectors'; +import { legacyDispatchCapture } from '../__tests__/support/legacy-snapshot-capture-fixture.ts'; +import { test, expect, beforeEach } from 'vitest'; +import { handleInteractionCommands } from './interaction/index.ts'; +import type { RawSnapshotNode, SnapshotNode } from '@agent-device/kernel/snapshot'; +import { makeSessionStore } from '../__tests__/support/store-factory.ts'; +import { makeAuthoringSession } from '../__tests__/support/session-factories.ts'; +import { installLegacyCaptureInteractorResolution } from '../__tests__/support/legacy-capture-interactor-resolution.ts'; +import { + captureAndStoreSnapshot, + commandSurfaceWithMembers, +} from '../__tests__/support/command-surface-override-fake.ts'; +import { + SAVE_BUTTON_EVIDENCE, + SAVE_BUTTON_NODES, + makeSessionWithSaveButtonSnapshot, +} from '../__tests__/support/target-evidence-fixture.ts'; +import { + getRuntimeBindings, + resetGetRuntimeFixture, +} from './__tests__/interaction-get-runtime-fixture.ts'; + +// ADR 0012 decision 3 / #1349, daemon-routed recording of the get path (recordIfSession): the +// target-v1 evidence is computed only while the session is being recorded, lands on the recorded +// action (never the public response), and the raw resolved node/tree reaches neither the client nor +// session history. The touch path is pinned in +// execution/interaction/internal/interaction-target-evidence.test.ts. + +installLegacyCaptureInteractorResolution(); + +const contextFromFlags = (_flags: CommandFlags | undefined) => ({}); + +beforeEach(() => { + legacyDispatchCapture.mockReset(); + legacyDispatchCapture.mockResolvedValue({}); + resetGetRuntimeFixture(); +}); + +const LABEL_OR_ID = /^(label|id)="?([^"]*)"?$/; + +function findNode(nodes: SnapshotNode[], selector: string): SnapshotNode | undefined { + const [, key, value] = LABEL_OR_ID.exec(selector) ?? []; + return nodes.find((node) => (key === 'id' ? node.identifier : node.label) === value); +} + +/** + * The command family's `get` and `is`, answering with the resolution payload (`node`, + * `preActionNodes`) the daemon records evidence from: a `@ref` reads the stored tree, a selector + * reads a capture taken through the config's backend and stored as the session snapshot. + */ +const selectorReadSurface = commandSurfaceWithMembers((config) => { + const read = async (selector: string, session: string | undefined) => { + const { nodes } = await captureAndStoreSnapshot(config, { + session, + includeRects: true, + }); + return { nodes, node: findNode(nodes, selector) }; + }; + return { + selectors: { + get: async (options): Promise => { + const { target } = options; + const record = await config.sessions?.get(options.session ?? 'default'); + const resolved = + target.kind === 'ref' + ? { + nodes: record?.snapshot?.nodes ?? [], + node: record?.snapshot?.nodes.find( + (candidate) => candidate.ref === target.ref.replace(/^@/, ''), + ), + } + : await read(target.selector, options.session); + if (!resolved.node) throw new Error('get: the target resolved to no node'); + return { + kind: 'text', + target, + text: resolved.node.label ?? '', + node: resolved.node, + selectorChain: buildSelectorChainForNode(resolved.node, config.backend.platform, { + action: 'get', + nodes: resolved.nodes, + }), + preActionNodes: resolved.nodes, + } as DaemonGetResult; + }, + is: async (options): Promise => { + const { nodes, node } = await read(options.selector, options.session); + if (options.predicate === 'absent') { + return { + predicate: 'absent', + pass: true, + selector: options.selector, + matches: nodes.filter((candidate) => candidate === node).length, + }; + } + if (!node) throw new Error('is: the selector resolved to no node'); + if (options.predicate === 'exists') { + return { predicate: 'exists', pass: true, selector: options.selector, matches: 1 }; + } + return { + predicate: options.predicate, + pass: true, + selector: options.selector, + selectorChain: buildSelectorChainForNode(node, config.backend.platform, { nodes }), + node, + preActionNodes: nodes, + }; + }, + }, + }; +}); + +async function runCommand( + sessionStore: ReturnType, + sessionName: string, + command: string, + positionals: string[], +) { + return await handleInteractionCommands({ + createCommandSurface: selectorReadSurface, + req: { token: 't', session: sessionName, command, positionals, flags: {} }, + sessionName, + sessionStore, + contextFromFlags, + ...getRuntimeBindings(), + }); +} + +test('get text @ref while recording attaches target-v1 evidence to the recorded action, never to session history payloads', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'recording-get-ref'; + sessionStore.publish( + sessionName, + makeSessionWithSaveButtonSnapshot(sessionName, { recording: true }), + ); + + const response = await runCommand(sessionStore, sessionName, 'get', ['text', '@e1']); + + expect(response?.ok).toBe(true); + if (response?.ok) { + expect(response.data).not.toHaveProperty('preActionNodes'); + expect(response.data).not.toHaveProperty('targetEvidence'); + } + + const recordedAction = sessionStore.get(sessionName)?.actions[0]; + expect(recordedAction?.targetEvidence).toEqual(SAVE_BUTTON_EVIDENCE); + expect(recordedAction?.result).not.toHaveProperty('node'); + expect(recordedAction?.result).not.toHaveProperty('preActionNodes'); +}); + +test('get text @ref without recording never computes target-v1 evidence', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'non-recording-get-ref'; + sessionStore.publish( + sessionName, + makeSessionWithSaveButtonSnapshot(sessionName, { recording: false }), + ); + + const response = await runCommand(sessionStore, sessionName, 'get', ['text', '@e1']); + + expect(response?.ok).toBe(true); + const recordedAction = sessionStore.get(sessionName)?.actions[0]; + expect(recordedAction?.targetEvidence).toBeUndefined(); +}); + +test('get text simple iOS id selector while recording records evidence from the snapshot path', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'recording-get-direct-gate'; + const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); + sessionStore.publish(sessionName, session); + legacyDispatchCapture.mockImplementation(async (_device, command) => { + if (command === 'snapshot') { + return { + backend: 'xctest', + nodes: [ + { + index: 0, + depth: 0, + type: 'Application', + rect: { x: 0, y: 0, width: 393, height: 852 }, + enabled: true, + hittable: true, + }, + { + index: 1, + depth: 1, + parentIndex: 0, + type: 'TextField', + label: 'Name', + identifier: 'field-name', + value: 'Ada Lovelace', + rect: { x: 24, y: 220, width: 320, height: 48 }, + enabled: true, + hittable: true, + }, + ], + }; + } + return {}; + }); + + const response = await runCommand(sessionStore, sessionName, 'get', ['text', 'id="field-name"']); + + expect(response?.ok).toBe(true); + // The snapshot path supplies the evidence tree. + expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toContain('snapshot'); + + const recordedAction = sessionStore.get(sessionName)?.actions[0]; + expect(recordedAction?.targetEvidence).toMatchObject({ + id: 'field-name', + role: 'textfield', + label: 'Name', + ancestry: [{ role: 'application' }], + verification: 'verified', + }); +}); + +// --------------------------------------------------------------------------- +// #1349: `is` joins the evidence-carrying set (get-pattern) — a unique- +// resolving predicate records target-v1 evidence while recording; `is exists` +// stays intentionally unannotated; the snapshot path supplies the evidence tree. +// --------------------------------------------------------------------------- + +function mockSnapshotWithSaveButton() { + legacyDispatchCapture.mockImplementation(async (_device, command) => { + if (command === 'snapshot') { + return { backend: 'xctest', nodes: SAVE_BUTTON_NODES as RawSnapshotNode[] }; + } + return {}; + }); +} + +test('is visible while recording attaches target-v1 evidence and strips the resolution payload everywhere', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'recording-is'; + sessionStore.publish( + sessionName, + makeSessionWithSaveButtonSnapshot(sessionName, { recording: true }), + ); + mockSnapshotWithSaveButton(); + + const response = await runCommand(sessionStore, sessionName, 'is', ['visible', 'label="Save"']); + + expect(response?.ok).toBe(true); + if (response?.ok) { + expect(response.data).not.toHaveProperty('node'); + expect(response.data).not.toHaveProperty('preActionNodes'); + expect(response.data).not.toHaveProperty('selectorChain'); + } + + const recordedAction = sessionStore.get(sessionName)?.actions[0]; + expect(recordedAction?.targetEvidence).toMatchObject({ + id: 'save', + role: 'button', + label: 'Save', + verification: 'verified', + }); + expect(recordedAction?.result).not.toHaveProperty('node'); + expect(recordedAction?.result).not.toHaveProperty('preActionNodes'); +}); + +test('is exists while recording stays intentionally unannotated (deferred coverage)', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'recording-is-exists'; + sessionStore.publish( + sessionName, + makeSessionWithSaveButtonSnapshot(sessionName, { recording: true }), + ); + mockSnapshotWithSaveButton(); + + const response = await runCommand(sessionStore, sessionName, 'is', ['exists', 'label="Save"']); + + expect(response?.ok).toBe(true); + const recordedAction = sessionStore.get(sessionName)?.actions[0]; + expect(recordedAction?.command).toBe('is'); + expect(recordedAction?.targetEvidence).toBeUndefined(); +}); + +test('is absent while recording stays an ordinary unannotated observation', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'recording-is-absent'; + sessionStore.publish( + sessionName, + makeSessionWithSaveButtonSnapshot(sessionName, { recording: true }), + ); + mockSnapshotWithSaveButton(); + + const response = await runCommand(sessionStore, sessionName, 'is', [ + 'absent', + 'label="Removed row"', + ]); + + expect(response?.ok).toBe(true); + if (response?.ok) { + expect(response.data).toEqual({ + predicate: 'absent', + pass: true, + selector: 'label="Removed row"', + matches: 0, + }); + } + const recordedAction = sessionStore.get(sessionName)?.actions[0]; + expect(recordedAction?.command).toBe('is'); + expect(recordedAction?.targetEvidence).toBeUndefined(); + expect(recordedAction?.result).toEqual({ + predicate: 'absent', + pass: true, + selector: 'label="Removed row"', + matches: 0, + }); +}); + +test('is visible without recording never computes target-v1 evidence', async () => { + const sessionStore = makeSessionStore(); + const sessionName = 'non-recording-is'; + sessionStore.publish( + sessionName, + makeSessionWithSaveButtonSnapshot(sessionName, { recording: false }), + ); + mockSnapshotWithSaveButton(); + + const response = await runCommand(sessionStore, sessionName, 'is', ['visible', 'label="Save"']); + + expect(response?.ok).toBe(true); + const recordedAction = sessionStore.get(sessionName)?.actions[0]; + expect(recordedAction?.targetEvidence).toBeUndefined(); +}); diff --git a/src/__tests__/daemon-composition/interaction/interaction-target-evidence.test.ts b/src/__tests__/daemon-composition/interaction/interaction-target-evidence.test.ts deleted file mode 100644 index bcaf53b4c8..0000000000 --- a/src/__tests__/daemon-composition/interaction/interaction-target-evidence.test.ts +++ /dev/null @@ -1,461 +0,0 @@ -import type { CommandFlags } from '@agent-device/contracts/command'; -import { legacyDispatchCapture } from '@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'; -import { test, expect, vi, beforeEach } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import { handleInteractionCommands } from '@agent-device/daemon/testing/interaction/index'; -import { attachRefs, type RawSnapshotNode } from '@agent-device/kernel/snapshot'; -import { makeSessionStore } from '@agent-device/daemon/testing/__tests__/support/store-factory'; -import { - makeIosSession, - makeAuthoringSession, - authoringPublication, -} from '@agent-device/daemon/testing/__tests__/support/session-factories'; -import { SessionScriptWriter } from '@agent-device/daemon/testing/session-script-writer'; -import type { SessionState } from '@agent-device/daemon/testing/session-state'; -import { mkdtempForTestSync } from '@agent-device/test-kit'; -import { testCreateCommandSurface } from '../command-surface-fixture.ts'; - -// ADR 0012 decision 3, daemon-routed recording: target-v1 evidence is -// computed only while the session is being recorded, lands on the recorded -// action (never the public response), and the raw resolved node/tree reaches -// neither the client nor session history. Covers the touch path -// (finalizeTouchInteraction) and the get path (recordIfSession), including -// the recording gate on the direct-iOS get fast path. - -const { mockRunAppleRunnerCommand } = vi.hoisted(() => ({ - mockRunAppleRunnerCommand: vi.fn(), -})); - -vi.mock('@agent-device/daemon/testing/snapshot-interactor-capture', async () => { - const fixture = - await import('@agent-device/daemon/testing/__tests__/legacy-snapshot-capture-fixture'); - return { captureSnapshotWithInteractor: fixture.captureSnapshotThroughLegacyDispatchFixture }; -}); - -vi.mock('@agent-device/platform-apple/runner/operations', async (importOriginal) => { - const actual = - await importOriginal(); - return { - ...actual, - runAppleRunnerCommand: mockRunAppleRunnerCommand, - }; -}); - -import { - getRuntimeBindings, - resetGetRuntimeFixture, -} from '@agent-device/daemon/testing/__tests__/interaction-get-runtime-fixture'; -const contextFromFlags = (_flags: CommandFlags | undefined) => ({}); - -beforeEach(() => { - legacyDispatchCapture.mockReset(); - legacyDispatchCapture.mockResolvedValue({}); - mockRunAppleRunnerCommand.mockReset(); - mockRunAppleRunnerCommand.mockResolvedValue({}); - resetGetRuntimeFixture(); -}); - -const SAVE_BUTTON_NODES: RawSnapshotNode[] = [ - { - index: 0, - type: 'XCUIElementTypeButton', - identifier: 'save', - label: 'Save', - rect: { x: 10, y: 20, width: 100, height: 40 }, - enabled: true, - hittable: true, - }, -]; - -function makeSessionWithSnapshot( - sessionName: string, - options: { recording: boolean }, -): SessionState { - const session = makeIosSession(sessionName, { - appBundleId: 'com.example.app', - ...(options.recording ? { scriptPublication: authoringPublication('armed') } : {}), - }); - session.snapshot = { - nodes: attachRefs(SAVE_BUTTON_NODES), - createdAt: Date.now(), - backend: 'xctest', - }; - return session; -} - -async function runCommand( - sessionStore: ReturnType, - sessionName: string, - command: string, - positionals: string[], - flags: CommandFlags = {}, -) { - return await handleInteractionCommands({ - createCommandSurface: testCreateCommandSurface, - req: { token: 't', session: sessionName, command, positionals, flags }, - sessionName, - sessionStore, - contextFromFlags, - ...getRuntimeBindings(), - }); -} - -test('press @ref while recording attaches target-v1 evidence to the recorded action, never to the public response', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'recording-press'; - sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); - - // verify:true forces full runtime resolution, which captures node/tree. - const response = await runCommand(sessionStore, sessionName, 'press', ['@e1'], { verify: true }); - - expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data).not.toHaveProperty('node'); - expect(response.data).not.toHaveProperty('preAction'); - expect(response.data).not.toHaveProperty('targetEvidence'); - } - - const recordedAction = sessionStore.get(sessionName)?.actions[0]; - expect(recordedAction?.targetEvidence).toEqual({ - id: 'save', - role: 'button', - label: 'Save', - ancestry: [], - sibling: 0, - viewportOrder: 0, - rect: { x: 10, y: 20, width: 100, height: 40 }, - verification: 'verified', - }); - expect(recordedAction?.result).not.toHaveProperty('node'); - expect(recordedAction?.result).not.toHaveProperty('preAction'); -}); - -test('press @ref without recording never computes target-v1 evidence', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'non-recording-press'; - sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); - - const response = await runCommand(sessionStore, sessionName, 'press', ['@e1'], { verify: true }); - - expect(response?.ok).toBe(true); - const recordedAction = sessionStore.get(sessionName)?.actions[0]; - expect(recordedAction?.targetEvidence).toBeUndefined(); - expect(recordedAction?.result).not.toHaveProperty('node'); - expect(recordedAction?.result).not.toHaveProperty('preAction'); -}); - -test('get text @ref while recording attaches target-v1 evidence to the recorded action, never to session history payloads', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'recording-get-ref'; - sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); - - const response = await runCommand(sessionStore, sessionName, 'get', ['text', '@e1']); - - expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data).not.toHaveProperty('preActionNodes'); - expect(response.data).not.toHaveProperty('targetEvidence'); - } - - const recordedAction = sessionStore.get(sessionName)?.actions[0]; - expect(recordedAction?.targetEvidence).toEqual({ - id: 'save', - role: 'button', - label: 'Save', - ancestry: [], - sibling: 0, - viewportOrder: 0, - rect: { x: 10, y: 20, width: 100, height: 40 }, - verification: 'verified', - }); - expect(recordedAction?.result).not.toHaveProperty('node'); - expect(recordedAction?.result).not.toHaveProperty('preActionNodes'); -}); - -test('get text @ref without recording never computes target-v1 evidence', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'non-recording-get-ref'; - sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); - - const response = await runCommand(sessionStore, sessionName, 'get', ['text', '@e1']); - - expect(response?.ok).toBe(true); - const recordedAction = sessionStore.get(sessionName)?.actions[0]; - expect(recordedAction?.targetEvidence).toBeUndefined(); -}); - -test('get text simple iOS id selector while recording skips the direct runner query and records evidence from the snapshot path', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'recording-get-direct-gate'; - const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); - sessionStore.publish(sessionName, session); - legacyDispatchCapture.mockImplementation(async (_device, command) => { - if (command === 'snapshot') { - return { - backend: 'xctest', - nodes: [ - { - index: 0, - depth: 0, - type: 'Application', - rect: { x: 0, y: 0, width: 393, height: 852 }, - enabled: true, - hittable: true, - }, - { - index: 1, - depth: 1, - parentIndex: 0, - type: 'TextField', - label: 'Name', - identifier: 'field-name', - value: 'Ada Lovelace', - rect: { x: 24, y: 220, width: 320, height: 48 }, - enabled: true, - hittable: true, - }, - ], - }; - } - return {}; - }); - - const response = await runCommand(sessionStore, sessionName, 'get', ['text', 'id="field-name"']); - - expect(response?.ok).toBe(true); - // The direct-iOS querySelector fast path must be gated during recording so - // the snapshot path supplies the evidence tree. - expect(mockRunAppleRunnerCommand).not.toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ command: 'querySelector' }), - expect.anything(), - ); - expect(legacyDispatchCapture.mock.calls.map((call) => call[1])).toContain('snapshot'); - - const recordedAction = sessionStore.get(sessionName)?.actions[0]; - expect(recordedAction?.targetEvidence).toMatchObject({ - id: 'field-name', - role: 'textfield', - label: 'Name', - ancestry: [{ role: 'application' }], - verification: 'verified', - }); -}); - -// --------------------------------------------------------------------------- -// #1280 (ADR 0012 decision 3 amendment), the recording-boundary split: a -// press on an identity-empty container retargets ONLY what gets recorded. -// The daemon RESPONSE stays entirely container-based (chain, hittability, no -// side-channel leak), while the recorded action entry (the .ad writer's -// source) and its target-v1 evidence are descendant-based. -// --------------------------------------------------------------------------- - -const IDENTITY_EMPTY_ROW_NODES: RawSnapshotNode[] = [ - { - index: 0, - depth: 0, - type: 'FrameLayout', - rect: { x: 0, y: 0, width: 400, height: 800 }, - enabled: true, - hittable: true, - }, - { - index: 1, - depth: 1, - parentIndex: 0, - type: 'LinearLayout', - rect: { x: 0, y: 100, width: 300, height: 48 }, - enabled: true, - hittable: true, - }, - { - index: 2, - depth: 2, - parentIndex: 1, - type: 'TextView', - label: 'Connected devices', - rect: { x: 0, y: 100, width: 300, height: 48 }, - enabled: true, - hittable: false, - }, -]; - -// The wire response describes the dispatched CONTAINER, and the -// recording-only side channel never leaks into it. -function expectContainerBasedResponse(data: Record): void { - expect(data.selectorChain).toEqual(['role="linearlayout"']); - expect(data.targetHittable).toBeUndefined(); - expect(data).not.toHaveProperty('recordingTarget'); - expect(data).not.toHaveProperty('node'); - expect(data).not.toHaveProperty('preAction'); -} - -// The recorded action entry — the .ad writer's source — carries the -// DESCENDANT chain/ref-label, and its target-v1 evidence names the same -// descendant (role+label; the container has no selective identity). -function expectDescendantBasedRecording(session: SessionState): void { - const recordedAction = session.actions[0]; - if (!recordedAction) throw new Error('expected a recorded action'); - expect(recordedAction.result?.selectorChain).toEqual([ - 'role="textview" label="Connected devices"', - 'label="Connected devices"', - ]); - expect(recordedAction.result?.refLabel).toBe('Connected devices'); - expect(recordedAction.result).not.toHaveProperty('recordingTarget'); - expect(recordedAction.targetEvidence).toMatchObject({ - role: 'textview', - label: 'Connected devices', - verification: 'verified', - }); - expect(recordedAction.targetEvidence?.id).toBeUndefined(); -} - -function writeSessionScript(session: SessionState): string { - const root = mkdtempForTestSync('agent-device-press-retarget-'); - const writer = new SessionScriptWriter(path.join(root, 'sessions')); - const written = writer.write(session); - if (!written.written) throw new Error('expected the script to be written'); - return fs.readFileSync(written.path, 'utf8'); -} - -test('press on an identity-empty container: container-based daemon response, descendant-based recorded entry + .ad script', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'recording-press-retarget'; - // The fixture is the measured Android list-row shape, but the recording - // boundary under test is platform-independent — an iOS session keeps this - // on the runtime resolution path (the direct-iOS fast path is gated during - // recording) without Android's real-adb dialog-readiness probes, which - // would burn wall-clock time this unit lane must not spend. - const session = makeAuthoringSession(sessionName, { appBundleId: 'com.example.app' }); - sessionStore.publish(sessionName, session); - legacyDispatchCapture.mockImplementation(async (_device, command) => { - if (command === 'snapshot') { - return { backend: 'xctest', nodes: IDENTITY_EMPTY_ROW_NODES }; - } - return {}; - }); - - const response = await runCommand(sessionStore, sessionName, 'press', ['role=linearlayout']); - - expect(response?.ok).toBe(true); - if (!response?.ok || !response.data) throw new Error('expected an ok response with data'); - expectContainerBasedResponse(response.data); - - const recordedSession = sessionStore.get(sessionName); - if (!recordedSession) throw new Error('expected the session to persist'); - expectDescendantBasedRecording(recordedSession); - - // And the WRITTEN .ad line re-resolves the descendant, not the container. - const script = writeSessionScript(recordedSession); - expect(script).toContain( - String.raw`press "role=\"textview\" label=\"Connected devices\" || label=\"Connected devices\""`, - ); - expect(script).toContain('"role":"textview","label":"Connected devices"'); -}); - -// --------------------------------------------------------------------------- -// #1349: `is` joins the evidence-carrying set (get-pattern) — a unique- -// resolving predicate records target-v1 evidence while recording; `is exists` -// stays intentionally unannotated; the direct-iOS fast path is gated during -// recording so the snapshot path supplies the evidence tree. -// --------------------------------------------------------------------------- - -function mockSnapshotWithSaveButton() { - legacyDispatchCapture.mockImplementation(async (_device, command) => { - if (command === 'snapshot') { - return { backend: 'xctest', nodes: SAVE_BUTTON_NODES }; - } - return {}; - }); -} - -test('is visible while recording attaches target-v1 evidence and strips the resolution payload everywhere', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'recording-is'; - sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); - mockSnapshotWithSaveButton(); - - const response = await runCommand(sessionStore, sessionName, 'is', ['visible', 'label="Save"']); - - expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data).not.toHaveProperty('node'); - expect(response.data).not.toHaveProperty('preActionNodes'); - expect(response.data).not.toHaveProperty('selectorChain'); - } - // The direct-iOS querySelector fast path must be gated during recording. - expect(mockRunAppleRunnerCommand).not.toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ command: 'querySelector' }), - expect.anything(), - ); - - const recordedAction = sessionStore.get(sessionName)?.actions[0]; - expect(recordedAction?.targetEvidence).toMatchObject({ - id: 'save', - role: 'button', - label: 'Save', - verification: 'verified', - }); - expect(recordedAction?.result).not.toHaveProperty('node'); - expect(recordedAction?.result).not.toHaveProperty('preActionNodes'); -}); - -test('is exists while recording stays intentionally unannotated (deferred coverage)', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'recording-is-exists'; - sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); - mockSnapshotWithSaveButton(); - - const response = await runCommand(sessionStore, sessionName, 'is', ['exists', 'label="Save"']); - - expect(response?.ok).toBe(true); - const recordedAction = sessionStore.get(sessionName)?.actions[0]; - expect(recordedAction?.command).toBe('is'); - expect(recordedAction?.targetEvidence).toBeUndefined(); -}); - -test('is absent while recording stays an ordinary unannotated observation', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'recording-is-absent'; - sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: true })); - mockSnapshotWithSaveButton(); - - const response = await runCommand(sessionStore, sessionName, 'is', [ - 'absent', - 'label="Removed row"', - ]); - - expect(response?.ok).toBe(true); - if (response?.ok) { - expect(response.data).toEqual({ - predicate: 'absent', - pass: true, - selector: 'label="Removed row"', - matches: 0, - }); - } - const recordedAction = sessionStore.get(sessionName)?.actions[0]; - expect(recordedAction?.command).toBe('is'); - expect(recordedAction?.targetEvidence).toBeUndefined(); - expect(recordedAction?.result).toEqual({ - predicate: 'absent', - pass: true, - selector: 'label="Removed row"', - matches: 0, - }); -}); - -test('is visible without recording never computes target-v1 evidence', async () => { - const sessionStore = makeSessionStore(); - const sessionName = 'non-recording-is'; - sessionStore.publish(sessionName, makeSessionWithSnapshot(sessionName, { recording: false })); - mockSnapshotWithSaveButton(); - - const response = await runCommand(sessionStore, sessionName, 'is', ['visible', 'label="Save"']); - - expect(response?.ok).toBe(true); - const recordedAction = sessionStore.get(sessionName)?.actions[0]; - expect(recordedAction?.targetEvidence).toBeUndefined(); -}); diff --git a/src/commands/interaction/runtime/selector-read-resolution-payload.test.ts b/src/commands/interaction/runtime/selector-read-resolution-payload.test.ts new file mode 100644 index 0000000000..dd557ce5f7 --- /dev/null +++ b/src/commands/interaction/runtime/selector-read-resolution-payload.test.ts @@ -0,0 +1,80 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { makeSnapshotState } from '@agent-device/selectors/snapshot-geometry-fixtures'; +import { createSelectorDevice } from './__tests__/test-utils/index.ts'; +import { ref } from './selector-read-utils.ts'; + +// ADR 0012 decision 3 / #1349: the resolution payload (`node`, `preActionNodes`) is what the daemon +// computes record-time target-v1 evidence from. Which selector reads carry it is the command +// family's decision: a unique-resolving read does, `is exists` and `is absent` deliberately do not. + +const saveSnapshot = () => + makeSnapshotState([ + { + index: 0, + type: 'XCUIElementTypeButton', + identifier: 'save', + label: 'Save', + rect: { x: 10, y: 20, width: 100, height: 40 }, + enabled: true, + hittable: true, + }, + ]); + +test('runtime get text on a @ref carries the resolved node and the tree it resolved against', async () => { + const snapshot = saveSnapshot(); + const device = createSelectorDevice(snapshot); + + const result = await device.selectors.get({ + session: 'default', + property: 'text', + target: ref('@e1'), + }); + + assert.equal(result.node.identifier, 'save'); + assert.deepEqual(result.preActionNodes, snapshot.nodes); +}); + +test('runtime is visible carries the resolved node and the tree it resolved against', async () => { + const snapshot = saveSnapshot(); + const device = createSelectorDevice(snapshot); + + const result = await device.selectors.is({ + session: 'default', + predicate: 'visible', + selector: 'label="Save"', + }); + + assert.equal(result.node?.identifier, 'save'); + assert.deepEqual(result.preActionNodes, snapshot.nodes); +}); + +test('runtime is exists carries no resolution payload', async () => { + const device = createSelectorDevice(saveSnapshot()); + + const result = await device.selectors.is({ + session: 'default', + predicate: 'exists', + selector: 'label="Save"', + }); + + assert.equal(result.node, undefined); + assert.equal(result.preActionNodes, undefined); +}); + +test('runtime is absent reports only its predicate, selector, and match count', async () => { + const device = createSelectorDevice(saveSnapshot()); + + const result = await device.selectors.is({ + session: 'default', + predicate: 'absent', + selector: 'label="Removed row"', + }); + + assert.deepEqual(result, { + predicate: 'absent', + pass: true, + selector: 'label="Removed row"', + matches: 0, + }); +});