|
| 1 | +import { beforeEach, expect, test, vi } from 'vitest'; |
| 2 | + |
| 3 | +vi.mock('../adb.ts', () => ({ runAndroidAdb: vi.fn(), runAndroidShell: vi.fn() })); |
| 4 | +vi.mock('@agent-device/host-kit/retry', async (importOriginal) => { |
| 5 | + const actual = await importOriginal<typeof import('@agent-device/host-kit/retry')>(); |
| 6 | + return { ...actual, sleep: vi.fn(async () => {}) }; |
| 7 | +}); |
| 8 | +vi.mock('@agent-device/host-kit/diagnostics', async (importOriginal) => { |
| 9 | + const actual = await importOriginal<typeof import('@agent-device/host-kit/diagnostics')>(); |
| 10 | + return { ...actual, emitDiagnostic: vi.fn() }; |
| 11 | +}); |
| 12 | + |
| 13 | +import { sleep } from '@agent-device/host-kit/retry'; |
| 14 | + |
| 15 | +import { runAndroidAdb, runAndroidShell } from '../adb.ts'; |
| 16 | +import { ANDROID_EMULATOR } from '../runtime.fixtures.ts'; |
| 17 | +import { ANDROID_FOLD_SETTLE_ATTEMPTS, setAndroidFoldPose } from './pose.ts'; |
| 18 | + |
| 19 | +const mockAdb = vi.mocked(runAndroidAdb); |
| 20 | +const mockShell = vi.mocked(runAndroidShell); |
| 21 | +const mockSleep = vi.mocked(sleep); |
| 22 | + |
| 23 | +/** `cmd device_state print-states` on a Pixel fold: the posture states are numbered from 0. */ |
| 24 | +const PIXEL_FOLD_STATES = `Supported states: [ |
| 25 | + DeviceState{identifier=0, name='CLOSED', app_accessible=true, cancel_when_requester_not_on_top=false}, |
| 26 | + DeviceState{identifier=1, name='HALF_OPENED', app_accessible=true, cancel_when_requester_not_on_top=false}, |
| 27 | + DeviceState{identifier=2, name='OPENED', app_accessible=true, cancel_when_requester_not_on_top=false}, |
| 28 | + DeviceState{identifier=3, name='REAR_DISPLAY_MODE', app_accessible=true, cancel_when_requester_not_on_top=false}, |
| 29 | +] |
| 30 | +`; |
| 31 | +/** The generic "7.6in Foldable" profile numbers the same states from 1. */ |
| 32 | +const GENERIC_FOLDABLE_STATES = `Supported states: [ |
| 33 | + DeviceState{identifier=1, name='CLOSED', app_accessible=true, cancel_when_requester_not_on_top=false}, |
| 34 | + DeviceState{identifier=2, name='HALF_OPENED', app_accessible=true, cancel_when_requester_not_on_top=false}, |
| 35 | + DeviceState{identifier=3, name='OPENED', app_accessible=true, cancel_when_requester_not_on_top=false}, |
| 36 | +] |
| 37 | +`; |
| 38 | +/** A phone profile has one state and no hinge. */ |
| 39 | +const PHONE_STATES = `Supported states: [ |
| 40 | + DeviceState{identifier=0, name='DEFAULT', app_accessible=true, cancel_when_requester_not_on_top=false}, |
| 41 | +] |
| 42 | +`; |
| 43 | + |
| 44 | +function answer(stdout: string) { |
| 45 | + return { stdout, stderr: '', exitCode: 0 }; |
| 46 | +} |
| 47 | + |
| 48 | +/** The last entry of a read sequence repeats, as a device holding its state would. */ |
| 49 | +function nextRead<T>(reads: T[]): T { |
| 50 | + return reads.length > 1 ? reads.shift()! : reads[0]!; |
| 51 | +} |
| 52 | + |
| 53 | +/** |
| 54 | + * One emulator, answered by command: `stateReads` are the successive `print-state` replies, |
| 55 | + * `keyguardReads` the successive `isKeyguardShowing` values, `posture` the console's reply. |
| 56 | + */ |
| 57 | +function stubEmulator(options: { |
| 58 | + states?: string; |
| 59 | + stateReads: string[]; |
| 60 | + keyguardReads?: boolean[]; |
| 61 | + posture?: string; |
| 62 | + hinge?: string; |
| 63 | +}) { |
| 64 | + const stateReads = [...options.stateReads]; |
| 65 | + const keyguardReads = [...(options.keyguardReads ?? [false])]; |
| 66 | + mockShell.mockImplementation(async (_device, words) => { |
| 67 | + const command = words.join(' '); |
| 68 | + if (command === 'cmd device_state print-states') { |
| 69 | + return answer(options.states ?? PIXEL_FOLD_STATES); |
| 70 | + } |
| 71 | + if (command === 'cmd device_state print-state') return answer(`${nextRead(stateReads)}\n`); |
| 72 | + if (command === 'dumpsys window') { |
| 73 | + return answer(` isKeyguardShowing=${nextRead(keyguardReads)}\n`); |
| 74 | + } |
| 75 | + if (command === 'wm dismiss-keyguard') return answer(''); |
| 76 | + throw new Error(`unexpected shell command: ${command}`); |
| 77 | + }); |
| 78 | + mockAdb.mockImplementation(async (_device, args) => { |
| 79 | + const command = args.join(' '); |
| 80 | + if (command.startsWith('emu posture ')) return answer(options.posture ?? 'OK\n'); |
| 81 | + if (command === 'emu sensor get hinge-angle0') { |
| 82 | + return answer(options.hinge ?? 'hinge-angle0 = 0\nOK\n'); |
| 83 | + } |
| 84 | + throw new Error(`unexpected adb command: ${command}`); |
| 85 | + }); |
| 86 | +} |
| 87 | + |
| 88 | +function adbCommands(): string[] { |
| 89 | + return mockAdb.mock.calls.map(([, args]) => args.join(' ')); |
| 90 | +} |
| 91 | + |
| 92 | +function shellCommands(): string[] { |
| 93 | + return mockShell.mock.calls.map(([, words]) => words.join(' ')); |
| 94 | +} |
| 95 | + |
| 96 | +beforeEach(() => { |
| 97 | + mockAdb.mockReset(); |
| 98 | + mockShell.mockReset(); |
| 99 | + mockSleep.mockClear(); |
| 100 | +}); |
| 101 | + |
| 102 | +test('sets the console posture, waits for the device state, and dismisses the lock screen the fold raised', async () => { |
| 103 | + // The first state read still sees the open posture; the lock screen shows once and is gone |
| 104 | + // for two reads after the dismissal. |
| 105 | + stubEmulator({ stateReads: ['2', '0'], keyguardReads: [true, false, false] }); |
| 106 | + |
| 107 | + await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).resolves.toEqual({ |
| 108 | + pose: 'closed', |
| 109 | + hingeAngleDegrees: 0, |
| 110 | + }); |
| 111 | + |
| 112 | + expect(adbCommands()).toEqual(['emu posture 1', 'emu sensor get hinge-angle0']); |
| 113 | + expect(shellCommands()).toEqual([ |
| 114 | + 'cmd device_state print-states', |
| 115 | + 'cmd device_state print-state', |
| 116 | + 'cmd device_state print-state', |
| 117 | + 'dumpsys window', |
| 118 | + 'wm dismiss-keyguard', |
| 119 | + 'dumpsys window', |
| 120 | + 'dumpsys window', |
| 121 | + ]); |
| 122 | +}); |
| 123 | + |
| 124 | +test('looks the device state up by name, so a profile numbered from 1 verifies the same pose', async () => { |
| 125 | + stubEmulator({ |
| 126 | + states: GENERIC_FOLDABLE_STATES, |
| 127 | + stateReads: ['2'], |
| 128 | + hinge: 'hinge-angle0 = 90\nOK\n', |
| 129 | + }); |
| 130 | + |
| 131 | + await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'half-open' })).resolves.toEqual({ |
| 132 | + pose: 'half-open', |
| 133 | + hingeAngleDegrees: 90, |
| 134 | + }); |
| 135 | + expect(adbCommands()[0]).toBe('emu posture 2'); |
| 136 | + expect(shellCommands()).not.toContain('wm dismiss-keyguard'); |
| 137 | +}); |
| 138 | + |
| 139 | +test('refuses a phone profile as single-panel-device before touching the console', async () => { |
| 140 | + stubEmulator({ states: PHONE_STATES, stateReads: ['0'] }); |
| 141 | + |
| 142 | + await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'open' })).rejects.toMatchObject({ |
| 143 | + code: 'UNSUPPORTED_OPERATION', |
| 144 | + details: { reason: 'single-panel-device' }, |
| 145 | + }); |
| 146 | + expect(mockAdb).not.toHaveBeenCalled(); |
| 147 | +}); |
| 148 | + |
| 149 | +test('refuses keyframes before touching the device', async () => { |
| 150 | + stubEmulator({ stateReads: ['0'] }); |
| 151 | + |
| 152 | + await expect( |
| 153 | + setAndroidFoldPose(ANDROID_EMULATOR, { |
| 154 | + keyframes: [ |
| 155 | + { atMs: 0, angle: 0 }, |
| 156 | + { atMs: 1000, angle: 180 }, |
| 157 | + ], |
| 158 | + }), |
| 159 | + ).rejects.toMatchObject({ |
| 160 | + code: 'UNSUPPORTED_OPERATION', |
| 161 | + details: { reason: 'fold-keyframes-unsupported' }, |
| 162 | + }); |
| 163 | + expect(mockAdb).not.toHaveBeenCalled(); |
| 164 | + expect(mockShell).not.toHaveBeenCalled(); |
| 165 | +}); |
| 166 | + |
| 167 | +test('reads a KO reply as a dispatch failure even though the console exits 0', async () => { |
| 168 | + stubEmulator({ stateReads: ['0'], posture: 'KO: Posture 1 not supported\n' }); |
| 169 | + |
| 170 | + await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).rejects.toMatchObject({ |
| 171 | + code: 'COMMAND_FAILED', |
| 172 | + details: { reason: 'fold-posture-dispatch-failed' }, |
| 173 | + }); |
| 174 | +}); |
| 175 | + |
| 176 | +test('fails as fold-pose-unverified when the guest never commits the device state', async () => { |
| 177 | + stubEmulator({ stateReads: ['2'] }); |
| 178 | + |
| 179 | + await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).rejects.toMatchObject({ |
| 180 | + code: 'COMMAND_FAILED', |
| 181 | + details: { reason: 'fold-pose-unverified', observedDeviceState: 'OPENED' }, |
| 182 | + }); |
| 183 | + expect(mockSleep).toHaveBeenCalledTimes(ANDROID_FOLD_SETTLE_ATTEMPTS); |
| 184 | +}); |
| 185 | + |
| 186 | +test('fails as fold-pose-unverified when the hinge sensor disagrees with the device state', async () => { |
| 187 | + stubEmulator({ stateReads: ['0'], hinge: 'hinge-angle0 = 180\nOK\n' }); |
| 188 | + |
| 189 | + await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).rejects.toMatchObject({ |
| 190 | + code: 'COMMAND_FAILED', |
| 191 | + details: { reason: 'fold-pose-unverified', hingeAngleDegrees: 180 }, |
| 192 | + }); |
| 193 | +}); |
| 194 | + |
| 195 | +test('fails when the lock screen keeps coming back', async () => { |
| 196 | + stubEmulator({ stateReads: ['0'], keyguardReads: [true] }); |
| 197 | + |
| 198 | + await expect(setAndroidFoldPose(ANDROID_EMULATOR, { pose: 'closed' })).rejects.toMatchObject({ |
| 199 | + code: 'COMMAND_FAILED', |
| 200 | + details: { reason: 'fold-lock-screen-persists' }, |
| 201 | + }); |
| 202 | +}); |
0 commit comments