diff --git a/src/daemon/interaction/internal/__tests__/interaction-touch-reference-frame.test.ts b/src/daemon/interaction/internal/__tests__/interaction-touch-reference-frame.test.ts new file mode 100644 index 0000000000..8ec76cc90e --- /dev/null +++ b/src/daemon/interaction/internal/__tests__/interaction-touch-reference-frame.test.ts @@ -0,0 +1,88 @@ +import { expect, test, vi } from 'vitest'; +import { makeSnapshotState } from '@agent-device/capture-kit/snapshot-state-fixtures'; +import { + makeAndroidSession, + makeIosAppSession, +} from '../../../../__tests__/test-utils/session-factories.ts'; +import { makeTestScreenRecordingResource } from '../../../../__tests__/test-utils/screen-recording-live-handle.ts'; +import { makeSessionStore } from '../../../../__tests__/test-utils/store-factory.ts'; +import { clearAndroidObservationFixture } from '../../../__tests__/android-observation-fixture.ts'; +import { contextFromFlags } from './interaction-touch-fixtures.ts'; +import { resolveDirectTouchReferenceFrameSafely } from '../interaction-touch-reference-frame.ts'; + +function holdProbe(result: T) { + let start!: () => void; + let release!: () => void; + const started = new Promise((resolve) => { + start = resolve; + }); + const held = new Promise((resolve) => { + release = resolve; + }); + return { + started, + release, + read: vi.fn(async () => { + start(); + await held; + return result; + }), + }; +} + +test.each( + (['android', 'ios'] as const).flatMap((platform) => + (['none', 'rebuild', 'retire-and-republish', 'replace-recording'] as const).map((change) => ({ + platform, + change, + })), + ), +)( + '$platform frame probe after $change writes only to its current recording resource', + async ({ platform, change }) => { + const store = makeSessionStore(); + const session = { android: makeAndroidSession, ios: makeIosAppSession }[platform]('default'); + const resource = makeTestScreenRecordingResource(session); + session.screenRecording = resource; + const ref = store.publish(`cwd:frame-${platform}-${change}:default`, session); + const snapshot = makeSnapshotState([ + { index: 0, type: 'Application', rect: { x: 0, y: 0, width: 390, height: 844 } }, + ]); + const capture = holdProbe(snapshot); + const screenSize = holdProbe({ width: 390, height: 844 }); + const probe = { android: screenSize, ios: capture }[platform]; + const running = resolveDirectTouchReferenceFrameSafely({ + ref, + flags: undefined, + sessionStore: store, + contextFromFlags, + captureSnapshotForSession: capture.read, + observation: { ...clearAndroidObservationFixture, readScreenSize: screenSize.read }, + }); + await probe.started; + let replacement; + if (change === 'rebuild') { + store.update(ref, { appName: 'updated during probe' }); + } else if (change === 'retire-and-republish') { + store.retire(ref); + store.publish(ref.address, session); + } else if (change === 'replace-recording') { + replacement = makeTestScreenRecordingResource(session); + store.update(ref, { screenRecording: replacement }); + } + probe.release(); + const frame = await running; + const expected = + change === 'none' || change === 'rebuild' + ? { referenceWidth: 390, referenceHeight: 844 } + : undefined; + expect(frame, change).toEqual(expected); + expect(resource.handle.inspect().touchReferenceFrame, change).toEqual(expected); + expect(replacement?.handle.inspect().touchReferenceFrame, change).toBeUndefined(); + expect(probe.read).toHaveBeenCalledOnce(); + expect(capture.read.mock.calls.length + screenSize.read.mock.calls.length).toBe(1); + if (change === 'rebuild') { + expect(store.requireCurrent(ref).appName).toBe('updated during probe'); + } + }, +); diff --git a/src/daemon/interaction/internal/interaction-touch-reference-frame.ts b/src/daemon/interaction/internal/interaction-touch-reference-frame.ts index 62955be1f9..e8c4c39389 100644 --- a/src/daemon/interaction/internal/interaction-touch-reference-frame.ts +++ b/src/daemon/interaction/internal/interaction-touch-reference-frame.ts @@ -20,10 +20,16 @@ async function resolveDirectTouchReferenceFrame(params: { params; const session = sessionStore.resolveCurrent(ref); if (!session) return undefined; - const recording = session.screenRecording?.handle; - if (!recording) { + const resource = session.screenRecording; + if (!resource) { return undefined; } + const recording = resource.handle; + const rememberFrame = (frame: GestureReferenceFrame | undefined) => { + if (!frame || sessionStore.resolveCurrent(ref)?.screenRecording !== resource) return undefined; + recording.setTouchReferenceFrame(frame); + return frame; + }; const currentFrame = recording.inspect().touchReferenceFrame; if (currentFrame) { return currentFrame; @@ -36,26 +42,21 @@ async function resolveDirectTouchReferenceFrame(params: { ) { if (!observation) throw new Error('Android observation was not injected into the request'); const size = await observation.readScreenSize(session.device); - const referenceFrame = { + return rememberFrame({ referenceWidth: size.width, referenceHeight: size.height, - }; - recording.setTouchReferenceFrame(referenceFrame); - return referenceFrame; + }); } const snapshotFrame = getSnapshotReferenceFrame(session.snapshot); if (snapshotFrame) { - recording.setTouchReferenceFrame(snapshotFrame); - return snapshotFrame; + return rememberFrame(snapshotFrame); } const snapshot = await captureSnapshotForSession(ref, flags, sessionStore, contextFromFlags, { interactiveOnly: true, }); - const referenceFrame = getSnapshotReferenceFrame(snapshot); - if (referenceFrame) recording.setTouchReferenceFrame(referenceFrame); - return referenceFrame; + return rememberFrame(getSnapshotReferenceFrame(snapshot)); } export async function resolveDirectTouchReferenceFrameSafely(params: {