Skip to content

Commit de9028b

Browse files
committed
refactor(daemon): move app-state target selection into its own module
The remaining admission decisions (explicit-session error, selector guard, Apple-session vs device-selector routing, platform refusals) now live in session-appstate.ts; the session-state entry is a plain command dispatcher. The dispatcher gains routing tests whose each-owned-command assertion is unconditional. The app-state device runtime tests move to session-appstate-device-runtime.test.ts and the missing-session refusal test joins session-appstate-input.test.ts, one-to-one with their subjects. No behavior change. Part of #3424.
1 parent 4a3896f commit de9028b

5 files changed

Lines changed: 441 additions & 410 deletions

File tree

Lines changed: 296 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,296 @@
1+
import { daemonPlatformServicesFixture } from '../../__tests__/platform-services-fixture.ts';
2+
import { test, expect, vi } from 'vitest';
3+
4+
import { handleSessionStateCommands } from '../session-state.ts';
5+
import { makeSessionStore } from '../../../__tests__/test-utils/store-factory.ts';
6+
import { withTestDeviceInventory } from '../../../__tests__/test-utils/device-inventory-gateways.ts';
7+
import { unavailableDeploymentSnapshotAndShutdownOperationFacts } from '../../../__tests__/test-utils/runtime-operation-facts.ts';
8+
import { applicationLifecycleOperationFacts } from '@agent-device/contracts/application-lifecycle-runtime';
9+
import {
10+
type DeviceBinding,
11+
type RuntimeFacts,
12+
localRuntimeOwner,
13+
narrowDeviceBinding,
14+
} from '@agent-device/contracts/platform-runtime';
15+
import {
16+
type PlatformRuntimeOperations,
17+
appStateUse,
18+
} from '@agent-device/contracts/platform-runtime-operations';
19+
import { createUnavailablePlatformRuntimeFacts } from '@agent-device/contracts/platform-runtime-unavailable';
20+
import { deviceShape, type DeviceInfo } from '@agent-device/kernel/device';
21+
import type { BindDeviceRuntime } from '../../request-runtime-binding.ts';
22+
23+
test('appstate rejects web before Android app-state backend dispatch', async () => {
24+
const device = {
25+
platform: 'web' as const,
26+
id: 'agent-browser-chrome',
27+
name: 'Agent Browser Chrome',
28+
kind: 'device' as const,
29+
target: 'desktop' as const,
30+
booted: true,
31+
};
32+
let bound = false;
33+
const bindDevice: BindDeviceRuntime = async () => {
34+
bound = true;
35+
throw new Error('unsupported appstate must not bind');
36+
};
37+
const response = await withTestDeviceInventory(
38+
{
39+
local: async () => [device],
40+
},
41+
async () =>
42+
await handleSessionStateCommands({
43+
platformServices: daemonPlatformServicesFixture(),
44+
req: {
45+
token: 't',
46+
session: 'default',
47+
command: 'appstate',
48+
positionals: [],
49+
flags: { platform: 'web' },
50+
},
51+
sessionName: 'default',
52+
sessionStore: makeSessionStore(),
53+
bindDevice,
54+
inspectFacts: async () =>
55+
createUnavailablePlatformRuntimeFacts(device, localRuntimeOwner('web'), {
56+
appLog: { available: false, reason: 'unsupported-platform-leaf' },
57+
appState: { available: false, reason: 'unsupported-platform-leaf' },
58+
network: { available: false, reason: 'unsupported-platform-leaf' },
59+
screenshot: { available: false, reason: 'unsupported-platform-leaf' },
60+
viewport: { available: false, reason: 'unsupported-platform-leaf' },
61+
focus: { available: false, reason: 'unsupported-platform-leaf' },
62+
gesture: { available: false, reason: 'unsupported-platform-leaf' },
63+
scroll: { available: false, reason: 'unsupported-platform-leaf' },
64+
typeText: { available: false, reason: 'unsupported-platform-leaf' },
65+
touch: { available: false, reason: 'unsupported-platform-leaf' },
66+
elementText: { available: false, reason: 'unsupported-platform-leaf' },
67+
back: { available: false, reason: 'unsupported-platform-leaf' },
68+
orientation: { available: false, reason: 'unsupported-platform-leaf' },
69+
tvRemote: { available: false, reason: 'unsupported-platform-leaf' },
70+
keyboard: { available: false, reason: 'unsupported-platform-leaf' },
71+
clipboard: { available: false, reason: 'unsupported-platform-leaf' },
72+
systemButton: { available: false, reason: 'unsupported-platform-leaf' },
73+
fold: { available: false, reason: 'unsupported-platform-leaf' },
74+
triggerAppEvent: { available: false, reason: 'unsupported-platform-leaf' },
75+
settings: { available: false, reason: 'unsupported-platform-leaf' },
76+
readAlert: { available: false, reason: 'unsupported-platform-leaf' },
77+
awaitAlert: { available: false, reason: 'unsupported-platform-leaf' },
78+
acceptAlert: { available: false, reason: 'unsupported-platform-leaf' },
79+
dismissAlert: { available: false, reason: 'unsupported-platform-leaf' },
80+
audioProbeCapture: { available: false, reason: 'unsupported-platform-leaf' },
81+
audioProbeQuery: { available: false, reason: 'unsupported-platform-leaf' },
82+
readiness: { available: false, reason: 'unsupported-platform-leaf' },
83+
lifecycle: applicationLifecycleOperationFacts({
84+
resolveOpenTarget: { available: false, reason: 'unsupported-platform-leaf' },
85+
prepareApplicationOpen: { available: false, reason: 'unsupported-platform-leaf' },
86+
openApplication: { available: false, reason: 'unsupported-platform-leaf' },
87+
applyRuntimeHints: { available: false, reason: 'unsupported-platform-leaf' },
88+
clearRuntimeHints: { available: false, reason: 'unsupported-platform-leaf' },
89+
closeApplication: { available: false, reason: 'unsupported-platform-leaf' },
90+
finalizeApplicationClose: { available: false, reason: 'unsupported-platform-leaf' },
91+
prepareAppleRunner: { available: false, reason: 'unsupported-platform-leaf' },
92+
configureProviderPortReverse: {
93+
available: false,
94+
reason: 'unsupported-platform-leaf',
95+
},
96+
}),
97+
}),
98+
}),
99+
);
100+
101+
expect(response).toBeTruthy();
102+
expect(response?.ok).toBe(false);
103+
if (response && !response.ok) {
104+
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
105+
expect(response.error.message).toMatch(/appstate is not supported on web/i);
106+
}
107+
expect(bound).toBe(false);
108+
});
109+
110+
test('appstate rejects a missing readiness fact even when appState is available', async () => {
111+
const device: DeviceInfo = {
112+
platform: 'android',
113+
id: 'android-device-1',
114+
name: 'Android Device',
115+
kind: 'device',
116+
target: 'mobile',
117+
booted: true,
118+
};
119+
const facts: RuntimeFacts<PlatformRuntimeOperations> = {
120+
device: { ...deviceShape(device), providerMode: 'local' },
121+
operations: {
122+
appLogInspect: { available: false, reason: 'owner-capability-missing' },
123+
appLogDoctor: { available: false, reason: 'owner-capability-missing' },
124+
appLogStart: { available: false, reason: 'owner-capability-missing' },
125+
appLogReattach: { available: false, reason: 'owner-capability-missing' },
126+
appLogCleanup: { available: false, reason: 'owner-capability-missing' },
127+
appState: { available: true },
128+
listApps: { available: false, reason: 'owner-capability-missing' },
129+
networkDump: { available: false, reason: 'owner-capability-missing' },
130+
screenRecordingStart: { available: false, reason: 'owner-capability-missing' },
131+
screenRecordingReattach: { available: false, reason: 'owner-capability-missing' },
132+
screenRecordingCleanup: { available: false, reason: 'owner-capability-missing' },
133+
ensureReady: {
134+
available: false,
135+
reason: 'unsupported-device-kind',
136+
hint: 'readiness is unavailable for this device kind',
137+
},
138+
bootTarget: { available: false, reason: 'unsupported-device-kind' },
139+
bootTargetHeadless: { available: false, reason: 'unsupported-device-kind' },
140+
...unavailableDeploymentSnapshotAndShutdownOperationFacts,
141+
...applicationLifecycleOperationFacts({
142+
resolveOpenTarget: { available: false, reason: 'unsupported-device-kind' },
143+
prepareApplicationOpen: { available: false, reason: 'unsupported-device-kind' },
144+
openApplication: { available: false, reason: 'unsupported-device-kind' },
145+
applyRuntimeHints: { available: false, reason: 'unsupported-device-kind' },
146+
clearRuntimeHints: { available: false, reason: 'unsupported-device-kind' },
147+
closeApplication: { available: false, reason: 'unsupported-device-kind' },
148+
finalizeApplicationClose: { available: false, reason: 'unsupported-device-kind' },
149+
prepareAppleRunner: { available: false, reason: 'unsupported-device-kind' },
150+
configureProviderPortReverse: { available: false, reason: 'unsupported-device-kind' },
151+
}),
152+
},
153+
};
154+
const inspectFacts = vi.fn(async () => facts);
155+
let bound = false;
156+
const bindDevice: BindDeviceRuntime = async () => {
157+
bound = true;
158+
throw new Error('missing readiness must not bind');
159+
};
160+
const response = await withTestDeviceInventory(
161+
{ local: async () => [device] },
162+
async () =>
163+
await handleSessionStateCommands({
164+
platformServices: daemonPlatformServicesFixture(),
165+
req: {
166+
token: 't',
167+
session: 'default',
168+
command: 'appstate',
169+
positionals: [],
170+
flags: { platform: 'android', device: device.name },
171+
},
172+
sessionName: 'default',
173+
sessionStore: makeSessionStore(),
174+
inspectFacts,
175+
bindDevice,
176+
}),
177+
);
178+
179+
expect(response).toBeTruthy();
180+
expect(response?.ok).toBe(false);
181+
if (response && !response.ok) {
182+
expect(response.error.code).toBe('UNSUPPORTED_OPERATION');
183+
expect(response.error.hint).toMatch(/readiness is unavailable/i);
184+
}
185+
expect(inspectFacts).toHaveBeenCalledOnce();
186+
expect(bound).toBe(false);
187+
});
188+
test('sessionless Android appstate inspects once, binds once, and preserves operation order', async () => {
189+
const device: DeviceInfo = {
190+
platform: 'android',
191+
id: 'emulator-5554',
192+
name: 'Pixel Emulator',
193+
kind: 'emulator',
194+
target: 'mobile',
195+
booted: true,
196+
};
197+
const unavailable = { available: false, reason: 'owner-capability-missing' } as const;
198+
const available = { available: true } as const;
199+
const facts: RuntimeFacts<PlatformRuntimeOperations> = {
200+
device: { ...deviceShape(device), providerMode: 'local' },
201+
operations: {
202+
appLogInspect: unavailable,
203+
appLogDoctor: unavailable,
204+
appLogStart: unavailable,
205+
appLogReattach: unavailable,
206+
appLogCleanup: unavailable,
207+
appState: available,
208+
listApps: unavailable,
209+
networkDump: unavailable,
210+
screenRecordingStart: unavailable,
211+
screenRecordingReattach: unavailable,
212+
screenRecordingCleanup: unavailable,
213+
ensureReady: available,
214+
bootTarget: available,
215+
bootTargetHeadless: unavailable,
216+
...unavailableDeploymentSnapshotAndShutdownOperationFacts,
217+
...applicationLifecycleOperationFacts({
218+
resolveOpenTarget: unavailable,
219+
prepareApplicationOpen: unavailable,
220+
openApplication: unavailable,
221+
applyRuntimeHints: unavailable,
222+
clearRuntimeHints: unavailable,
223+
closeApplication: unavailable,
224+
finalizeApplicationClose: unavailable,
225+
prepareAppleRunner: unavailable,
226+
configureProviderPortReverse: unavailable,
227+
}),
228+
},
229+
};
230+
const events: string[] = [];
231+
const binding: DeviceBinding<PlatformRuntimeOperations> = {
232+
device,
233+
owner: localRuntimeOwner('android'),
234+
facts,
235+
operations: {
236+
ensureReady: async (input) => {
237+
events.push(`ensureReady:${JSON.stringify(input)}`);
238+
return { ...device, booted: true };
239+
},
240+
appState: async () => {
241+
events.push('appState');
242+
return { package: 'com.example.app', activity: '.MainActivity' };
243+
},
244+
},
245+
[Symbol.asyncDispose]: async () => undefined,
246+
};
247+
const inspectFacts = vi.fn(async () => {
248+
events.push('inspectFacts');
249+
return facts;
250+
});
251+
const bindDevice: BindDeviceRuntime = async (selected, use) => {
252+
events.push('bindDevice');
253+
expect(selected).toEqual(device);
254+
expect(use).toBe(appStateUse);
255+
return narrowDeviceBinding(binding, use);
256+
};
257+
258+
const response = await withTestDeviceInventory(
259+
{ local: async () => [device] },
260+
async () =>
261+
await handleSessionStateCommands({
262+
platformServices: daemonPlatformServicesFixture(),
263+
req: {
264+
token: 't',
265+
session: 'default',
266+
command: 'appstate',
267+
positionals: [],
268+
flags: {
269+
platform: 'android',
270+
device: 'Pixel Emulator',
271+
androidDeviceAllowlist: 'emulator-5555,emulator-5554',
272+
},
273+
},
274+
sessionName: 'default',
275+
sessionStore: makeSessionStore(),
276+
inspectFacts,
277+
bindDevice,
278+
}),
279+
);
280+
281+
expect(response).toEqual({
282+
ok: true,
283+
data: {
284+
platform: 'android',
285+
package: 'com.example.app',
286+
activity: '.MainActivity',
287+
},
288+
});
289+
expect(inspectFacts).toHaveBeenCalledOnce();
290+
expect(events).toEqual([
291+
'inspectFacts',
292+
'bindDevice',
293+
'ensureReady:{"androidSerialAllowlist":["emulator-5554","emulator-5555"]}',
294+
'appState',
295+
]);
296+
});

