refactor(8/12): migrate UI automation tools to event-based handlers#326
refactor(8/12): migrate UI automation tools to event-based handlers#326cameroncooke wants to merge 1 commit intorefactor/migrate-device-macos-toolsfrom
Conversation
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
Autofix Details
Bugbot Autofix prepared a fix for the issue found in the latest run.
- ✅ Fixed: Identical
runLogichelper duplicated across 10 test files- Created shared test-helpers.ts module exporting runLogic, createMockToolHandlerContext, and allText functions, then updated all 11 UI automation test files to import from the shared module, eliminating 374 lines of duplicated code.
Or push these changes by commenting:
@cursor push 4eca3c811f
Preview (4eca3c811f)
diff --git a/src/mcp/tools/ui-automation/__tests__/button.test.ts b/src/mcp/tools/ui-automation/__tests__/button.test.ts
--- a/src/mcp/tools/ui-automation/__tests__/button.test.ts
+++ b/src/mcp/tools/ui-automation/__tests__/button.test.ts
@@ -8,41 +8,8 @@
import { schema, handler, buttonLogic } from '../button.ts';
import type { CommandExecutor } from '../../../../utils/execution/index.ts';
import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts';
-import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts';
+import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
-const runLogic = async (logic: () => Promise<unknown>) => {
- const { result, run } = createMockToolHandlerContext();
- const response = await run(logic);
-
- if (
- response &&
- typeof response === 'object' &&
- 'content' in (response as Record<string, unknown>)
- ) {
- return response as {
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
- isError?: boolean;
- nextStepParams?: unknown;
- };
- }
-
- const text = result.text();
- const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : [];
- const imageContent = result.attachments.map((attachment) => ({
- type: 'image' as const,
- data: attachment.data,
- mimeType: attachment.mimeType,
- }));
-
- return {
- content: [...textContent, ...imageContent],
- isError: result.isError() ? true : undefined,
- nextStepParams: result.nextStepParams,
- attachments: result.attachments,
- text,
- };
-};
-
describe('Button Plugin', () => {
describe('Export Field Validation (Literal)', () => {
it('should have handler function', () => {
diff --git a/src/mcp/tools/ui-automation/__tests__/gesture.test.ts b/src/mcp/tools/ui-automation/__tests__/gesture.test.ts
--- a/src/mcp/tools/ui-automation/__tests__/gesture.test.ts
+++ b/src/mcp/tools/ui-automation/__tests__/gesture.test.ts
@@ -8,41 +8,8 @@
import { sessionStore } from '../../../../utils/session-store.ts';
import { schema, handler, gestureLogic } from '../gesture.ts';
import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts';
-import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts';
+import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
-const runLogic = async (logic: () => Promise<unknown>) => {
- const { result, run } = createMockToolHandlerContext();
- const response = await run(logic);
-
- if (
- response &&
- typeof response === 'object' &&
- 'content' in (response as Record<string, unknown>)
- ) {
- return response as {
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
- isError?: boolean;
- nextStepParams?: unknown;
- };
- }
-
- const text = result.text();
- const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : [];
- const imageContent = result.attachments.map((attachment) => ({
- type: 'image' as const,
- data: attachment.data,
- mimeType: attachment.mimeType,
- }));
-
- return {
- content: [...textContent, ...imageContent],
- isError: result.isError() ? true : undefined,
- nextStepParams: result.nextStepParams,
- attachments: result.attachments,
- text,
- };
-};
-
describe('Gesture Plugin', () => {
beforeEach(() => {
sessionStore.clear();
diff --git a/src/mcp/tools/ui-automation/__tests__/key_press.test.ts b/src/mcp/tools/ui-automation/__tests__/key_press.test.ts
--- a/src/mcp/tools/ui-automation/__tests__/key_press.test.ts
+++ b/src/mcp/tools/ui-automation/__tests__/key_press.test.ts
@@ -9,41 +9,8 @@
import { sessionStore } from '../../../../utils/session-store.ts';
import { schema, handler, key_pressLogic } from '../key_press.ts';
import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts';
-import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts';
+import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
-const runLogic = async (logic: () => Promise<unknown>) => {
- const { result, run } = createMockToolHandlerContext();
- const response = await run(logic);
-
- if (
- response &&
- typeof response === 'object' &&
- 'content' in (response as Record<string, unknown>)
- ) {
- return response as {
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
- isError?: boolean;
- nextStepParams?: unknown;
- };
- }
-
- const text = result.text();
- const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : [];
- const imageContent = result.attachments.map((attachment) => ({
- type: 'image' as const,
- data: attachment.data,
- mimeType: attachment.mimeType,
- }));
-
- return {
- content: [...textContent, ...imageContent],
- isError: result.isError() ? true : undefined,
- nextStepParams: result.nextStepParams,
- attachments: result.attachments,
- text,
- };
-};
-
function createDefaultMockAxeHelpers() {
return {
getAxePath: () => '/usr/local/bin/axe',
diff --git a/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts b/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts
--- a/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts
+++ b/src/mcp/tools/ui-automation/__tests__/key_sequence.test.ts
@@ -8,41 +8,8 @@
import { sessionStore } from '../../../../utils/session-store.ts';
import { schema, handler, key_sequenceLogic } from '../key_sequence.ts';
import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts';
-import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts';
+import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
-const runLogic = async (logic: () => Promise<unknown>) => {
- const { result, run } = createMockToolHandlerContext();
- const response = await run(logic);
-
- if (
- response &&
- typeof response === 'object' &&
- 'content' in (response as Record<string, unknown>)
- ) {
- return response as {
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
- isError?: boolean;
- nextStepParams?: unknown;
- };
- }
-
- const text = result.text();
- const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : [];
- const imageContent = result.attachments.map((attachment) => ({
- type: 'image' as const,
- data: attachment.data,
- mimeType: attachment.mimeType,
- }));
-
- return {
- content: [...textContent, ...imageContent],
- isError: result.isError() ? true : undefined,
- nextStepParams: result.nextStepParams,
- attachments: result.attachments,
- text,
- };
-};
-
describe('Key Sequence Tool', () => {
beforeEach(() => {
sessionStore.clear();
diff --git a/src/mcp/tools/ui-automation/__tests__/long_press.test.ts b/src/mcp/tools/ui-automation/__tests__/long_press.test.ts
--- a/src/mcp/tools/ui-automation/__tests__/long_press.test.ts
+++ b/src/mcp/tools/ui-automation/__tests__/long_press.test.ts
@@ -4,41 +4,8 @@
import { sessionStore } from '../../../../utils/session-store.ts';
import { schema, handler, long_pressLogic } from '../long_press.ts';
import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts';
-import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts';
+import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
-const runLogic = async (logic: () => Promise<unknown>) => {
- const { result, run } = createMockToolHandlerContext();
- const response = await run(logic);
-
- if (
- response &&
- typeof response === 'object' &&
- 'content' in (response as Record<string, unknown>)
- ) {
- return response as {
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
- isError?: boolean;
- nextStepParams?: unknown;
- };
- }
-
- const text = result.text();
- const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : [];
- const imageContent = result.attachments.map((attachment) => ({
- type: 'image' as const,
- data: attachment.data,
- mimeType: attachment.mimeType,
- }));
-
- return {
- content: [...textContent, ...imageContent],
- isError: result.isError() ? true : undefined,
- nextStepParams: result.nextStepParams,
- attachments: result.attachments,
- text,
- };
-};
-
describe('Long Press Plugin', () => {
beforeEach(() => {
sessionStore.clear();
diff --git a/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts b/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts
--- a/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts
+++ b/src/mcp/tools/ui-automation/__tests__/screenshot.test.ts
@@ -14,41 +14,8 @@
detectLandscapeMode,
rotateImage,
} from '../screenshot.ts';
-import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts';
+import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
-const runLogic = async (logic: () => Promise<unknown>) => {
- const { result, run } = createMockToolHandlerContext();
- const response = await run(logic);
-
- if (
- response &&
- typeof response === 'object' &&
- 'content' in (response as Record<string, unknown>)
- ) {
- return response as {
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
- isError?: boolean;
- nextStepParams?: unknown;
- };
- }
-
- const text = result.text();
- const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : [];
- const imageContent = result.attachments.map((attachment) => ({
- type: 'image' as const,
- data: attachment.data,
- mimeType: attachment.mimeType,
- }));
-
- return {
- content: [...textContent, ...imageContent],
- isError: result.isError() ? true : undefined,
- nextStepParams: result.nextStepParams,
- attachments: result.attachments,
- text,
- };
-};
-
describe('Screenshot Plugin', () => {
beforeEach(() => {
sessionStore.clear();
diff --git a/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts b/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts
--- a/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts
+++ b/src/mcp/tools/ui-automation/__tests__/snapshot_ui.test.ts
@@ -4,41 +4,8 @@
import type { CommandExecutor } from '../../../../utils/execution/index.ts';
import { schema, handler, snapshot_uiLogic } from '../snapshot_ui.ts';
import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts';
-import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts';
+import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
-const runLogic = async (logic: () => Promise<unknown>) => {
- const { result, run } = createMockToolHandlerContext();
- const response = await run(logic);
-
- if (
- response &&
- typeof response === 'object' &&
- 'content' in (response as Record<string, unknown>)
- ) {
- return response as {
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
- isError?: boolean;
- nextStepParams?: unknown;
- };
- }
-
- const text = result.text();
- const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : [];
- const imageContent = result.attachments.map((attachment) => ({
- type: 'image' as const,
- data: attachment.data,
- mimeType: attachment.mimeType,
- }));
-
- return {
- content: [...textContent, ...imageContent],
- isError: result.isError() ? true : undefined,
- nextStepParams: result.nextStepParams,
- attachments: result.attachments,
- text,
- };
-};
-
describe('Snapshot UI Plugin', () => {
describe('Export Field Validation (Literal)', () => {
it('should have handler function', () => {
diff --git a/src/mcp/tools/ui-automation/__tests__/swipe.test.ts b/src/mcp/tools/ui-automation/__tests__/swipe.test.ts
--- a/src/mcp/tools/ui-automation/__tests__/swipe.test.ts
+++ b/src/mcp/tools/ui-automation/__tests__/swipe.test.ts
@@ -6,41 +6,8 @@
import { schema, handler, type AxeHelpers, swipeLogic, type SwipeParams } from '../swipe.ts';
import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts';
-import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts';
+import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
-const runLogic = async (logic: () => Promise<unknown>) => {
- const { result, run } = createMockToolHandlerContext();
- const response = await run(logic);
-
- if (
- response &&
- typeof response === 'object' &&
- 'content' in (response as Record<string, unknown>)
- ) {
- return response as {
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
- isError?: boolean;
- nextStepParams?: unknown;
- };
- }
-
- const text = result.text();
- const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : [];
- const imageContent = result.attachments.map((attachment) => ({
- type: 'image' as const,
- data: attachment.data,
- mimeType: attachment.mimeType,
- }));
-
- return {
- content: [...textContent, ...imageContent],
- isError: result.isError() ? true : undefined,
- nextStepParams: result.nextStepParams,
- attachments: result.attachments,
- text,
- };
-};
-
function createMockAxeHelpers(): AxeHelpers {
return {
getAxePath: () => '/mocked/axe/path',
diff --git a/src/mcp/tools/ui-automation/__tests__/tap.test.ts b/src/mcp/tools/ui-automation/__tests__/tap.test.ts
--- a/src/mcp/tools/ui-automation/__tests__/tap.test.ts
+++ b/src/mcp/tools/ui-automation/__tests__/tap.test.ts
@@ -5,41 +5,8 @@
import { schema, handler, type AxeHelpers, tapLogic } from '../tap.ts';
import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts';
-import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts';
+import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
-const runLogic = async (logic: () => Promise<unknown>) => {
- const { result, run } = createMockToolHandlerContext();
- const response = await run(logic);
-
- if (
- response &&
- typeof response === 'object' &&
- 'content' in (response as Record<string, unknown>)
- ) {
- return response as {
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
- isError?: boolean;
- nextStepParams?: unknown;
- };
- }
-
- const text = result.text();
- const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : [];
- const imageContent = result.attachments.map((attachment) => ({
- type: 'image' as const,
- data: attachment.data,
- mimeType: attachment.mimeType,
- }));
-
- return {
- content: [...textContent, ...imageContent],
- isError: result.isError() ? true : undefined,
- nextStepParams: result.nextStepParams,
- attachments: result.attachments,
- text,
- };
-};
-
function createMockAxeHelpers(): AxeHelpers {
return {
getAxePath: () => '/mocked/axe/path',
diff --git a/src/mcp/tools/ui-automation/__tests__/touch.test.ts b/src/mcp/tools/ui-automation/__tests__/touch.test.ts
--- a/src/mcp/tools/ui-automation/__tests__/touch.test.ts
+++ b/src/mcp/tools/ui-automation/__tests__/touch.test.ts
@@ -4,41 +4,8 @@
import { sessionStore } from '../../../../utils/session-store.ts';
import { schema, handler, touchLogic } from '../touch.ts';
import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts';
-import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts';
+import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
-const runLogic = async (logic: () => Promise<unknown>) => {
- const { result, run } = createMockToolHandlerContext();
- const response = await run(logic);
-
- if (
- response &&
- typeof response === 'object' &&
- 'content' in (response as Record<string, unknown>)
- ) {
- return response as {
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
- isError?: boolean;
- nextStepParams?: unknown;
- };
- }
-
- const text = result.text();
- const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : [];
- const imageContent = result.attachments.map((attachment) => ({
- type: 'image' as const,
- data: attachment.data,
- mimeType: attachment.mimeType,
- }));
-
- return {
- content: [...textContent, ...imageContent],
- isError: result.isError() ? true : undefined,
- nextStepParams: result.nextStepParams,
- attachments: result.attachments,
- text,
- };
-};
-
describe('Touch Plugin', () => {
beforeEach(() => {
sessionStore.clear();
diff --git a/src/mcp/tools/ui-automation/__tests__/type_text.test.ts b/src/mcp/tools/ui-automation/__tests__/type_text.test.ts
--- a/src/mcp/tools/ui-automation/__tests__/type_text.test.ts
+++ b/src/mcp/tools/ui-automation/__tests__/type_text.test.ts
@@ -8,41 +8,8 @@
import { sessionStore } from '../../../../utils/session-store.ts';
import { schema, handler, type_textLogic } from '../type_text.ts';
import { AXE_NOT_AVAILABLE_MESSAGE } from '../../../../utils/axe-helpers.ts';
-import { allText, createMockToolHandlerContext } from '../../../../test-utils/test-helpers.ts';
+import { allText, runLogic } from '../../../../test-utils/test-helpers.ts';
-const runLogic = async (logic: () => Promise<unknown>) => {
- const { result, run } = createMockToolHandlerContext();
- const response = await run(logic);
-
- if (
- response &&
- typeof response === 'object' &&
- 'content' in (response as Record<string, unknown>)
- ) {
- return response as {
- content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
- isError?: boolean;
- nextStepParams?: unknown;
- };
- }
-
- const text = result.text();
- const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : [];
- const imageContent = result.attachments.map((attachment) => ({
- type: 'image' as const,
- data: attachment.data,
- mimeType: attachment.mimeType,
- }));
-
- return {
- content: [...textContent, ...imageContent],
- isError: result.isError() ? true : undefined,
- nextStepParams: result.nextStepParams,
- attachments: result.attachments,
- text,
- };
-};
-
// Mock axe helpers for dependency injection
function createMockAxeHelpers(
overrides: {
diff --git a/src/test-utils/test-helpers.ts b/src/test-utils/test-helpers.ts
new file mode 100644
--- /dev/null
+++ b/src/test-utils/test-helpers.ts
@@ -1,0 +1,105 @@
+import type { PipelineEvent } from '../types/pipeline-events.ts';
+import type { ToolHandlerContext, ImageAttachment } from '../rendering/types.ts';
+import type { NextStepParamsMap } from '../types/common.ts';
+
+export interface MockToolHandlerResult {
+ getEvents(): readonly PipelineEvent[];
+ getAttachments(): readonly ImageAttachment[];
+ isError(): boolean;
+ text(): string;
+ nextStepParams?: NextStepParamsMap;
+}
+
+export interface MockToolHandlerContext {
+ ctx: ToolHandlerContext;
+ result: MockToolHandlerResult;
+ run: (logic: () => Promise<unknown>) => Promise<unknown>;
+}
+
+export function createMockToolHandlerContext(): MockToolHandlerContext {
+ const events: PipelineEvent[] = [];
+ const attachments: ImageAttachment[] = [];
+ let nextStepParams: NextStepParamsMap | undefined;
+
+ const ctx: ToolHandlerContext = {
+ emit: (event: PipelineEvent) => {
+ events.push(event);
+ },
+ attach: (image: ImageAttachment) => {
+ attachments.push(image);
+ },
+ get nextStepParams() {
+ return nextStepParams;
+ },
+ set nextStepParams(value: NextStepParamsMap | undefined) {
+ nextStepParams = value;
+ },
+ };
+
+ const result: MockToolHandlerResult = {
+ getEvents: () => events,
+ getAttachments: () => attachments,
+ isError: () => events.some((e) => e.type === 'status-line' && e.level === 'error'),
+ text: () =>
+ events
+ .filter((e) => e.type === 'status-line')
+ .map((e) => (e as { message: string }).message)
+ .join('\n'),
+ get nextStepParams() {
+ return nextStepParams;
+ },
+ };
+
+ const run = async (logic: () => Promise<unknown>) => {
+ return await logic();
+ };
+
+ return { ctx, result, run };
+}
+
+export function allText(
+ result:
+ | MockToolHandlerResult
+ | { content: Array<{ type: string; text?: string; data?: string; mimeType?: string }> },
+): string {
+ if ('content' in result) {
+ return result.content
+ .filter((item) => item.type === 'text' && item.text)
+ .map((item) => item.text)
+ .join('\n');
+ }
+ return result.text();
+}
+
+export async function runLogic(logic: () => Promise<unknown>) {
+ const { result, run } = createMockToolHandlerContext();
+ const response = await run(logic);
+
+ if (
+ response &&
+ typeof response === 'object' &&
+ 'content' in (response as Record<string, unknown>)
+ ) {
+ return response as {
+ content: Array<{ type: string; text?: string; data?: string; mimeType?: string }>;
+ isError?: boolean;
+ nextStepParams?: unknown;
+ };
+ }
+
+ const text = result.text();
+ const textContent = text.length > 0 ? [{ type: 'text' as const, text }] : [];
+ const imageContent = result.getAttachments().map((attachment) => ({
+ type: 'image' as const,
+ data: attachment.data,
+ mimeType: attachment.mimeType,
+ }));
+
+ return {
+ content: [...textContent, ...imageContent],
+ isError: result.isError() ? true : undefined,
+ nextStepParams: result.nextStepParams,
+ attachments: result.getAttachments(),
+ text,
+ };
+}This Bugbot Autofix run was free. To enable autofix for future PRs, go to the Cursor dashboard.
46fdf65 to
d2e93fa
Compare
ae0e2ac to
6eabc79
Compare
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 1 potential issue.
❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.
Reviewed by Cursor Bugbot for commit d2e93fa. Configure here.
|
|
||
| const result = await screenshotLogic( | ||
| { | ||
| simulatorId: '12345678-1234-4234-8234-123456789012', |
There was a problem hiding this comment.
Screenshot command failure test removed without replacement
Medium Severity
Three screenshotLogic tests were deleted without replacement: the parameter validation test, the success-with-image test, and critically the command execution failure test (which verified that success: false with 'Simulator not found' produces the correct error message). The failure path for a failed xcrun simctl io screenshot command is now untested.
Triggered by project rule: Bugbot Review Guide for XcodeBuildMCP
Reviewed by Cursor Bugbot for commit d2e93fa. Configure here.



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_textNotable changes
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 anemitcallback.axe-helpers.tsandaxe/index.ts: Minor updates to work with the shared command module.screenshot.ts: Usesctx.attach()for image data instead of constructingToolResponseContentdirectly. 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
Test plan
npx vitest runpasses -- all UI automation tool tests updatedctx.attach()for image data