diff --git a/src/commands/device.ts b/src/commands/device.ts index f23eaaf..4788bc9 100644 --- a/src/commands/device.ts +++ b/src/commands/device.ts @@ -13,15 +13,20 @@ import { } from '../gateways/device-gateway.js'; import type { AuthContext } from '../types/domain/auth.types.js'; import { + DEVICE_KEYS, + type DeviceActionRequest, type DeviceCatalogue, type DeviceCommandResult, type DeviceHumanStatus, type DeviceInstalledApp, + type DevicePoint, type DeviceRunResponse, type DeviceScreenElement, type DeviceSession, + type DeviceSwipeDirection, ENDED_STATES, type StartDeviceSessionRequest, + SWIPE_DIRECTIONS, } from '../types/domain/device-session.types.js'; import { platformOfAppFile, uploadLocalApp } from '../utils/app-upload.js'; import { refreshAuth, resolveAuth } from '../utils/auth.js'; @@ -713,6 +718,199 @@ const launchSub = defineCommand({ }, }); +/** Shared by the typed-action subcommands: the Maestro switch. */ +const maestroArg = { + type: 'boolean', + default: false, + description: 'Run the action as a Maestro flow instead of directly on the device', +} as const; + +/** A percentage of the screen from a flag value, or a usage error. */ +function percentArg(value: unknown, flag: string): number { + const n = typeof value === 'number' ? value : Number(value); + if (typeof value === 'boolean' || value === '' || !Number.isFinite(n) || n < 0 || n > 100) { + throw new CliError(`--${flag} must be a number from 0 to 100 (a percentage of the screen).`, 1); + } + return n; +} + +/** "x,y" (percentages) from a flag value, or a usage error. */ +function pointArg(value: unknown, flag: string): DevicePoint { + const parts = typeof value === 'string' ? value.split(',') : []; + if (parts.length !== 2) { + throw new CliError(`--${flag} must be "x,y", percentages of the screen, e.g. --${flag} 50,80.`, 1); + } + return { x: percentArg(parts[0].trim(), flag), y: percentArg(parts[1].trim(), flag) }; +} + +/** + * Send one typed action and print its outcome, the way `launch` does: exit 2 + * with the result when the device could not do it. + */ +async function performAction( + args: Record, + id: string | undefined, + body: DeviceActionRequest, + labels: { done: string; failed: string }, +) { + const ctx = await context(args); + const sessionId = await resolveSessionId(ctx.apiUrl, ctx.auth, id); + const { result, session } = await DeviceGateway.action(ctx.apiUrl, ctx.auth, sessionId, { + ...body, + ...(args.maestro ? { mode: 'maestro' as const } : {}), + }); + return { + session, + data: { result } as { result: DeviceCommandResult }, + ...(!result.success && { + failure: { code: null, message: result.error || labels.failed }, + }), + print() { + logger.log(result.success ? ui.success(labels.done) : `${ui.statusSymbol('FAILED')} ${labels.failed}`); + if (result.error) logger.log(colors.error(result.error)); + }, + }; +} + +const tapSub = defineCommand({ + meta: { name: 'tap', description: 'Tap an element by text or id, or a point on the screen' }, + args: { + ...commonArgs, + id: idArg, + text: { type: 'string', description: 'The element\'s text, label or hint, matched whole, ignoring case' }, + rid: { type: 'string', description: 'The element\'s resource id (rid in `dcd device inspect`)' }, + x: { type: 'string', description: 'With --y: a point, as percentages of the screen (0-100)' }, + y: { type: 'string', description: 'See --x' }, + maestro: maestroArg, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const targets = [ + args.text !== undefined, + args.rid !== undefined, + args.x !== undefined || args.y !== undefined, + ].filter(Boolean).length; + if (targets !== 1) { + throw new CliError('tap takes exactly one target: --text, --rid, or --x and --y.', 1); + } + const body: DeviceActionRequest = + args.text !== undefined + ? { type: 'tap', text: String(args.text) } + : args.rid !== undefined + ? { type: 'tap', id: String(args.rid) } + : { type: 'tap', x: percentArg(args.x, 'x'), y: percentArg(args.y, 'y') }; + const what = + args.text !== undefined + ? `"${String(args.text)}"` + : args.rid !== undefined + ? `id ${String(args.rid)}` + : `${String(args.x)}%,${String(args.y)}%`; + return performAction(args, args.id as string | undefined, body, { + done: `Tapped ${what}`, + failed: `Tap ${what} failed`, + }); + }); + }, +}); + +const typeSub = defineCommand({ + meta: { name: 'type', description: 'Type text into the focused field' }, + args: { + ...commonArgs, + id: idArg, + text: { type: 'string', description: 'What to type', required: true }, + sensitive: { + type: 'boolean', + default: false, + description: 'The text is a secret: stored encrypted, never shown, masked in the output', + }, + maestro: maestroArg, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + const text = String(args.text ?? ''); + if (!text) throw new CliError('--text must not be empty.', 1); + const shown = args.sensitive ? `${text.length} character(s)` : `"${text}"`; + return performAction( + args, + args.id as string | undefined, + { type: 'type', text, ...(args.sensitive ? { sensitive: true } : {}) }, + { done: `Typed ${shown}`, failed: `Typing ${shown} failed` }, + ); + }); + }, +}); + +const isDirection = (value: unknown): value is DeviceSwipeDirection => + typeof value === 'string' && (SWIPE_DIRECTIONS as readonly string[]).includes(value); + +const swipeSub = defineCommand({ + meta: { name: 'swipe', description: 'Swipe up, down, left or right, or from one point to another' }, + args: { + ...commonArgs, + id: idArg, + direction: { + type: 'positional', + required: false, + description: 'up, down, left or right (a swipe across most of the screen)', + }, + from: { type: 'string', description: 'With --to: where the swipe starts, as "x,y" percentages' }, + to: { type: 'string', description: 'Where it ends, as "x,y" percentages' }, + maestro: maestroArg, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + let id = args.id as string | undefined; + let direction = args.direction as string | undefined; + // `dcd device swipe up`: the one positional is the direction, not a session. + if (id && !direction && isDirection(id)) [id, direction] = [undefined, id]; + const byPoints = args.from !== undefined || args.to !== undefined; + if ((direction === undefined) === !byPoints) { + throw new CliError('swipe takes a direction (up, down, left, right), or --from and --to.', 1); + } + let body: DeviceActionRequest; + let what: string; + if (direction !== undefined) { + if (!isDirection(direction)) { + throw new CliError(`direction must be one of ${SWIPE_DIRECTIONS.join(', ')}.`, 1); + } + body = { type: 'swipe', direction }; + what = direction; + } else { + body = { type: 'swipe', from: pointArg(args.from, 'from'), to: pointArg(args.to, 'to') }; + what = `${String(args.from)} to ${String(args.to)}`; + } + return performAction(args, id, body, { done: `Swiped ${what}`, failed: `Swipe ${what} failed` }); + }); + }, +}); + +const pressKeySub = defineCommand({ + meta: { name: 'press-key', description: `Press a key: ${DEVICE_KEYS.join(', ')}` }, + args: { + ...commonArgs, + id: idArg, + key: { type: 'positional', required: false, description: `One of ${DEVICE_KEYS.join(', ')}` }, + maestro: maestroArg, + }, + async run({ args }) { + const json = Boolean(args.json); + await runDeviceCommand(json, async () => { + let id = args.id as string | undefined; + let key = args.key as string | undefined; + // `dcd device press-key back`: the one positional is the key, not a session. + if (id && !key && (DEVICE_KEYS as readonly string[]).includes(id)) [id, key] = [undefined, id]; + if (!key || !(DEVICE_KEYS as readonly string[]).includes(key)) { + throw new CliError(`key must be one of ${DEVICE_KEYS.join(', ')}.`, 1); + } + return performAction(args, id, { type: 'press_key', key }, { done: `Pressed ${key}`, failed: `Press ${key} failed` }); + }); + }, +}); + const modelsSub = defineCommand({ meta: { name: 'models', description: 'The devices a session can ask for' }, args: { @@ -1343,6 +1541,10 @@ export const deviceCommand = defineCommand({ stop: stopSub, install: installSub, launch: launchSub, + tap: tapSub, + type: typeSub, + swipe: swipeSub, + 'press-key': pressKeySub, models: modelsSub, inspect: inspectSub, screenshot: screenshotSub, diff --git a/src/gateways/device-gateway.ts b/src/gateways/device-gateway.ts index e192396..bd99951 100644 --- a/src/gateways/device-gateway.ts +++ b/src/gateways/device-gateway.ts @@ -1,10 +1,11 @@ import type { AuthContext } from '../types/domain/auth.types.js'; import type { + DeviceActionRequest, DeviceArtifactsPage, DeviceBundleUploadUrls, - DeviceFlowBundle, DeviceCatalogue, DeviceCommandResult, + DeviceFlowBundle, DeviceHumanStatus, DeviceInstalledApp, DeviceLogs, @@ -277,6 +278,16 @@ export const DeviceGateway = { return request(baseUrl, auth, 'POST', `${sessionPath(id)}/request-human`, { reason }); }, + /** One typed action: tap, type, swipe or press_key. Synchronous: answers with the outcome. */ + action( + baseUrl: string, + auth: AuthContext, + id: string, + body: DeviceActionRequest, + ): Promise<{ result: DeviceCommandResult; session: DeviceSession }> { + return request(baseUrl, auth, 'POST', `${sessionPath(id)}/actions`, body); + }, + run( baseUrl: string, auth: AuthContext, diff --git a/src/types/domain/device-session.types.ts b/src/types/domain/device-session.types.ts index ea4d063..993539a 100644 --- a/src/types/domain/device-session.types.ts +++ b/src/types/domain/device-session.types.ts @@ -117,6 +117,44 @@ export interface DeviceFlowResult { success: boolean; } +export type DeviceSwipeDirection = 'down' | 'left' | 'right' | 'up'; +export interface DevicePoint { + /** Percentages of the screen's width and height, 0 to 100. */ + x: number; + y: number; +} + +/** + * One typed action for POST /device/sessions/:id/actions. `mode: 'maestro'` + * runs it as a Maestro flow instead of on the device directly. + */ +export type DeviceActionRequest = + | { id?: string; mode?: 'maestro'; text?: string; type: 'tap'; x?: number; y?: number } + | { key: string; mode?: 'maestro'; type: 'press_key' } + | { mode?: 'maestro'; sensitive?: boolean; text: string; type: 'type' } + | { + direction?: DeviceSwipeDirection; + from?: DevicePoint; + mode?: 'maestro'; + to?: DevicePoint; + type: 'swipe'; + }; + +/** The keys press_key takes, as the API names them. */ +export const DEVICE_KEYS = [ + 'back', + 'home', + 'enter', + 'backspace', + 'tab', + 'volume_up', + 'volume_down', + 'lock', + 'power', +] as const; + +export const SWIPE_DIRECTIONS: readonly DeviceSwipeDirection[] = ['up', 'down', 'left', 'right']; + /** The result of a typed action, a launch or a run. */ export interface DeviceCommandResult { error?: string; diff --git a/test/unit/device-command.test.ts b/test/unit/device-command.test.ts index c656f28..028fbba 100644 --- a/test/unit/device-command.test.ts +++ b/test/unit/device-command.test.ts @@ -293,6 +293,98 @@ describe('dcd device', () => { }); }); + describe('typed actions (tap, type, swipe, press-key)', () => { + const ok = () => ({ body: { result: { success: true }, session: session('a-b-c') } }); + + it('tap sends one target to /actions: text, id, or a point', async () => { + const calls = stubFetch(ok); + await runJson('tap', { id: 'a-b-c', text: 'Log in' }); + await runJson('tap', { id: 'a-b-c', rid: 'login_button' }); + await runJson('tap', { id: 'a-b-c', x: '50', y: '80.5' }); + expect(calls.map((c) => [c.method, c.url, c.body])).to.deep.equal([ + ['POST', `${API}/device/sessions/a-b-c/actions`, { type: 'tap', text: 'Log in' }], + ['POST', `${API}/device/sessions/a-b-c/actions`, { type: 'tap', id: 'login_button' }], + ['POST', `${API}/device/sessions/a-b-c/actions`, { type: 'tap', x: 50, y: 80.5 }], + ]); + expect(calls[0].headers['x-dcd-source']).to.equal('cli'); + }); + + it('tap refuses no target, two targets and a bad percentage without calling the API', async () => { + const calls = stubFetch(ok); + for (const args of [ + { id: 'a-b-c' }, + { id: 'a-b-c', text: 'x', rid: 'y' }, + { id: 'a-b-c', x: '150', y: '10' }, + { id: 'a-b-c', x: 'left', y: '10' }, + ]) { + const { exit, out } = await runJson('tap', args); + expect(exit).to.equal(DEVICE_EXIT.usage); + expect(out.ok).to.equal(false); + } + expect(calls).to.have.length(0); + }); + + it('--maestro asks for the Maestro path', async () => { + const calls = stubFetch(ok); + await runJson('tap', { id: 'a-b-c', text: 'OK', maestro: true }); + expect(calls[0].body).to.deep.equal({ type: 'tap', text: 'OK', mode: 'maestro' }); + }); + + it('type sends the text, sensitive when asked, and keeps it out of the envelope', async () => { + const calls = stubFetch(ok); + const { out } = await runJson('type', { id: 'a-b-c', text: 'hunter2', sensitive: true }); + expect(calls[0].body).to.deep.equal({ type: 'type', text: 'hunter2', sensitive: true }); + expect(JSON.stringify(out)).to.not.include('hunter2'); + await runJson('type', { id: 'a-b-c', text: 'hello' }); + expect(calls[1].body).to.deep.equal({ type: 'type', text: 'hello' }); + }); + + it('swipe takes a direction as its only positional, or --from and --to', async () => { + const calls = stubFetch((call) => + call.url.endsWith('/device/sessions') ? { body: { sessions: [session('a-b-c')] } } : ok(), + ); + await runJson('swipe', { id: 'up' }); + await runJson('swipe', { id: 'a-b-c', direction: 'left' }); + await runJson('swipe', { id: 'a-b-c', from: '90,50', to: '10, 50' }); + const posts = calls.filter((c) => c.method === 'POST'); + expect(posts.map((c) => c.body)).to.deep.equal([ + { type: 'swipe', direction: 'up' }, + { type: 'swipe', direction: 'left' }, + { type: 'swipe', from: { x: 90, y: 50 }, to: { x: 10, y: 50 } }, + ]); + const { exit } = await runJson('swipe', { id: 'a-b-c', direction: 'sideways' }); + expect(exit).to.equal(DEVICE_EXIT.usage); + const both = await runJson('swipe', { id: 'a-b-c', direction: 'up', from: '1,1', to: '2,2' }); + expect(both.exit).to.equal(DEVICE_EXIT.usage); + }); + + it('press-key takes the key as its only positional when the session is implied', async () => { + const calls = stubFetch((call) => + call.url.endsWith('/device/sessions') ? { body: { sessions: [session('a-b-c')] } } : ok(), + ); + await runJson('press-key', { id: 'back' }); + await runJson('press-key', { id: 'a-b-c', key: 'enter' }); + const posts = calls.filter((c) => c.method === 'POST'); + expect(posts.map((c) => [c.url, c.body])).to.deep.equal([ + [`${API}/device/sessions/a-b-c/actions`, { type: 'press_key', key: 'back' }], + [`${API}/device/sessions/a-b-c/actions`, { type: 'press_key', key: 'enter' }], + ]); + const { exit } = await runJson('press-key', { id: 'a-b-c', key: 'menu' }); + expect(exit).to.equal(DEVICE_EXIT.usage); + }); + + it('exits 2 with the result when the device could not do the action', async () => { + stubFetch(() => ({ + body: { result: { success: false, error: 'Element not found: "Nope"' }, session: session('a-b-c') }, + })); + const { exit, out } = await runJson('tap', { id: 'a-b-c', text: 'Nope' }); + expect(exit).to.equal(DEVICE_EXIT.failed); + expect(out.ok).to.equal(false); + expect(out.data).to.deep.equal({ result: { success: false, error: 'Element not found: "Nope"' } }); + expect(out.error).to.deep.equal({ code: null, message: 'Element not found: "Nope"' }); + }); + }); + describe('install --wait', () => { const installed = { app: { binary_upload_id: 'b-2', app_id: 'com.x', reused: true } }; const withInstall = (status: 'failed' | 'installed' | 'installing', error: null | string = null) =>