‎src/daemon/handlers/__tests__/session-appstate-input.test.ts‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import type { DeviceInfo } from '@agent-device/kernel/device';
1212
import type { SessionState } from '../../session-state.ts';
1313
import type { DaemonRequest } from '../../daemon-request.ts';
1414
import { handleSessionCommands, mockInspectDeviceRuntimeFacts } from './session-command-harness.ts';
15+
import { handleSessionStateCommands } from '../session-state.ts';
1516
import { mkdtempForTestSync } from '../../../__tests__/test-utils/tmp-dir.ts';
1617
import { AppError } from '@agent-device/kernel/errors';
1718
import type {
@@ -452,3 +453,25 @@ test('appstate on iOS keeps the session answer, with no state, when the runner c
452453
expect(response.data).not.toHaveProperty('state');
453454
}
454455
});
456+
457+
test('appstate returns missing-session error for explicit session flag', async () => {
458+
const response = await handleSessionStateCommands({
459+
platformServices: daemonPlatformServicesFixture(),
460+
req: {
461+
token: 't',
462+
session: 'named',
463+
command: 'appstate',
464+
positionals: [],
465+
flags: { platform: 'ios', session: 'named' },
466+
},
467+
sessionName: 'named',
468+
sessionStore: makeSessionStore(),
469+
});
470+
471+
expect(response).toBeTruthy();
472+
expect(response?.ok).toBe(false);
473+
if (response && !response.ok) {
474+
expect(response.error.code).toBe('SESSION_NOT_FOUND');
475+
expect(response.error.message).toMatch(/Run open with --session named first/i);
476+
}
477+
});

0 commit comments

Comments
 (0)