@autifyhq/aximo-cli 0.9.0 → 0.10.1

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 (35) hide show
  1. package/README.md +1 -1
  2. package/dist/commands/device/connect.js +19 -1
  3. package/dist/commands/device/doctor.d.ts +2 -0
  4. package/dist/commands/device/doctor.js +37 -10
  5. package/dist/commands/device/doctor.test.js +26 -1
  6. package/dist/commands/mcp/server.test.js +3 -1
  7. package/dist/commands/mcp/tools.js +28 -0
  8. package/dist/commands/mcp/tools.test.js +90 -0
  9. package/dist/commands/mobile/connect-core.d.ts +2 -2
  10. package/dist/commands/mobile/connect-core.js +35 -2
  11. package/dist/commands/mobile/signing-consent.js +5 -11
  12. package/dist/desktop-server/preflight.d.ts +43 -0
  13. package/dist/desktop-server/preflight.js +78 -0
  14. package/dist/desktop-server/preflight.test.d.ts +1 -0
  15. package/dist/desktop-server/preflight.test.js +115 -0
  16. package/dist/mobile-control/appium-controller.d.ts +24 -0
  17. package/dist/mobile-control/appium-controller.js +221 -127
  18. package/dist/mobile-control/appium-controller.test.js +105 -0
  19. package/dist/mobile-control/frame-sender.d.ts +34 -0
  20. package/dist/mobile-control/frame-sender.js +96 -0
  21. package/dist/mobile-control/frame-sender.test.d.ts +1 -0
  22. package/dist/mobile-control/frame-sender.test.js +108 -0
  23. package/dist/mobile-control/mjpeg-stream.d.ts +43 -0
  24. package/dist/mobile-control/mjpeg-stream.js +160 -0
  25. package/dist/mobile-control/mjpeg-stream.test.d.ts +1 -0
  26. package/dist/mobile-control/mjpeg-stream.test.js +93 -0
  27. package/dist/mobile-control/tunnel.d.ts +10 -1
  28. package/dist/mobile-control/tunnel.js +27 -3
  29. package/dist/mobile-control/tunnel.test.js +50 -0
  30. package/dist/mobile-control/webdriver.d.ts +2 -0
  31. package/dist/mobile-control/webdriver.js +6 -0
  32. package/dist/prompt.d.ts +11 -0
  33. package/dist/prompt.js +19 -0
  34. package/dist/types.d.ts +17 -0
  35. package/package.json +1 -1
