Skip to content

Commit 442ab59

Browse files
authored
refactor(8/12): migrate UI automation tools to event-based handlers (#326)
## Summary This is **PR 8 of 12** in a stacked PR series that decouples the rendering pipeline from MCP transport. Depends on PR 7 (device/macOS migrations). Migrates all UI automation tool handlers to the new event-based handler contract. ### Tools migrated (25 files) `button`, `gesture`, `key_press`, `key_sequence`, `long_press`, `screenshot`, `snapshot_ui`, `swipe`, `tap`, `touch`, `type_text` ### Notable changes - **Shared AXe command module** (`src/mcp/tools/ui-automation/shared/axe-command.ts`): Extracted common AXe CLI invocation logic that was duplicated across all 11 UI automation tools. Each tool had its own copy of AXe process spawning, timeout handling, and error formatting. Now consolidated into one shared module that accepts an `emit` callback. - `axe-helpers.ts` and `axe/index.ts`: Minor updates to work with the shared command module. - `screenshot.ts`: Uses `ctx.attach()` for image data instead of constructing `ToolResponseContent` directly. This is the only tool that produces non-text output. ### Pattern UI automation tools are simpler than build tools -- they invoke AXe, parse the response, and emit result events. The main simplification is removing the per-tool AXe boilerplate: \`\`\`typescript // Before: each tool had ~30 lines of AXe setup const axeResult = await executeAxeCommand({ ... }); return toolResponse([...formatResult(axeResult)]); // After: shared module handles AXe setup await executeAxeAction(ctx, { ... }); ctx.emit(statusLine('success', '...')); \`\`\` ## Stack navigation - PR 1-5/12: Foundation, utilities, runtime contract - PR 6-7/12: Simulator, device, macOS migrations - **PR 8/12** (this PR): UI automation tool migrations - PR 9/12: Remaining tool migrations - PR 10-12/12: Boundaries, config, tests ## Test plan - [ ] `npx vitest run` passes -- all UI automation tool tests updated - [ ] Screenshot tool correctly uses `ctx.attach()` for image data - [ ] Shared AXe command module correctly propagates errors via events
1 parent 7317fb4 commit 442ab59

26 files changed

+2299
-3766
lines changed

src/mcp/tools/ui-automation/__tests__/button.test.ts

Lines changed: 130 additions & 182 deletions
Large diffs are not rendered by default.

src/mcp/tools/ui-automation/__tests__/gesture.test.ts

Lines changed: 133 additions & 185 deletions
Large diffs are not rendered by default.

src/mcp/tools/ui-automation/__tests__/key_press.test.ts

Lines changed: 121 additions & 149 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,3 @@
1-
/**
2-
* Tests for key_press tool
3-
*/
4-
51
import { describe, it, expect, beforeEach } from 'vitest';
62
import * as z from 'zod';
73
import {
@@ -13,20 +9,13 @@ import {
139
import { sessionStore } from '../../../../utils/session-store.ts';
1410
import { schema, handler, key_pressLogic } from '../key_press.ts';
1511
import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts';
12+
import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
13+
1614

1715
function createDefaultMockAxeHelpers() {
1816
return {
1917
getAxePath: () => '/usr/local/bin/axe',
2018
getBundledAxeEnvironment: () => ({}),
21-
createAxeNotAvailableResponse: () => ({
22-
content: [
23-
{
24-
type: 'text' as const,
25-
text: AXE_NOT_AVAILABLE_MESSAGE,
26-
},
27-
],
28-
isError: true,
29-
}),
3019
};
3120
}
3221

@@ -98,13 +87,15 @@ describe('Key Press Tool', () => {
9887

9988
const mockAxeHelpers = createDefaultMockAxeHelpers();
10089

101-
await key_pressLogic(
102-
{
103-
simulatorId: '12345678-1234-4234-8234-123456789012',
104-
keyCode: 40,
105-
},
106-
trackingExecutor,
107-
mockAxeHelpers,
90+
await runLogic(() =>
91+
key_pressLogic(
92+
{
93+
simulatorId: '12345678-1234-4234-8234-123456789012',
94+
keyCode: 40,
95+
},
96+
trackingExecutor,
97+
mockAxeHelpers,
98+
),
10899
);
109100

110101
expect(capturedCommand).toEqual([
@@ -130,14 +121,16 @@ describe('Key Press Tool', () => {
130121

131122
const mockAxeHelpers = createDefaultMockAxeHelpers();
132123

133-
await key_pressLogic(
134-
{
135-
simulatorId: '12345678-1234-4234-8234-123456789012',
136-
keyCode: 42,
137-
duration: 1.5,
138-
},
139-
trackingExecutor,
140-
mockAxeHelpers,
124+
await runLogic(() =>
125+
key_pressLogic(
126+
{
127+
simulatorId: '12345678-1234-4234-8234-123456789012',
128+
keyCode: 42,
129+
duration: 1.5,
130+
},
131+
trackingExecutor,
132+
mockAxeHelpers,
133+
),
141134
);
142135

143136
expect(capturedCommand).toEqual([
@@ -165,13 +158,15 @@ describe('Key Press Tool', () => {
165158

166159
const mockAxeHelpers = createDefaultMockAxeHelpers();
167160

168-
await key_pressLogic(
169-
{
170-
simulatorId: '12345678-1234-4234-8234-123456789012',
171-
keyCode: 255,
172-
},
173-
trackingExecutor,
174-
mockAxeHelpers,
161+
await runLogic(() =>
162+
key_pressLogic(
163+
{
164+
simulatorId: '12345678-1234-4234-8234-123456789012',
165+
keyCode: 255,
166+
},
167+
trackingExecutor,
168+
mockAxeHelpers,
169+
),
175170
);
176171

177172
expect(capturedCommand).toEqual([
@@ -198,24 +193,17 @@ describe('Key Press Tool', () => {
198193
const mockAxeHelpers = {
199194
getAxePath: () => '/path/to/bundled/axe',
200195
getBundledAxeEnvironment: () => ({ AXE_PATH: '/some/path' }),
201-
createAxeNotAvailableResponse: () => ({
202-
content: [
203-
{
204-
type: 'text' as const,
205-
text: AXE_NOT_AVAILABLE_MESSAGE,
206-
},
207-
],
208-
isError: true,
209-
}),
210196
};
211197

212-
await key_pressLogic(
213-
{
214-
simulatorId: '12345678-1234-4234-8234-123456789012',
215-
keyCode: 44,
216-
},
217-
trackingExecutor,
218-
mockAxeHelpers,
198+
await runLogic(() =>
199+
key_pressLogic(
200+
{
201+
simulatorId: '12345678-1234-4234-8234-123456789012',
202+
keyCode: 44,
203+
},
204+
trackingExecutor,
205+
mockAxeHelpers,
206+
),
219207
);
220208

221209
expect(capturedCommand).toEqual([
@@ -241,19 +229,19 @@ describe('Key Press Tool', () => {
241229

242230
const mockAxeHelpers = createDefaultMockAxeHelpers();
243231

244-
const result = await key_pressLogic(
245-
{
246-
simulatorId: '12345678-1234-4234-8234-123456789012',
247-
keyCode: 40,
248-
},
249-
mockExecutor,
250-
mockAxeHelpers,
232+
const result = await runLogic(() =>
233+
key_pressLogic(
234+
{
235+
simulatorId: '12345678-1234-4234-8234-123456789012',
236+
keyCode: 40,
237+
},
238+
mockExecutor,
239+
mockAxeHelpers,
240+
),
251241
);
252242

253-
expect(result).toEqual({
254-
content: [{ type: 'text' as const, text: 'Key press (code: 40) simulated successfully.' }],
255-
isError: false,
256-
});
243+
expect(result.isError).toBeFalsy();
244+
expect(allText(result)).toContain('Key press (code: 40) simulated successfully.');
257245
});
258246

259247
it('should return success for key press with duration', async () => {
@@ -265,55 +253,41 @@ describe('Key Press Tool', () => {
265253

266254
const mockAxeHelpers = createDefaultMockAxeHelpers();
267255

268-
const result = await key_pressLogic(
269-
{
270-
simulatorId: '12345678-1234-4234-8234-123456789012',
271-
keyCode: 42,
272-
duration: 1.5,
273-
},
274-
mockExecutor,
275-
mockAxeHelpers,
256+
const result = await runLogic(() =>
257+
key_pressLogic(
258+
{
259+
simulatorId: '12345678-1234-4234-8234-123456789012',
260+
keyCode: 42,
261+
duration: 1.5,
262+
},
263+
mockExecutor,
264+
mockAxeHelpers,
265+
),
276266
);
277267

278-
expect(result).toEqual({
279-
content: [{ type: 'text' as const, text: 'Key press (code: 42) simulated successfully.' }],
280-
isError: false,
281-
});
268+
expect(result.isError).toBeFalsy();
269+
expect(allText(result)).toContain('Key press (code: 42) simulated successfully.');
282270
});
283271

284272
it('should handle DependencyError when axe is not available', async () => {
285273
const mockAxeHelpers = {
286274
getAxePath: () => null,
287275
getBundledAxeEnvironment: () => ({}),
288-
createAxeNotAvailableResponse: () => ({
289-
content: [
290-
{
291-
type: 'text' as const,
292-
text: AXE_NOT_AVAILABLE_MESSAGE,
293-
},
294-
],
295-
isError: true,
296-
}),
297276
};
298277

299-
const result = await key_pressLogic(
300-
{
301-
simulatorId: '12345678-1234-4234-8234-123456789012',
302-
keyCode: 40,
303-
},
304-
createNoopExecutor(),
305-
mockAxeHelpers,
306-
);
307-
308-
expect(result).toEqual({
309-
content: [
278+
const result = await runLogic(() =>
279+
key_pressLogic(
310280
{
311-
type: 'text' as const,
312-
text: AXE_NOT_AVAILABLE_MESSAGE,
281+
simulatorId: '12345678-1234-4234-8234-123456789012',
282+
keyCode: 40,
313283
},
314-
],
315-
isError: true,
316-
});
284+
createNoopExecutor(),
285+
mockAxeHelpers,
286+
),
287+
);
288+
289+
expect(result.isError).toBe(true);
290+
expect(allText(result)).toContain(AXE_NOT_AVAILABLE_MESSAGE);
317291
});
318292

319293
it('should handle AxeError from failed command execution', async () => {
@@ -325,24 +299,21 @@ describe('Key Press Tool', () => {
325299

326300
const mockAxeHelpers = createDefaultMockAxeHelpers();
327301

328-
const result = await key_pressLogic(
329-
{
330-
simulatorId: '12345678-1234-4234-8234-123456789012',
331-
keyCode: 40,
332-
},
333-
mockExecutor,
334-
mockAxeHelpers,
335-
);
336-
337-
expect(result).toEqual({
338-
content: [
302+
const result = await runLogic(() =>
303+
key_pressLogic(
339304
{
340-
type: 'text' as const,
341-
text: "Error: Failed to simulate key press (code: 40): axe command 'key' failed.\nDetails: axe command failed",
305+
simulatorId: '12345678-1234-4234-8234-123456789012',
306+
keyCode: 40,
342307
},
343-
],
344-
isError: true,
345-
});
308+
mockExecutor,
309+
mockAxeHelpers,
310+
),
311+
);
312+
313+
expect(result.isError).toBe(true);
314+
expect(allText(result)).toContain(
315+
"Failed to simulate key press (code: 40): axe command 'key' failed.",
316+
);
346317
});
347318

348319
it('should handle SystemError from command execution', async () => {
@@ -352,18 +323,20 @@ describe('Key Press Tool', () => {
352323

353324
const mockAxeHelpers = createDefaultMockAxeHelpers();
354325

355-
const result = await key_pressLogic(
356-
{
357-
simulatorId: '12345678-1234-4234-8234-123456789012',
358-
keyCode: 40,
359-
},
360-
mockExecutor,
361-
mockAxeHelpers,
326+
const result = await runLogic(() =>
327+
key_pressLogic(
328+
{
329+
simulatorId: '12345678-1234-4234-8234-123456789012',
330+
keyCode: 40,
331+
},
332+
mockExecutor,
333+
mockAxeHelpers,
334+
),
362335
);
363336

364337
expect(result.isError).toBe(true);
365-
expect(result.content[0].text).toContain(
366-
'Error: System error executing axe: Failed to execute axe command: System error occurred',
338+
expect(allText(result)).toContain(
339+
'System error executing axe: Failed to execute axe command: System error occurred',
367340
);
368341
});
369342

@@ -374,18 +347,20 @@ describe('Key Press Tool', () => {
374347

375348
const mockAxeHelpers = createDefaultMockAxeHelpers();
376349

377-
const result = await key_pressLogic(
378-
{
379-
simulatorId: '12345678-1234-4234-8234-123456789012',
380-
keyCode: 40,
381-
},
382-
mockExecutor,
383-
mockAxeHelpers,
350+
const result = await runLogic(() =>
351+
key_pressLogic(
352+
{
353+
simulatorId: '12345678-1234-4234-8234-123456789012',
354+
keyCode: 40,
355+
},
356+
mockExecutor,
357+
mockAxeHelpers,
358+
),
384359
);
385360

386361
expect(result.isError).toBe(true);
387-
expect(result.content[0].text).toContain(
388-
'Error: System error executing axe: Failed to execute axe command: Unexpected error',
362+
expect(allText(result)).toContain(
363+
'System error executing axe: Failed to execute axe command: Unexpected error',
389364
);
390365
});
391366

@@ -396,24 +371,21 @@ describe('Key Press Tool', () => {
396371

397372
const mockAxeHelpers = createDefaultMockAxeHelpers();
398373

399-
const result = await key_pressLogic(
400-
{
401-
simulatorId: '12345678-1234-4234-8234-123456789012',
402-
keyCode: 40,
403-
},
404-
mockExecutor,
405-
mockAxeHelpers,
406-
);
407-
408-
expect(result).toEqual({
409-
content: [
374+
const result = await runLogic(() =>
375+
key_pressLogic(
410376
{
411-
type: 'text' as const,
412-
text: 'Error: System error executing axe: Failed to execute axe command: String error',
377+
simulatorId: '12345678-1234-4234-8234-123456789012',
378+
keyCode: 40,
413379
},
414-
],
415-
isError: true,
416-
});
380+
mockExecutor,
381+
mockAxeHelpers,
382+
),
383+
);
384+
385+
expect(result.isError).toBe(true);
386+
expect(allText(result)).toContain(
387+
'System error executing axe: Failed to execute axe command: String error',
388+
);
417389
});
418390
});
419391
});

0 commit comments

Comments
 (0)