|
1 | 1 | import { test } from 'vitest'; |
2 | 2 | import assert from 'node:assert/strict'; |
3 | 3 | import { createAgentDeviceClient, type AgentDeviceClientConfig } from '../client.ts'; |
| 4 | +import { runCommand } from '../commands/command-surface.ts'; |
4 | 5 | import type { DaemonRequest, DaemonResponse } from '../contracts.ts'; |
5 | 6 | import { AppError } from '../utils/errors.ts'; |
6 | 7 |
|
@@ -118,6 +119,121 @@ test('apps.open forwards explicit runtime hints through the daemon request', asy |
118 | 119 | }); |
119 | 120 | }); |
120 | 121 |
|
| 122 | +test('structured command input accepts target as deviceTarget alias when no UI target exists', async () => { |
| 123 | + const setup = createTransport(async (req) => { |
| 124 | + if (req.command === 'open') { |
| 125 | + return { |
| 126 | + ok: true, |
| 127 | + data: { |
| 128 | + session: 'qa', |
| 129 | + appName: 'Settings', |
| 130 | + appBundleId: 'com.apple.Preferences', |
| 131 | + platform: 'ios', |
| 132 | + target: 'tv', |
| 133 | + device: 'Apple TV', |
| 134 | + id: 'TV-001', |
| 135 | + kind: 'simulator', |
| 136 | + }, |
| 137 | + }; |
| 138 | + } |
| 139 | + throw new Error(`Unexpected command: ${req.command}`); |
| 140 | + }); |
| 141 | + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); |
| 142 | + |
| 143 | + await runCommand(client, 'open', { app: 'Settings', target: 'tv' }); |
| 144 | + |
| 145 | + assert.equal(setup.calls.length, 1); |
| 146 | + assert.equal(setup.calls[0]?.command, 'open'); |
| 147 | + assert.deepEqual(setup.calls[0]?.positionals, ['Settings']); |
| 148 | + assert.equal(setup.calls[0]?.flags?.target, 'tv'); |
| 149 | +}); |
| 150 | + |
| 151 | +test('structured interaction input keeps UI target separate from deviceTarget', async () => { |
| 152 | + const setup = createTransport(async (req) => { |
| 153 | + if (req.command === 'get' || req.command === 'longpress') { |
| 154 | + return { |
| 155 | + ok: true, |
| 156 | + data: { ok: true }, |
| 157 | + }; |
| 158 | + } |
| 159 | + throw new Error(`Unexpected command: ${req.command}`); |
| 160 | + }); |
| 161 | + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); |
| 162 | + |
| 163 | + await runCommand(client, 'get', { |
| 164 | + deviceTarget: 'mobile', |
| 165 | + format: 'text', |
| 166 | + target: { kind: 'ref', ref: '@e1' }, |
| 167 | + }); |
| 168 | + await runCommand(client, 'longpress', { |
| 169 | + deviceTarget: 'mobile', |
| 170 | + durationMs: 800, |
| 171 | + target: { kind: 'ref', ref: '@e2' }, |
| 172 | + }); |
| 173 | + |
| 174 | + assert.equal(setup.calls.length, 2); |
| 175 | + assert.equal(setup.calls[0]?.command, 'get'); |
| 176 | + assert.deepEqual(setup.calls[0]?.positionals, ['text', '@e1']); |
| 177 | + assert.equal(setup.calls[0]?.flags?.target, 'mobile'); |
| 178 | + assert.equal(setup.calls[1]?.command, 'longpress'); |
| 179 | + assert.deepEqual(setup.calls[1]?.positionals, ['@e2', '800']); |
| 180 | + assert.equal(setup.calls[1]?.flags?.target, 'mobile'); |
| 181 | +}); |
| 182 | + |
| 183 | +test('apps.installFromSource forwards source payload and normalizes launch identity', async () => { |
| 184 | + const setup = createTransport(async () => ({ |
| 185 | + ok: true, |
| 186 | + data: { |
| 187 | + packageName: 'com.example.demo', |
| 188 | + appName: 'Demo', |
| 189 | + launchTarget: 'com.example.demo', |
| 190 | + installablePath: '/tmp/materialized/installable/demo.apk', |
| 191 | + archivePath: '/tmp/materialized/archive/demo.zip', |
| 192 | + materializationId: 'materialized-123', |
| 193 | + materializationExpiresAt: '2026-03-13T12:00:00.000Z', |
| 194 | + }, |
| 195 | + })); |
| 196 | + const client = createAgentDeviceClient(setup.config, { transport: setup.transport }); |
| 197 | + |
| 198 | + const result = await client.apps.installFromSource({ |
| 199 | + platform: 'android', |
| 200 | + retainPaths: true, |
| 201 | + retentionMs: 60_000, |
| 202 | + source: { |
| 203 | + kind: 'url', |
| 204 | + url: 'https://example.com/demo.apk', |
| 205 | + headers: { authorization: 'Bearer token' }, |
| 206 | + }, |
| 207 | + }); |
| 208 | + |
| 209 | + assert.equal(setup.calls.length, 1); |
| 210 | + assert.equal(setup.calls[0]?.command, 'install_source'); |
| 211 | + assert.deepEqual(setup.calls[0]?.meta?.installSource, { |
| 212 | + kind: 'url', |
| 213 | + url: 'https://example.com/demo.apk', |
| 214 | + headers: { authorization: 'Bearer token' }, |
| 215 | + }); |
| 216 | + assert.equal(setup.calls[0]?.meta?.retainMaterializedPaths, true); |
| 217 | + assert.equal(setup.calls[0]?.meta?.materializedPathRetentionMs, 60_000); |
| 218 | + assert.deepEqual(result, { |
| 219 | + appName: 'Demo', |
| 220 | + appId: 'com.example.demo', |
| 221 | + bundleId: undefined, |
| 222 | + packageName: 'com.example.demo', |
| 223 | + launchTarget: 'com.example.demo', |
| 224 | + installablePath: '/tmp/materialized/installable/demo.apk', |
| 225 | + archivePath: '/tmp/materialized/archive/demo.zip', |
| 226 | + materializationId: 'materialized-123', |
| 227 | + materializationExpiresAt: '2026-03-13T12:00:00.000Z', |
| 228 | + identifiers: { |
| 229 | + session: 'qa', |
| 230 | + appId: 'com.example.demo', |
| 231 | + appBundleId: undefined, |
| 232 | + package: 'com.example.demo', |
| 233 | + }, |
| 234 | + }); |
| 235 | +}); |
| 236 | + |
121 | 237 | test('apps.installFromSource derives Android launchTarget from packageName when daemon omits it', async () => { |
122 | 238 | const setup = createTransport(async () => ({ |
123 | 239 | ok: true, |
|
0 commit comments