@@ -52,7 +52,7 @@ function defaultConnect(wsUrl, deviceToken) {
52
52
  // Mirrors the gateway's lock lease: a session quiet for longer than this has lost
53
53
  // its server-side lock, so a new session id is a legitimate takeover, not a stray.
54
54
  const SESSION_LEASE_MS = 25 * 60 * 1000;
55
- export function registerHandlers(socket, controller, installApp) {
55
+ export function registerHandlers(socket, controller, installApp, stream) {
56
56
  // Device-side guard against interleaved sessions: the gateway announces owner
57
57
  // changes (session_acquired/session_released) and tags each command with its
58
58
  // session id, so commands from a session that no longer owns the device are
@@ -70,8 +70,22 @@ export function registerHandlers(socket, controller, installApp) {
70
70
  const sessionId = args?.sessionId;
71
71
  if (activeSession && activeSession.id === sessionId) {
72
72
  activeSession = null;
73
+ // No owning session -> nobody may watch the phone.
74
+ stream?.stop();
73
75
  }
74
76
  });
77
+ // Demand gating: the gateway asks for frames only while a session owns the
78
+ // device AND at least one viewer is reading them; it signals idle explicitly
79
+ // (and the frame acks carry the same signal as a backstop). The device-side
80
+ // owner check mirrors the command guard: a stray start from a gateway
81
+ // instance with stale state must not stream an idle phone. A start dropped
82
+ // here self-heals — the gateway re-signals while viewers keep reading, and
83
+ // the owner becomes known on session_acquired or the next tagged command.
84
+ socket.on("stream_start", () => {
85
+ if (activeSession)
86
+ stream?.start();
87
+ });
88
+ socket.on("stream_stop", () => stream?.stop());
75
89
  const on = (event, handler) => {
76
90
  socket.on(event, (rawArgs, ack) => {
77
91
  // Destructure the routing-only tag off a clone so the caller's payload
@@ -88,7 +102,13 @@ export function registerHandlers(socket, controller, installApp) {
88
102
  }
89
103
  void handler(args)
90
104
  .then((result) => ack?.(result))
91
- .catch((err) => ack?.(fail(err)));
105
+ .catch((err) => {
106
+ const failure = fail(err);
107
+ // The server may collapse this ack into a generic error; the terminal
108
+ // is the only place the device-side cause is guaranteed to be visible.
109
+ console.error(`Command "${event}" failed: ${failure.error}`);
110
+ ack?.(failure);
111
+ });
92
112
  });
93
113
  };
94
114
  on("ping", async () => ok());
@@ -172,7 +192,7 @@ export function registerHandlers(socket, controller, installApp) {
172
192
  export async function runControlTunnel(options) {
173
193
  const connect = options.connect ?? defaultConnect;
174
194
  const socket = connect(options.wsUrl, options.deviceToken);
175
- registerHandlers(socket, options.controller, options.installApp);
195
+ registerHandlers(socket, options.controller, options.installApp, options.stream);
176
196
  await new Promise((resolve, reject) => {
177
197
  let shuttingDown = false;
178
198
  let serverKicks = [];
@@ -180,6 +200,7 @@ export async function runControlTunnel(options) {
180
200
  if (shuttingDown)
181
201
  return;
182
202
  shuttingDown = true;
203
+ options.stream?.stop();
183
204
  socket.close();
184
205
  resolve();
185
206
  };
@@ -211,6 +232,9 @@ export async function runControlTunnel(options) {
211
232
  console.error(`Connection error: ${err.message}`);
212
233
  });
213
234
  socket.on("disconnect", (reason) => {
235
+ // Whatever the reason, a disconnected tunnel means no viewer can be
236
+ // watching — stop capture; a reconnect re-arms via stream_start.
237
+ options.stream?.stop();
214
238
  // A SIGINT/SIGTERM teardown closes the socket itself; cleanup() already
215
239
  // resolved the promise, so don't report our own close as an error.
216
240
  if (shuttingDown)
@@ -49,6 +49,23 @@ describe("registerHandlers", () => {
49
49
  expect(Buffer.isBuffer(result)).toBe(true);
50
50
  expect(result.equals(Buffer.from([1, 2, 3]))).toBe(true);
51
51
  });
52
+ it("a throwing handler acks the failure and logs it to the terminal", async () => {
53
+ const logSpy = vi.spyOn(console, "error").mockImplementation(() => { });
54
+ try {
55
+ const screenshot = vi.fn(async () => {
56
+ throw new Error("Hidden API policy cannot be enabled");
57
+ });
58
+ const socket = wire(stubController({ screenshot }));
59
+ await expect(socket.emit("screenshot", {})).resolves.toEqual({
60
+ ok: false,
61
+ error: "Hidden API policy cannot be enabled",
62
+ });
63
+ expect(logSpy).toHaveBeenCalledWith('Command "screenshot" failed: Hidden API policy cannot be enabled');
64
+ }
65
+ finally {
66
+ logSpy.mockRestore();
67
+ }
68
+ });
52
69
  it("tap forwards coordinates and acks ok", async () => {
53
70
  const tap = vi.fn(async () => { });
54
71
  const socket = wire(stubController({ tap }));
@@ -310,3 +327,36 @@ describe("registerHandlers session guard", () => {
310
327
  expect(tap).toHaveBeenCalledTimes(2);
311
328
  });
312
329
  });
330
+ describe("stream lifecycle handlers", () => {
331
+ function wireWithStream() {
332
+ const socket = new FakeSocket();
333
+ const stream = { start: vi.fn(), stop: vi.fn() };
334
+ registerHandlers(socket, stubController(), undefined, stream);
335
+ return { socket, stream };
336
+ }
337
+ it("starts and stops capture on gateway demand signals while a session owns the device", () => {
338
+ const { socket, stream } = wireWithStream();
339
+ socket.handlers.get("session_acquired")?.({ sessionId: "s1" }, undefined);
340
+ socket.handlers.get("stream_start")?.({}, undefined);
341
+ expect(stream.start).toHaveBeenCalledTimes(1);
342
+ socket.handlers.get("stream_stop")?.({}, undefined);
343
+ expect(stream.stop).toHaveBeenCalledTimes(1);
344
+ });
345
+ it("drops stream_start when no session owns the device", () => {
346
+ const { socket, stream } = wireWithStream();
347
+ socket.handlers.get("stream_start")?.({}, undefined);
348
+ expect(stream.start).not.toHaveBeenCalled();
349
+ });
350
+ it("stops capture when the owning session releases the device", () => {
351
+ const { socket, stream } = wireWithStream();
352
+ socket.handlers.get("session_acquired")?.({ sessionId: "s1" }, undefined);
353
+ socket.handlers.get("session_released")?.({ sessionId: "s1" }, undefined);
354
+ expect(stream.stop).toHaveBeenCalledTimes(1);
355
+ });
356
+ it("ignores a release from a session that does not own the device", () => {
357
+ const { socket, stream } = wireWithStream();
358
+ socket.handlers.get("session_acquired")?.({ sessionId: "s1" }, undefined);
359
+ socket.handlers.get("session_released")?.({ sessionId: "other" }, undefined);
360
+ expect(stream.stop).not.toHaveBeenCalled();
361
+ });
362
+ });
@@ -33,5 +33,7 @@ export declare class WebDriverSession {
33
33
  elementSendKeys(elementRef: string, text: string): Promise<void>;
34
34
  elementDisplayed(elementRef: string): Promise<boolean>;
35
35
  execute(script: string, args?: unknown[]): Promise<unknown>;
36
+ /** Appium extension endpoint: update driver settings for the current session. */
37
+ updateSettings(settings: Record<string, unknown>): Promise<void>;
36
38
  }
37
39
  export declare const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
@@ -128,6 +128,12 @@ export class WebDriverSession {
128
128
  args,
129
129
  });
130
130
  }
131
+ /** Appium extension endpoint: update driver settings for the current session. */
132
+ async updateSettings(settings) {
133
+ await this.transport.request("POST", `/session/${this.requireId()}/appium/settings`, {
134
+ settings,
135
+ });
136
+ }
131
137
  }
132
138
  export const ELEMENT_KEY = "element-6066-11e4-a52e-4f735466cecf";
133
139
  function elementId(value) {
@@ -0,0 +1,11 @@
1
+ export interface ConfirmOptions {
2
+ /** Whether a human can answer; defaults to stdin being a TTY. */
3
+ interactive?: boolean;
4
+ ask?: (question: string) => Promise<string>;
5
+ }
6
+ /**
7
+ * Yes-by-default confirmation. Enter proceeds; only an explicit n/no declines.
8
+ * Unattended callers (no TTY) proceed without asking, so CI and daemonized
9
+ * devices keep working instead of hanging on a prompt nobody can see.
10
+ */
11
+ export declare function confirmYesNo(question: string, options?: ConfirmOptions): Promise<boolean>;
package/dist/prompt.js ADDED
@@ -0,0 +1,19 @@
1
+ import { createInterface } from "node:readline/promises";
2
+ function defaultAsk(question) {
3
+ // stderr, so prompts never contaminate the JSON envelope on stdout.
4
+ const rl = createInterface({ input: process.stdin, output: process.stderr });
5
+ return rl.question(question).finally(() => rl.close());
6
+ }
7
+ /**
8
+ * Yes-by-default confirmation. Enter proceeds; only an explicit n/no declines.
9
+ * Unattended callers (no TTY) proceed without asking, so CI and daemonized
10
+ * devices keep working instead of hanging on a prompt nobody can see.
11
+ */
12
+ export async function confirmYesNo(question, options = {}) {
13
+ const interactive = options.interactive ?? process.stdin.isTTY === true;
14
+ if (!interactive)
15
+ return true;
16
+ const ask = options.ask ?? defaultAsk;
17
+ const answer = (await ask(`${question} [Y/n] `)).trim().toLowerCase();
18
+ return answer !== "n" && answer !== "no";
19
+ }
package/dist/types.d.ts CHANGED
@@ -109,6 +109,23 @@ export interface PlanRun {
109
109
  status?: string | undefined;
110
110
  [key: string]: unknown;
111
111
  }
112
+ export interface PlanRunSummary {
113
+ id: string;
114
+ status: string;
115
+ triggerSource: string | null;
116
+ executionMode: string;
117
+ totalCases: number;
118
+ completedCases: number;
119
+ passedCases: number;
120
+ failedCases: number;
121
+ startedAt: string | null;
122
+ completedAt: string | null;
123
+ createdAt: string;
124
+ }
125
+ export interface PlanRunList {
126
+ items: PlanRunSummary[];
127
+ nextCursor?: string | undefined;
128
+ }
112
129
  export interface Device {
113
130
  arn?: string | undefined;
114
131
  name?: string | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@autifyhq/aximo-cli",
3
- "version": "0.9.0",
3
+ "version": "0.10.1",
4
4
  "description": "Autify Aximo Command Line Interface (CLI)",
5
5
  "keywords": [
6
6
  "autify",