agent-device 0.3.0 → 0.3.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +26 -2
  2. package/dist/src/274.js +1 -0
  3. package/dist/src/bin.js +27 -22
  4. package/dist/src/daemon.js +15 -10
  5. package/ios-runner/AgentDeviceRunner/AgentDeviceRunner.xcodeproj/project.pbxproj +4 -2
  6. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +149 -0
  7. package/package.json +2 -2
  8. package/skills/agent-device/SKILL.md +8 -1
  9. package/src/cli.ts +13 -0
  10. package/src/core/__tests__/capabilities.test.ts +2 -0
  11. package/src/core/capabilities.ts +2 -0
  12. package/src/daemon/handlers/__tests__/replay-heal.test.ts +5 -0
  13. package/src/daemon/handlers/__tests__/session-reinstall.test.ts +219 -0
  14. package/src/daemon/handlers/__tests__/session.test.ts +122 -0
  15. package/src/daemon/handlers/find.ts +23 -3
  16. package/src/daemon/handlers/session.ts +175 -10
  17. package/src/daemon-client.ts +1 -24
  18. package/src/daemon.ts +1 -24
  19. package/src/platforms/__tests__/boot-diagnostics.test.ts +59 -0
  20. package/src/platforms/android/__tests__/index.test.ts +17 -0
  21. package/src/platforms/android/devices.ts +167 -42
  22. package/src/platforms/android/index.ts +101 -14
  23. package/src/platforms/boot-diagnostics.ts +128 -0
  24. package/src/platforms/ios/index.ts +161 -2
  25. package/src/platforms/ios/runner-client.ts +19 -1
  26. package/src/utils/__tests__/exec.test.ts +16 -0
  27. package/src/utils/__tests__/finders.test.ts +34 -0
  28. package/src/utils/__tests__/retry.test.ts +44 -0
  29. package/src/utils/args.ts +9 -1
  30. package/src/utils/exec.ts +39 -0
  31. package/src/utils/finders.ts +27 -9
  32. package/src/utils/retry.ts +143 -13
  33. package/src/utils/version.ts +26 -0
  34. package/dist/src/861.js +0 -1
@@ -9,10 +9,12 @@ import XCTest
9
9
  import Network
10
10
 
