Repository navigation
refactor(daemon): relocate the settle, disclosure and capture tests by what they assert #3698
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
thymikee
merged 10 commits into
refactor/daemon-find-tests
from
refactor/daemon-settle-tests
Oct 16, 2026
+809
−233
Merged
Changes from all commits
Commits
Show all changes
10 commits
Select commit
Hold shift + click to select a range
2d2079c
test(daemon): add a touch-forwarding command surface fake
thymikee 9afd1c6
test(interaction): capture disclosure moves beside the interaction ha…
thymikee 42022b3
test(interaction): parameterized fill scrubbing moves beside the touc…
thymikee 0692d20
test(interaction): dispatch-disclosure daemon rows move beside the in…
thymikee 9f7c181
test(daemon): let the touch-forwarding surface observe a settle and c…
thymikee f709ae9
test(interaction): settle wiring moves beside the interaction handler…
thymikee a15f090
chore(test): repoint the activation checklist at the relocated captur…
thymikee 1662807
chore(daemon): drop the two testing subpaths the settle and disclosur…
thymikee a39b933
test(interaction): assert typed refusals in the escape and post-gestu…
thymikee 9c7f710
test(interaction): assert the ref-frame preference through the fill h…
thymikee File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
228 changes: 228 additions & 0 deletions
228
packages/daemon/src/__tests__/support/touch-forwarding-surface-fake.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,228 @@ | ||
| import type { CreateDaemonCommandSurface } from '@agent-device/contracts/daemon-command-surface'; | ||
| import type { | ||
| FillCommandResult, | ||
| InteractionTarget, | ||
| PressCommandResult, | ||
| SettleObservation, | ||
| SettleParams, | ||
| } from '@agent-device/contracts/interaction'; | ||
| import { buildSelectorChainForNode } from '@agent-device/selectors'; | ||
| import { AppError } from '@agent-device/kernel/errors'; | ||
| import { | ||
| centerOfRect, | ||
| type Point, | ||
| type SnapshotNode, | ||
| type SnapshotPreferredBackend, | ||
| type SnapshotQualityVerdict, | ||
| type SnapshotState, | ||
| } from '@agent-device/kernel/snapshot'; | ||
| import { | ||
| captureAndStoreSnapshot, | ||
| commandSurfaceWithMembers, | ||
| type CommandSurfaceMembers, | ||
| } from './command-surface-override-fake.ts'; | ||
| import type { AgentDeviceRuntimeConfig } from '@agent-device/contracts/runtime-contract'; | ||
|
|
||
| type TouchResolution = { | ||
| point: Point; | ||
| target: InteractionTarget; | ||
| node?: SnapshotNode; | ||
| nodes?: SnapshotNode[]; | ||
| selectorChain?: string[]; | ||
| snapshotQuality?: SnapshotQualityVerdict; | ||
| }; | ||
|
|
||
| export type TouchForwardingSurfaceOptions = { | ||
| /** | ||
| * Which node a selector expression names. The default reads `label="X"` / `label=X` and | ||
| * `id="X"` / `id=X`; a suite that needs another selector shape states its own matcher. | ||
| */ | ||
| matchSelector?: (nodes: SnapshotNode[], selector: string) => SnapshotNode | undefined; | ||
| /** | ||
| * The post-action observation a `--settle` touch owes. It runs after the touch with the | ||
| * settle parameters the daemon forwarded, the pre-action tree, and a capture taken through the | ||
| * config's backend and stored as the session snapshot; its answer rides on the result. | ||
| */ | ||
| observeSettle?: (params: { | ||
| settle: SettleParams; | ||
| baseline: SnapshotNode[] | undefined; | ||
| /** The quality verdict of the tree the touch resolved against, when it published one. */ | ||
| baselineQuality: SnapshotQualityVerdict | undefined; | ||
| capture: (options?: { | ||
| signal?: AbortSignal; | ||
| preferredBackend?: SnapshotPreferredBackend; | ||
| }) => Promise<SnapshotState>; | ||
| }) => Promise<SettleObservation>; | ||
| /** Members the suite answers itself, layered over the touch actions (selector reads, gestures). */ | ||
| members?: (config: AgentDeviceRuntimeConfig) => CommandSurfaceMembers; | ||
| }; | ||
|
|
||
| function defaultMatchSelector(nodes: SnapshotNode[], selector: string): SnapshotNode | undefined { | ||
| const parsed = /^(label|id)="?([^"]*)"?$/.exec(selector.trim()); | ||
| if (!parsed) return undefined; | ||
| const [, key, value] = parsed; | ||
| return nodes.find((node) => (key === 'id' ? node.identifier : node.label) === value); | ||
| } | ||
|
|
||
| function refBody(ref: string): string { | ||
| return ref.replace(/^@/, '').replace(/~s\d+$/, ''); | ||
| } | ||
|
|
||
| /** | ||
| * A command surface whose touch actions do what the daemon depends on the real command family | ||
| * to do, and no more: locate the target (a stored-tree lookup for a `@ref`, one interactive | ||
| * capture through the config's backend for a selector), dispatch the touch through the | ||
| * config's backend, and answer with the resolved target and the tree it was read from. Selector | ||
| * disambiguation, hittability, the moving-target gate, and every other command-family decision | ||
| * are out of scope: the suites that use this surface pin what the daemon does around them. | ||
| */ | ||
| export function touchForwardingCreateCommandSurface( | ||
| options: TouchForwardingSurfaceOptions = {}, | ||
| ): CreateDaemonCommandSurface { | ||
| const matchSelector = options.matchSelector ?? defaultMatchSelector; | ||
| return commandSurfaceWithMembers((config) => { | ||
| const locate = async ( | ||
| target: InteractionTarget, | ||
| context: { session?: string; requestId?: string; signal?: AbortSignal }, | ||
| action: 'click' | 'fill', | ||
| ): Promise<TouchResolution> => { | ||
| const chainFor = (node: SnapshotNode, nodes: SnapshotNode[]) => | ||
| buildSelectorChainForNode(node, config.backend.platform, { action, nodes }); | ||
| if (target.kind === 'point') return { point: { x: target.x, y: target.y }, target }; | ||
| if (target.kind === 'ref') { | ||
| const record = await config.sessions?.get(context.session ?? 'default'); | ||
| const tree = record?.refFrameSnapshot ?? record?.snapshot; | ||
| const nodes = tree?.nodes ?? []; | ||
| const node = nodes.find((candidate) => candidate.ref === refBody(target.ref)); | ||
| if (!node?.rect) { | ||
| throw new AppError('COMMAND_FAILED', `Ref ${target.ref} not found`, { | ||
| reason: 'ref_not_found', | ||
| dispatched: 'no', | ||
| }); | ||
| } | ||
| return { | ||
| point: centerOfRect(node.rect), | ||
| target, | ||
| node, | ||
| nodes, | ||
| selectorChain: chainFor(node, nodes), | ||
| snapshotQuality: tree?.snapshotQuality, | ||
| }; | ||
| } | ||
| const { nodes, snapshotQuality } = await captureAndStoreSnapshot(config, { | ||
| ...context, | ||
| interactiveOnly: true, | ||
| includeRects: true, | ||
| }); | ||
| const node = matchSelector(nodes, target.selector); | ||
| if (node?.rect) { | ||
| return { | ||
| point: centerOfRect(node.rect), | ||
| target, | ||
| node, | ||
| nodes, | ||
| selectorChain: chainFor(node, nodes), | ||
| snapshotQuality, | ||
| }; | ||
| } | ||
| throw new AppError('COMMAND_FAILED', `Selector did not match: ${target.selector}`, { | ||
| reason: 'selector_not_found', | ||
| dispatched: 'no', | ||
| }); | ||
| }; | ||
|
|
||
| const resolvedTarget = (resolution: TouchResolution) => { | ||
| const { target, point, node, nodes, selectorChain } = resolution; | ||
| const preAction = nodes ? { nodes } : undefined; | ||
| if (target.kind === 'point') return { kind: 'point' as const, point, preAction }; | ||
| if (target.kind === 'ref') { | ||
| return { kind: 'ref' as const, point, target, node, selectorChain, preAction }; | ||
| } | ||
| return { | ||
| kind: 'selector' as const, | ||
| point, | ||
| target, | ||
| node: node as SnapshotNode, | ||
| selectorChain: selectorChain ?? [], | ||
| preAction, | ||
| }; | ||
| }; | ||
|
|
||
| const observe = async ( | ||
| context: { session?: string; requestId?: string; signal?: AbortSignal }, | ||
| resolution: TouchResolution, | ||
| settle: SettleParams | undefined, | ||
| ): Promise<SettleObservation | undefined> => { | ||
| if (!settle || !options.observeSettle) return undefined; | ||
| return await options.observeSettle({ | ||
| settle, | ||
| baseline: resolution.nodes, | ||
| baselineQuality: resolution.snapshotQuality, | ||
| capture: (capture) => | ||
| captureAndStoreSnapshot(config, { | ||
| ...context, | ||
| signal: capture?.signal ?? context.signal, | ||
| preferredBackend: capture?.preferredBackend, | ||
| includeRects: true, | ||
| }), | ||
| }); | ||
| }; | ||
|
|
||
| const press = async ( | ||
| target: InteractionTarget, | ||
| pressOptions?: { | ||
| session?: string; | ||
| requestId?: string; | ||
| signal?: AbortSignal; | ||
| settle?: SettleParams; | ||
| }, | ||
| ): Promise<PressCommandResult> => { | ||
| const context = { | ||
| session: pressOptions?.session, | ||
| requestId: pressOptions?.requestId, | ||
| signal: pressOptions?.signal, | ||
| }; | ||
| const resolution = await locate(target, context, 'click'); | ||
| const backendResult = await config.backend.tap?.(context, resolution.point); | ||
| const settle = await observe(context, resolution, pressOptions?.settle); | ||
| return { | ||
| ...resolvedTarget(resolution), | ||
| ...(backendResult ? { backendResult } : {}), | ||
| ...(settle ? { settle } : {}), | ||
| }; | ||
| }; | ||
|
|
||
| const fill = async ( | ||
| target: InteractionTarget, | ||
| text: string, | ||
| fillOptions?: { | ||
| session?: string; | ||
| requestId?: string; | ||
| signal?: AbortSignal; | ||
| settle?: SettleParams; | ||
| }, | ||
| ): Promise<FillCommandResult> => { | ||
| const context = { | ||
| session: fillOptions?.session, | ||
| requestId: fillOptions?.requestId, | ||
| signal: fillOptions?.signal, | ||
| }; | ||
| const resolution = await locate(target, context, 'fill'); | ||
| const backendResult = await config.backend.fill?.(context, resolution.point, text); | ||
| const settle = await observe(context, resolution, fillOptions?.settle); | ||
| return { | ||
| ...resolvedTarget(resolution), | ||
| text, | ||
| ...(backendResult ? { backendResult } : {}), | ||
| ...(settle ? { settle } : {}), | ||
| }; | ||
| }; | ||
|
|
||
| const own = options.members?.(config) ?? {}; | ||
| return { | ||
| capture: own.capture, | ||
| selectors: own.selectors, | ||
| interactions: { press, click: press, fill, ...own.interactions }, | ||
| }; | ||
| }); | ||
| } |
63 changes: 63 additions & 0 deletions
63
...src/execution/interaction/internal/interaction-android-escape-dispatch-disclosure.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| import assert from 'node:assert/strict'; | ||
| import fs from 'node:fs'; | ||
| import { test } from 'vitest'; | ||
| import { AppError } from '@agent-device/kernel/errors'; | ||
| import type { AndroidObservationAdapter } from '@agent-device/contracts/android-observation'; | ||
| import { | ||
| assertDispatchDisclosureDriversMatchRows, | ||
| DISPATCH_DISCLOSURE_TABLE_PATH, | ||
| dispatchDisclosureRowsOwnedBy, | ||
| } from '@agent-device/test-kit'; | ||
| import { makeAndroidSession } from '../../../__tests__/support/session-factories.ts'; | ||
| import { discloseRequestDispatch } from '../../request-dispatch-disclosure.ts'; | ||
| import { createRequestDispatchLedger } from '../../../core/request-dispatch-ledger.ts'; | ||
| import { assertAndroidPressStayedInApp } from './interaction-android-escape.ts'; | ||
|
|
||
| // contracts/fixtures/dispatch-disclosure.json, post-action guard rows: the guard is driven itself, | ||
| // inside the router's dispatch seam. | ||
|
|
||
| async function pressThatLeftTheApp(): Promise<unknown> { | ||
| const session = makeAndroidSession('dispatch-disclosure-android', { | ||
| appBundleId: 'com.example.app', | ||
| }); | ||
| const observation = { | ||
| readAppState: async () => ({ package: 'com.android.settings' }), | ||
| isPermissionPackage: async () => false, | ||
| } as unknown as AndroidObservationAdapter; | ||
| return await discloseRequestDispatch( | ||
| { token: 't', session: session.name, command: 'press', positionals: ['@e1'] }, | ||
| createRequestDispatchLedger(), | ||
| async () => { | ||
| await assertAndroidPressStayedInApp(session, '@e1', observation); | ||
| return null; | ||
| }, | ||
| ); | ||
| } | ||
|
|
||
| const DRIVERS: Record<string, () => Promise<unknown>> = { | ||
| 'post-action-guard.android-press-left-app': pressThatLeftTheApp, | ||
| }; | ||
|
|
||
| const ROWS = dispatchDisclosureRowsOwnedBy( | ||
| import.meta.url, | ||
| fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), | ||
| ); | ||
|
|
||
| test('every post-action guard dispatch-disclosure row has exactly one driver', () => { | ||
| assertDispatchDisclosureDriversMatchRows(ROWS, Object.keys(DRIVERS)); | ||
| }); | ||
|
|
||
| for (const row of ROWS) { | ||
| test(`${row.id}: ${row.trigger} → dispatched ${row.dispatched}`, async () => { | ||
| const drive = DRIVERS[row.id]; | ||
| assert.ok(drive, `no driver for ${row.id}`); | ||
| await assert.rejects(drive(), (error: unknown) => { | ||
| assert.ok(error instanceof AppError); | ||
| assert.equal(error.code, 'COMMAND_FAILED'); | ||
| assert.equal(error.details?.expectedPackage, 'com.example.app'); | ||
| assert.equal(error.details?.foregroundPackage, 'com.android.settings'); | ||
| assert.equal(error.details?.dispatched, row.dispatched); | ||
| return true; | ||
| }); | ||
| }); | ||
| } | ||
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.