11
11
  final class RunnerTests: XCTestCase {
12
+ private static let springboardBundleId = "com.apple.springboard"
12
13
  private var listener: NWListener?
13
14
  private var port: UInt16 = 0
14
15
  private var doneExpectation: XCTestExpectation?
15
16
  private let app = XCUIApplication()
17
+ private lazy var springboard = XCUIApplication(bundleIdentifier: Self.springboardBundleId)
16
18
  private var currentApp: XCUIApplication?
17
19
  private var currentBundleId: String?
18
20
  private let maxRequestBytes = 2 * 1024 * 1024
@@ -36,6 +38,15 @@ final class RunnerTests: XCTestCase {
36
38
  .secureTextField,
37
39
  .textView,
38
40
  ]
41
+ // Keep blocker actions narrow to avoid false positives from generic hittable containers.
42
+ private let actionableTypes: Set<XCUIElement.ElementType> = [
43
+ .button,
44
+ .cell,
45
+ .link,
46
+ .menuItem,
47
+ .checkBox,
48
+ .switch,
49
+ ]
39
50
 
40
51
  override func setUp() {
41
52
  continueAfterFailure = false
@@ -535,6 +546,10 @@ final class RunnerTests: XCTestCase {
535
546
  }
536
547
 
537
548
  private func snapshotFast(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
549
+ if let blocking = blockingSystemAlertSnapshot() {
550
+ return blocking
551
+ }
552
+
538
553
  var nodes: [SnapshotNode] = []
539
554
  var truncated = false
540
555
  let maxDepth = options.depth ?? Int.max
@@ -636,6 +651,10 @@ final class RunnerTests: XCTestCase {
636
651
  }
637
652
 
638
653
  private func snapshotRaw(app: XCUIApplication, options: SnapshotOptions) -> DataPayload {
654
+ if let blocking = blockingSystemAlertSnapshot() {
655
+ return blocking
656
+ }
657
+
639
658
  let root = options.scope.flatMap { findScopeElement(app: app, scope: $0) } ?? app
640
659
  var nodes: [SnapshotNode] = []
641
660
  var truncated = false
@@ -688,6 +707,136 @@ final class RunnerTests: XCTestCase {
688
707
  return DataPayload(nodes: nodes, truncated: truncated)
689
708
  }
690
709
 
710
+ private func blockingSystemAlertSnapshot() -> DataPayload? {
711
+ guard let modal = firstBlockingSystemModal(in: springboard) else {
712
+ return nil
713
+ }
714
+ let actions = actionableElements(in: modal)
715
+ guard !actions.isEmpty else {
716
+ return nil
717
+ }
718
+
719
+ let title = preferredSystemModalTitle(modal)
720
+
721
+ var nodes: [SnapshotNode] = [
722
+ makeSnapshotNode(
723
+ element: modal,
724
+ index: 0,
725
+ type: "Alert",
726
+ labelOverride: title,
727
+ identifierOverride: modal.identifier,
728
+ depth: 0,
729
+ hittableOverride: true
730
+ )
731
+ ]
732
+
733
+ for action in actions {
734
+ nodes.append(
735
+ makeSnapshotNode(
736
+ element: action,
737
+ index: nodes.count,
738
+ type: elementTypeName(action.elementType),
739
+ depth: 1,
740
+ hittableOverride: true
741
+ )
742
+ )
743
+ }
744
+
745
+ return DataPayload(nodes: nodes, truncated: false)
746
+ }
747
+
748
+ private func firstBlockingSystemModal(in springboard: XCUIApplication) -> XCUIElement? {
749
+ for alert in springboard.alerts.allElementsBoundByIndex {
750
+ if isBlockingSystemModal(alert, in: springboard) {
751
+ return alert
752
+ }
753
+ }
754
+
755
+ for sheet in springboard.sheets.allElementsBoundByIndex {
756
+ if isBlockingSystemModal(sheet, in: springboard) {
757
+ return sheet
758
+ }
759
+ }
760
+
761
+ return nil
762
+ }
763
+
764
+ private func isBlockingSystemModal(_ element: XCUIElement, in springboard: XCUIApplication) -> Bool {
765
+ guard element.exists else { return false }
766
+ let frame = element.frame
767
+ if frame.isNull || frame.isEmpty { return false }
768
+
769
+ let viewport = springboard.frame
770
+ if viewport.isNull || viewport.isEmpty { return false }
771
+
772
+ let center = CGPoint(x: frame.midX, y: frame.midY)
773
+ if !viewport.contains(center) { return false }
774
+
775
+ return true
776
+ }
777
+
778
+ private func actionableElements(in element: XCUIElement) -> [XCUIElement] {
779
+ var seen = Set<String>()
780
+ var actions: [XCUIElement] = []
781
+ let descendants = element.descendants(matching: .any).allElementsBoundByIndex
782
+ for candidate in descendants {
783
+ if !candidate.exists || !candidate.isHittable { continue }
784
+ if !actionableTypes.contains(candidate.elementType) { continue }
785
+ let frame = candidate.frame
786
+ if frame.isNull || frame.isEmpty { continue }
787
+ let key = "\(candidate.elementType.rawValue)-\(frame.origin.x)-\(frame.origin.y)-\(frame.size.width)-\(frame.size.height)-\(candidate.label)"
788
+ if seen.contains(key) { continue }
789
+ seen.insert(key)
790
+ actions.append(candidate)
791
+ }
792
+ return actions
793
+ }
794
+
795
+ private func preferredSystemModalTitle(_ element: XCUIElement) -> String {
796
+ let label = element.label
797
+ if !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
798
+ return label
799
+ }
800
+ let identifier = element.identifier
801
+ if !identifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
802
+ return identifier
803
+ }
804
+ return "System Alert"
805
+ }
806
+
807
+ private func makeSnapshotNode(
808
+ element: XCUIElement,
809
+ index: Int,
810
+ type: String,
811
+ labelOverride: String? = nil,
812
+ identifierOverride: String? = nil,
813
+ depth: Int,
814
+ hittableOverride: Bool? = nil
815
+ ) -> SnapshotNode {
816
+ let label = (labelOverride ?? element.label).trimmingCharacters(in: .whitespacesAndNewlines)
817
+ let identifier = (identifierOverride ?? element.identifier).trimmingCharacters(in: .whitespacesAndNewlines)
818
+ return SnapshotNode(
819
+ index: index,
820
+ type: type,
821
+ label: label.isEmpty ? nil : label,
822
+ identifier: identifier.isEmpty ? nil : identifier,
823
+ value: nil,
824
+ rect: snapshotRect(from: element.frame),
825
+ enabled: element.isEnabled,
826
+ hittable: hittableOverride ?? element.isHittable,
827
+ depth: depth
828
+ )
829
+ }
830
+
831
+ private func snapshotRect(from frame: CGRect) -> SnapshotRect {
832
+ return SnapshotRect(
833
+ x: Double(frame.origin.x),
834
+ y: Double(frame.origin.y),
835
+ width: Double(frame.size.width),
836
+ height: Double(frame.size.height)
837
+ )
838
+ }
839
+
691
840
  private func shouldInclude(
692
841
  element: XCUIElement,
693
842
  label: String,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-device",
3
- "version": "0.3.0",
3
+ "version": "0.3.2",
4
4
  "description": "Unified control plane for physical and virtual devices via an agent-driven CLI.",
5
5
  "license": "MIT",
6
6
  "author": "Callstack",
@@ -26,7 +26,7 @@
26
26
  "prepack": "pnpm build:node && pnpm build:axsnapshot",
27
27
  "typecheck": "tsc -p tsconfig.json",
28
28
  "test": "node --test",
29
- "test:unit": "node --test src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts",
29
+ "test:unit": "node --test src/core/__tests__/*.test.ts src/daemon/__tests__/*.test.ts src/daemon/handlers/__tests__/*.test.ts src/platforms/**/__tests__/*.test.ts src/utils/**/__tests__/*.test.ts",
30
30
  "test:smoke": "node --test test/integration/smoke-*.test.ts",
31
31
  "test:integration": "node --test test/integration/*.test.ts"
32
32
  },
@@ -27,7 +27,7 @@ npx -y agent-device
27
27
 
28
28
  ## Core workflow
29
29
 
30
- 1. Open app or just boot device: `open [app]`
30
+ 1. Open app: `open [app]` (`open` handles target selection + boot/activation in the normal flow)
31
31
  2. Snapshot: `snapshot` to get refs from accessibility tree
32
32
  3. Interact using refs (`click @ref`, `fill @ref "text"`)
33
33
  4. Re-snapshot after navigation/UI changes
@@ -38,12 +38,19 @@ npx -y agent-device
38
38
  ### Navigation
39
39
 
40
40
  ```bash
41
+ agent-device boot # Ensure target is booted/ready without opening app
42
+ agent-device boot --platform ios # Boot iOS simulator
43
+ agent-device boot --platform android # Boot Android emulator/device target
41
44
  agent-device open [app] # Boot device/simulator; optionally launch app
42
45
  agent-device open [app] --activity com.example/.MainActivity # Android: open specific activity
43
46
  agent-device close [app] # Close app or just end session
47
+ agent-device reinstall <app> <path> # Uninstall + install app in one command
44
48
  agent-device session list # List active sessions
45
49
  ```
46
50
 
51
+ `boot` requires either an active session or an explicit selector (`--platform`, `--device`, `--udid`, or `--serial`).
52
+ `boot` is a fallback, not a regular step: use it when starting a new session only if `open` cannot find/connect to an available target.
53
+
47
54
  ### Snapshot (page analysis)
48
55
 
49
56
  ```bash
package/src/cli.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { parseArgs, usage } from './utils/args.ts';
2
2
  import { asAppError, AppError } from './utils/errors.ts';
3
3
  import { formatSnapshotText, printHumanError, printJson } from './utils/output.ts';
4
+ import { readVersion } from './utils/version.ts';
4
5
  import { pathToFileURL } from 'node:url';
5
6
  import { sendToDaemon } from './daemon-client.ts';
6
7
  import fs from 'node:fs';
@@ -10,6 +11,11 @@ import path from 'node:path';
10
11
  export async function runCli(argv: string[]): Promise<void> {
11
12
  const parsed = parseArgs(argv);
12
13
 
14
+ if (parsed.flags.version) {
15
+ process.stdout.write(`${readVersion()}\n`);
16
+ process.exit(0);
17
+ }
18
+
13
19
  if (parsed.flags.help || !parsed.command) {
14
20
  process.stdout.write(`${usage()}\n`);
15
21
  process.exit(parsed.flags.help ? 0 : 1);
@@ -99,6 +105,13 @@ export async function runCli(argv: string[]): Promise<void> {
99
105
  if (logTailStopper) logTailStopper();
100
106
  return;
101
107
  }
108
+ if (command === 'boot') {
109
+ const platform = (response.data as any)?.platform ?? 'unknown';
110
+ const device = (response.data as any)?.device ?? (response.data as any)?.id ?? 'unknown';
111
+ process.stdout.write(`Boot ready: ${device} (${platform})\n`);
112
+ if (logTailStopper) logTailStopper();
113
+ return;
114
+ }
102
115
  if (command === 'click') {
103
116
  const ref = (response.data as any)?.ref ?? '';
104
117
  const x = (response.data as any)?.x;
@@ -37,6 +37,7 @@ test('iOS simulator + Android commands reject iOS devices', () => {
37
37
  'app-switcher',
38
38
  'apps',
39
39
  'back',
40
+ 'boot',
40
41
  'click',
41
42
  'close',
42
43
  'fill',
@@ -46,6 +47,7 @@ test('iOS simulator + Android commands reject iOS devices', () => {
46
47
  'home',
47
48
  'long-press',
48
49
  'open',
50
+ 'reinstall',
49
51
  'press',
50
52
  'record',
51
53
  'screenshot',
@@ -19,6 +19,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
19
19
  'app-switcher': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
20
20
  apps: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
21
21
  back: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
22
+ boot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
22
23
  click: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
23
24
  close: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
24
25
  fill: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
@@ -29,6 +30,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
29
30
  home: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
30
31
  'long-press': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
31
32
  open: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
33
+ reinstall: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
32
34
  press: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
33
35
  record: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
34
36
  screenshot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
@@ -225,6 +225,11 @@ test('replay without --update does not heal or rewrite', async () => {
225
225
 
226
226
  assert.ok(response);
227
227
  assert.equal(response.ok, false);
228
+ if (!response.ok) {
229
+ assert.match(response.error.message, /Replay failed at step 1/);
230
+ assert.equal(response.error.details?.step, 1);
231
+ assert.equal(response.error.details?.action, 'click');
232
+ }
228
233
  assert.equal(snapshotDispatchCalls, 0);
229
234
  assert.equal(fs.readFileSync(replayPath, 'utf8'), originalPayload);
230
235
  });
@@ -0,0 +1,219 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { handleSessionCommands } from '../session.ts';
7
+ import { SessionStore } from '../../session-store.ts';
8
+ import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts';
9
+
10
+ function makeStore(): SessionStore {
11
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-reinstall-'));
12
+ return new SessionStore(path.join(tempRoot, 'sessions'));
13
+ }
14
+
15
+ function makeSession(name: string, device: SessionState['device']): SessionState {
16
+ return {
17
+ name,
18
+ device,
19
+ createdAt: Date.now(),
20
+ actions: [],
21
+ };
22
+ }
23
+
24
+ const invoke = async (_req: DaemonRequest): Promise<DaemonResponse> => {
25
+ return { ok: false, error: { code: 'INVALID_ARGS', message: 'invoke should not be called in reinstall tests' } };
26
+ };
27
+
28
+ test('reinstall requires active session or explicit device selector', async () => {
29
+ const sessionStore = makeStore();
30
+ const response = await handleSessionCommands({
31
+ req: {
32
+ token: 't',
33
+ session: 'default',
34
+ command: 'reinstall',
35
+ positionals: ['com.example.app', '/tmp/app.apk'],
36
+ flags: {},
37
+ },
38
+ sessionName: 'default',
39
+ logPath: '/tmp/daemon.log',
40
+ sessionStore,
41
+ invoke,
42
+ });
43
+ assert.ok(response);
44
+ assert.equal(response.ok, false);
45
+ if (!response.ok) {
46
+ assert.equal(response.error.code, 'INVALID_ARGS');
47
+ assert.match(response.error.message, /active session or an explicit device selector/i);
48
+ }
49
+ });
50
+
51
+ test('reinstall validates required args before device operations', async () => {
52
+ const sessionStore = makeStore();
53
+ sessionStore.set(
54
+ 'default',
55
+ makeSession('default', {
56
+ platform: 'ios',
57
+ id: 'sim-1',
58
+ name: 'iPhone',
59
+ kind: 'simulator',
60
+ booted: true,
61
+ }),
62
+ );
63
+ const response = await handleSessionCommands({
64
+ req: {
65
+ token: 't',
66
+ session: 'default',
67
+ command: 'reinstall',
68
+ positionals: ['com.example.app'],
69
+ flags: {},
70
+ },
71
+ sessionName: 'default',
72
+ logPath: '/tmp/daemon.log',
73
+ sessionStore,
74
+ invoke,
75
+ });
76
+ assert.ok(response);
77
+ assert.equal(response.ok, false);
78
+ if (!response.ok) {
79
+ assert.equal(response.error.code, 'INVALID_ARGS');
80
+ assert.match(response.error.message, /reinstall <app> <path-to-app-binary>/i);
81
+ }
82
+ });
83
+
84
+ test('reinstall reports unsupported operation on iOS physical devices', async () => {
85
+ const sessionStore = makeStore();
86
+ sessionStore.set(
87
+ 'default',
88
+ makeSession('default', {
89
+ platform: 'ios',
90
+ id: 'device-1',
91
+ name: 'iPhone Device',
92
+ kind: 'device',
93
+ booted: true,
94
+ }),
95
+ );
96
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-reinstall-binary-'));
97
+ const appPath = path.join(tempRoot, 'Sample.app');
98
+ fs.writeFileSync(appPath, 'placeholder');
99
+
100
+ const response = await handleSessionCommands({
101
+ req: {
102
+ token: 't',
103
+ session: 'default',
104
+ command: 'reinstall',
105
+ positionals: ['com.example.app', appPath],
106
+ flags: {},
107
+ },
108
+ sessionName: 'default',
109
+ logPath: '/tmp/daemon.log',
110
+ sessionStore,
111
+ invoke,
112
+ });
113
+ assert.ok(response);
114
+ assert.equal(response.ok, false);
115
+ if (!response.ok) {
116
+ assert.equal(response.error.code, 'UNSUPPORTED_OPERATION');
117
+ assert.match(response.error.message, /reinstall is not supported/i);
118
+ }
119
+ });
120
+
121
+ test('reinstall succeeds on active iOS simulator session and records action', async () => {
122
+ const sessionStore = makeStore();
123
+ const session = makeSession('default', {
124
+ platform: 'ios',
125
+ id: 'sim-1',
126
+ name: 'iPhone',
127
+ kind: 'simulator',
128
+ booted: true,
129
+ });
130
+ sessionStore.set('default', session);
131
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-reinstall-success-ios-'));
132
+ const appPath = path.join(tempRoot, 'Sample.app');
133
+ fs.writeFileSync(appPath, 'placeholder');
134
+
135
+ const response = await handleSessionCommands({
136
+ req: {
137
+ token: 't',
138
+ session: 'default',
139
+ command: 'reinstall',
140
+ positionals: ['com.example.app', appPath],
141
+ flags: {},
142
+ },
143
+ sessionName: 'default',
144
+ logPath: '/tmp/daemon.log',
145
+ sessionStore,
146
+ invoke,
147
+ reinstallOps: {
148
+ ios: async (_device, app, pathToBinary) => {
149
+ assert.equal(app, 'com.example.app');
150
+ assert.equal(pathToBinary, appPath);
151
+ return { bundleId: 'com.example.app' };
152
+ },
153
+ android: async () => {
154
+ throw new Error('unexpected android reinstall');
155
+ },
156
+ },
157
+ });
158
+
159
+ assert.ok(response);
160
+ assert.equal(response.ok, true);
161
+ if (response.ok) {
162
+ assert.equal(response.data?.platform, 'ios');
163
+ assert.equal(response.data?.appId, 'com.example.app');
164
+ assert.equal(response.data?.bundleId, 'com.example.app');
165
+ assert.equal(response.data?.appPath, appPath);
166
+ }
167
+ assert.equal(session.actions.length, 1);
168
+ assert.equal(session.actions[0]?.command, 'reinstall');
169
+ });
170
+
171
+ test('reinstall succeeds on active Android session with normalized appId', async () => {
172
+ const sessionStore = makeStore();
173
+ sessionStore.set(
174
+ 'default',
175
+ makeSession('default', {
176
+ platform: 'android',
177
+ id: 'emulator-5554',
178
+ name: 'Pixel',
179
+ kind: 'emulator',
180
+ booted: true,
181
+ }),
182
+ );
183
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-reinstall-success-android-'));
184
+ const appPath = path.join(tempRoot, 'Sample.apk');
185
+ fs.writeFileSync(appPath, 'placeholder');
186
+
187
+ const response = await handleSessionCommands({
188
+ req: {
189
+ token: 't',
190
+ session: 'default',
191
+ command: 'reinstall',
192
+ positionals: ['com.example.app', appPath],
193
+ flags: {},
194
+ },
195
+ sessionName: 'default',
196
+ logPath: '/tmp/daemon.log',
197
+ sessionStore,
198
+ invoke,
199
+ reinstallOps: {
200
+ ios: async () => {
201
+ throw new Error('unexpected ios reinstall');
202
+ },
203
+ android: async (_device, app, pathToBinary) => {
204
+ assert.equal(app, 'com.example.app');
205
+ assert.equal(pathToBinary, appPath);
206
+ return { package: 'com.example.app' };
207
+ },
208
+ },
209
+ });
210
+
211
+ assert.ok(response);
212
+ assert.equal(response.ok, true);
213
+ if (response.ok) {
214
+ assert.equal(response.data?.platform, 'android');
215
+ assert.equal(response.data?.appId, 'com.example.app');
216
+ assert.equal(response.data?.package, 'com.example.app');
217
+ assert.equal(response.data?.appPath, appPath);
218
+ }
219
+ });
@@ -0,0 +1,122 @@
1
+ import test from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { handleSessionCommands } from '../session.ts';
7
+ import { SessionStore } from '../../session-store.ts';
8
+ import type { DaemonRequest, DaemonResponse, SessionState } from '../../types.ts';
9
+
10
+ function makeSessionStore(): SessionStore {
11
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-session-handler-'));
12
+ return new SessionStore(path.join(root, 'sessions'));
13
+ }
14
+
15
+ function makeSession(name: string, device: SessionState['device']): SessionState {
16
+ return {
17
+ name,
18
+ device,
19
+ createdAt: Date.now(),
20
+ actions: [],
21
+ };
22
+ }
23
+
24
+ const noopInvoke = async (_req: DaemonRequest): Promise<DaemonResponse> => ({ ok: true, data: {} });
25
+
26
+ test('boot requires session or explicit selector', async () => {
27
+ const sessionStore = makeSessionStore();
28
+ const response = await handleSessionCommands({
29
+ req: {
30
+ token: 't',
31
+ session: 'default',
32
+ command: 'boot',
33
+ positionals: [],
34
+ flags: {},
35
+ },
36
+ sessionName: 'default',
37
+ logPath: path.join(os.tmpdir(), 'daemon.log'),
38
+ sessionStore,
39
+ invoke: noopInvoke,
40
+ ensureReady: async () => {},
41
+ });
42
+ assert.ok(response);
43
+ assert.equal(response?.ok, false);
44
+ if (response && !response.ok) {
45
+ assert.equal(response.error.code, 'INVALID_ARGS');
46
+ }
47
+ });
48
+
49
+ test('boot rejects unsupported iOS device kind', async () => {
50
+ const sessionStore = makeSessionStore();
51
+ const sessionName = 'ios-device-session';
52
+ sessionStore.set(
53
+ sessionName,
54
+ makeSession(sessionName, {
55
+ platform: 'ios',
56
+ id: 'ios-device-1',
57
+ name: 'iPhone Device',
58
+ kind: 'device',
59
+ booted: true,
60
+ }),
61
+ );
62
+ const response = await handleSessionCommands({
63
+ req: {
64
+ token: 't',
65
+ session: sessionName,
66
+ command: 'boot',
67
+ positionals: [],
68
+ flags: {},
69
+ },
70
+ sessionName,
71
+ logPath: path.join(os.tmpdir(), 'daemon.log'),
72
+ sessionStore,
73
+ invoke: noopInvoke,
74
+ ensureReady: async () => {
75
+ throw new Error('ensureReady should not be called for unsupported boot');
76
+ },
77
+ });
78
+ assert.ok(response);
79
+ assert.equal(response?.ok, false);
80
+ if (response && !response.ok) {
81
+ assert.equal(response.error.code, 'UNSUPPORTED_OPERATION');
82
+ }
83
+ });
84
+
85
+ test('boot succeeds for supported device in session', async () => {
86
+ const sessionStore = makeSessionStore();
87
+ const sessionName = 'android-session';
88
+ sessionStore.set(
89
+ sessionName,
90
+ makeSession(sessionName, {
91
+ platform: 'android',
92
+ id: 'emulator-5554',
93
+ name: 'Pixel Emulator',
94
+ kind: 'emulator',
95
+ booted: true,
96
+ }),
97
+ );
98
+ let ensureCalls = 0;
99
+ const response = await handleSessionCommands({
100
+ req: {
101
+ token: 't',
102
+ session: sessionName,
103
+ command: 'boot',
104
+ positionals: [],
105
+ flags: {},
106
+ },
107
+ sessionName,
108
+ logPath: path.join(os.tmpdir(), 'daemon.log'),
109
+ sessionStore,
110
+ invoke: noopInvoke,
111
+ ensureReady: async () => {
112
+ ensureCalls += 1;
113
+ },
114
+ });
115
+ assert.ok(response);
116
+ assert.equal(response?.ok, true);
117
+ assert.equal(ensureCalls, 1);
118
+ if (response && response.ok) {
119
+ assert.equal(response.data?.platform, 'android');
120
+ assert.equal(response.data?.booted, true);
121
+ }
122
+ });