snapdrive-ios 0.1.0
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.
- package/LICENSE +21 -0
- package/README.ja.md +95 -0
- package/README.md +95 -0
- package/dist/cli.d.ts +7 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +265 -0
- package/dist/cli.js.map +1 -0
- package/dist/core/command-executor.d.ts +15 -0
- package/dist/core/command-executor.d.ts.map +1 -0
- package/dist/core/command-executor.js +64 -0
- package/dist/core/command-executor.js.map +1 -0
- package/dist/core/element-finder.d.ts +81 -0
- package/dist/core/element-finder.d.ts.map +1 -0
- package/dist/core/element-finder.js +246 -0
- package/dist/core/element-finder.js.map +1 -0
- package/dist/core/idb-client.d.ts +68 -0
- package/dist/core/idb-client.d.ts.map +1 -0
- package/dist/core/idb-client.js +327 -0
- package/dist/core/idb-client.js.map +1 -0
- package/dist/core/image-differ.d.ts +55 -0
- package/dist/core/image-differ.d.ts.map +1 -0
- package/dist/core/image-differ.js +211 -0
- package/dist/core/image-differ.js.map +1 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +9 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/report-generator.d.ts +31 -0
- package/dist/core/report-generator.d.ts.map +1 -0
- package/dist/core/report-generator.js +675 -0
- package/dist/core/report-generator.js.map +1 -0
- package/dist/core/scenario-runner.d.ts +54 -0
- package/dist/core/scenario-runner.d.ts.map +1 -0
- package/dist/core/scenario-runner.js +701 -0
- package/dist/core/scenario-runner.js.map +1 -0
- package/dist/core/simctl-client.d.ts +64 -0
- package/dist/core/simctl-client.d.ts.map +1 -0
- package/dist/core/simctl-client.js +214 -0
- package/dist/core/simctl-client.js.map +1 -0
- package/dist/index.d.ts +7 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +11 -0
- package/dist/index.js.map +1 -0
- package/dist/interfaces/config.interface.d.ts +37 -0
- package/dist/interfaces/config.interface.d.ts.map +1 -0
- package/dist/interfaces/config.interface.js +14 -0
- package/dist/interfaces/config.interface.js.map +1 -0
- package/dist/interfaces/element.interface.d.ts +49 -0
- package/dist/interfaces/element.interface.d.ts.map +1 -0
- package/dist/interfaces/element.interface.js +5 -0
- package/dist/interfaces/element.interface.js.map +1 -0
- package/dist/interfaces/index.d.ts +7 -0
- package/dist/interfaces/index.d.ts.map +1 -0
- package/dist/interfaces/index.js +7 -0
- package/dist/interfaces/index.js.map +1 -0
- package/dist/interfaces/scenario.interface.d.ts +101 -0
- package/dist/interfaces/scenario.interface.d.ts.map +1 -0
- package/dist/interfaces/scenario.interface.js +5 -0
- package/dist/interfaces/scenario.interface.js.map +1 -0
- package/dist/server.d.ts +28 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +943 -0
- package/dist/server.js.map +1 -0
- package/dist/utils/index.d.ts +5 -0
- package/dist/utils/index.d.ts.map +1 -0
- package/dist/utils/index.js +5 -0
- package/dist/utils/index.js.map +1 -0
- package/dist/utils/logger.d.ts +24 -0
- package/dist/utils/logger.d.ts.map +1 -0
- package/dist/utils/logger.js +50 -0
- package/dist/utils/logger.js.map +1 -0
- package/package.json +67 -0
package/dist/server.js
ADDED
|
@@ -0,0 +1,943 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SnapDrive MCP Server
|
|
3
|
+
* Provides iOS Simulator automation tools via Model Context Protocol
|
|
4
|
+
*/
|
|
5
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
6
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
7
|
+
import { z } from 'zod';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import { mkdir } from 'node:fs/promises';
|
|
10
|
+
import { IDBClient } from './core/idb-client.js';
|
|
11
|
+
import { SimctlClient } from './core/simctl-client.js';
|
|
12
|
+
import { ElementFinder } from './core/element-finder.js';
|
|
13
|
+
import { ImageDiffer } from './core/image-differ.js';
|
|
14
|
+
import { ScenarioRunner } from './core/scenario-runner.js';
|
|
15
|
+
import { ReportGenerator } from './core/report-generator.js';
|
|
16
|
+
import { Logger } from './utils/logger.js';
|
|
17
|
+
import { DEFAULT_CONFIG } from './interfaces/config.interface.js';
|
|
18
|
+
/**
|
|
19
|
+
* Normalize coordinate to 6 decimal places (10cm precision)
|
|
20
|
+
* This ensures consistent coordinates for reproducible tests
|
|
21
|
+
*/
|
|
22
|
+
function normalizeCoordinate(value) {
|
|
23
|
+
return Math.round(value * 1_000_000) / 1_000_000;
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Normalize waypoint coordinates to 6 decimal places
|
|
27
|
+
*/
|
|
28
|
+
function normalizeWaypoint(wp) {
|
|
29
|
+
return {
|
|
30
|
+
latitude: normalizeCoordinate(wp.latitude),
|
|
31
|
+
longitude: normalizeCoordinate(wp.longitude),
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Normalize all coordinates in scenario steps for reproducibility
|
|
36
|
+
*/
|
|
37
|
+
function normalizeStepCoordinates(steps) {
|
|
38
|
+
return steps.map(step => {
|
|
39
|
+
const normalized = { ...step };
|
|
40
|
+
// Normalize set_location coordinates
|
|
41
|
+
if (typeof normalized.latitude === 'number') {
|
|
42
|
+
normalized.latitude = normalizeCoordinate(normalized.latitude);
|
|
43
|
+
}
|
|
44
|
+
if (typeof normalized.longitude === 'number') {
|
|
45
|
+
normalized.longitude = normalizeCoordinate(normalized.longitude);
|
|
46
|
+
}
|
|
47
|
+
// Normalize waypoints array
|
|
48
|
+
if (Array.isArray(normalized.waypoints)) {
|
|
49
|
+
normalized.waypoints = normalized.waypoints.map(normalizeWaypoint);
|
|
50
|
+
}
|
|
51
|
+
return normalized;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export function createServerContext(config = {}) {
|
|
55
|
+
const mergedConfig = { ...DEFAULT_CONFIG, ...config };
|
|
56
|
+
const logger = new Logger('snapdrive', mergedConfig.logLevel);
|
|
57
|
+
const idbClient = new IDBClient({ deviceUdid: mergedConfig.defaultDeviceUdid }, undefined, logger);
|
|
58
|
+
const simctlClient = new SimctlClient({ defaultDeviceUdid: mergedConfig.defaultDeviceUdid }, undefined, logger);
|
|
59
|
+
const elementFinder = new ElementFinder();
|
|
60
|
+
const imageDiffer = new ImageDiffer(logger);
|
|
61
|
+
const scenarioRunner = new ScenarioRunner({
|
|
62
|
+
idbClient,
|
|
63
|
+
simctlClient,
|
|
64
|
+
elementFinder,
|
|
65
|
+
imageDiffer,
|
|
66
|
+
logger,
|
|
67
|
+
});
|
|
68
|
+
const reportGenerator = new ReportGenerator(logger);
|
|
69
|
+
return {
|
|
70
|
+
idbClient,
|
|
71
|
+
simctlClient,
|
|
72
|
+
elementFinder,
|
|
73
|
+
imageDiffer,
|
|
74
|
+
scenarioRunner,
|
|
75
|
+
reportGenerator,
|
|
76
|
+
logger,
|
|
77
|
+
config: mergedConfig,
|
|
78
|
+
resultsDir: join(mergedConfig.resultsDir, new Date().toISOString().replace(/[:.]/g, '-')),
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
export async function createServer(context = createServerContext()) {
|
|
82
|
+
const { idbClient, simctlClient, imageDiffer, scenarioRunner, reportGenerator, logger } = context;
|
|
83
|
+
// Ensure results directory exists (do not clean up previous results)
|
|
84
|
+
await mkdir(context.resultsDir, { recursive: true });
|
|
85
|
+
await mkdir(join(context.resultsDir, 'screenshots'), { recursive: true });
|
|
86
|
+
await mkdir(join(context.resultsDir, 'diffs'), { recursive: true });
|
|
87
|
+
const server = new McpServer({
|
|
88
|
+
name: 'snapdrive',
|
|
89
|
+
version: '0.1.0',
|
|
90
|
+
});
|
|
91
|
+
// ===========================================
|
|
92
|
+
// OBSERVATION TOOLS
|
|
93
|
+
// ===========================================
|
|
94
|
+
server.tool('screenshot', `Capture a screenshot of the iOS Simulator via idb (iOS Development Bridge).
|
|
95
|
+
|
|
96
|
+
IMPORTANT: Always use this tool for iOS Simulator screenshots. Do NOT use xcrun simctl, cliclick, osascript, or other CLI commands directly. This tool uses idb internally for reliable automation.`, {
|
|
97
|
+
name: z.string().optional().describe('Optional name for the screenshot'),
|
|
98
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID'),
|
|
99
|
+
}, async ({ name, deviceUdid }) => {
|
|
100
|
+
const screenshotName = name ?? `screenshot_${Date.now()}`;
|
|
101
|
+
const outputPath = join(context.resultsDir, 'screenshots', `${screenshotName}.png`);
|
|
102
|
+
try {
|
|
103
|
+
await simctlClient.screenshot(outputPath, deviceUdid);
|
|
104
|
+
const base64 = await imageDiffer.toBase64(outputPath);
|
|
105
|
+
return {
|
|
106
|
+
content: [
|
|
107
|
+
{
|
|
108
|
+
type: 'image',
|
|
109
|
+
data: base64,
|
|
110
|
+
mimeType: 'image/png',
|
|
111
|
+
},
|
|
112
|
+
{
|
|
113
|
+
type: 'text',
|
|
114
|
+
text: JSON.stringify({ path: outputPath, name: screenshotName }),
|
|
115
|
+
},
|
|
116
|
+
],
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
catch (error) {
|
|
120
|
+
return {
|
|
121
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
122
|
+
isError: true,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
server.tool('describe_ui', `Get the accessibility tree of the iOS Simulator screen via idb (iOS Development Bridge).
|
|
127
|
+
|
|
128
|
+
IMPORTANT: Always use this tool to get UI element information. Do NOT use osascript, AppleScript, or other methods. This tool uses idb internally for reliable automation.
|
|
129
|
+
|
|
130
|
+
Use screenshot tool to visually identify elements, then use this for precise coordinates:
|
|
131
|
+
- Each element has 'frame' with x, y, width, height
|
|
132
|
+
- Tap point = frame center: x + width/2, y + height/2
|
|
133
|
+
- Note: Not all visual elements have accessibility entries`, {
|
|
134
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID'),
|
|
135
|
+
}, async ({ deviceUdid }) => {
|
|
136
|
+
try {
|
|
137
|
+
const uiTree = await idbClient.describeAll(deviceUdid);
|
|
138
|
+
return {
|
|
139
|
+
content: [
|
|
140
|
+
{
|
|
141
|
+
type: 'text',
|
|
142
|
+
text: JSON.stringify({
|
|
143
|
+
elementCount: uiTree.elements.length,
|
|
144
|
+
elements: uiTree.elements,
|
|
145
|
+
timestamp: uiTree.timestamp,
|
|
146
|
+
}, null, 2),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
return {
|
|
153
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
154
|
+
isError: true,
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
// ===========================================
|
|
159
|
+
// ACTION TOOLS
|
|
160
|
+
// ===========================================
|
|
161
|
+
server.tool('tap', `Tap on the iOS Simulator screen at specific coordinates via idb (iOS Development Bridge).
|
|
162
|
+
|
|
163
|
+
IMPORTANT: Always use this tool for tapping on iOS Simulator. Do NOT use cliclick, osascript, or other CLI tools. This tool uses idb internally for reliable automation.
|
|
164
|
+
|
|
165
|
+
Usage:
|
|
166
|
+
1. Use describe_ui to get element coordinates from frame property
|
|
167
|
+
2. Calculate tap point: frame.x + frame.width/2, frame.y + frame.height/2
|
|
168
|
+
3. Tap using those x/y coordinates`, {
|
|
169
|
+
x: z.number().describe('X coordinate to tap'),
|
|
170
|
+
y: z.number().describe('Y coordinate to tap'),
|
|
171
|
+
duration: z.number().optional().describe('Tap duration in seconds (for long press)'),
|
|
172
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID'),
|
|
173
|
+
}, async ({ x, y, duration, deviceUdid }) => {
|
|
174
|
+
try {
|
|
175
|
+
await idbClient.tap(x, y, { duration, deviceUdid });
|
|
176
|
+
return {
|
|
177
|
+
content: [
|
|
178
|
+
{
|
|
179
|
+
type: 'text',
|
|
180
|
+
text: JSON.stringify({ success: true, tappedAt: { x, y } }),
|
|
181
|
+
},
|
|
182
|
+
],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
catch (error) {
|
|
186
|
+
return {
|
|
187
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
188
|
+
isError: true,
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
server.tool('swipe', `Swipe from one point to another on the iOS Simulator via idb (iOS Development Bridge).
|
|
193
|
+
|
|
194
|
+
IMPORTANT: Always use this tool for swiping on iOS Simulator. Do NOT use cliclick, osascript, or other CLI tools. This tool uses idb internally for reliable automation.
|
|
195
|
+
|
|
196
|
+
Get coordinates from describe_ui frame property for precise swiping.`, {
|
|
197
|
+
startX: z.number().describe('Starting X coordinate'),
|
|
198
|
+
startY: z.number().describe('Starting Y coordinate'),
|
|
199
|
+
endX: z.number().describe('Ending X coordinate'),
|
|
200
|
+
endY: z.number().describe('Ending Y coordinate'),
|
|
201
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID'),
|
|
202
|
+
}, async ({ startX, startY, endX, endY, deviceUdid }) => {
|
|
203
|
+
try {
|
|
204
|
+
await idbClient.swipe(startX, startY, endX, endY, { deviceUdid });
|
|
205
|
+
return {
|
|
206
|
+
content: [
|
|
207
|
+
{
|
|
208
|
+
type: 'text',
|
|
209
|
+
text: JSON.stringify({
|
|
210
|
+
success: true,
|
|
211
|
+
from: { x: startX, y: startY },
|
|
212
|
+
to: { x: endX, y: endY },
|
|
213
|
+
}),
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
catch (error) {
|
|
219
|
+
return {
|
|
220
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
221
|
+
isError: true,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
server.tool('type_text', `Type text into the currently focused text field on iOS Simulator via idb (iOS Development Bridge).
|
|
226
|
+
|
|
227
|
+
IMPORTANT: Always use this tool for typing text. Do NOT use osascript, cliclick, or other CLI tools. This tool uses idb internally for reliable automation.`, {
|
|
228
|
+
text: z.string().describe('Text to type'),
|
|
229
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID'),
|
|
230
|
+
}, async ({ text, deviceUdid }) => {
|
|
231
|
+
try {
|
|
232
|
+
await idbClient.typeText(text, deviceUdid);
|
|
233
|
+
return {
|
|
234
|
+
content: [
|
|
235
|
+
{
|
|
236
|
+
type: 'text',
|
|
237
|
+
text: JSON.stringify({ success: true, typed: text }),
|
|
238
|
+
},
|
|
239
|
+
],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
catch (error) {
|
|
243
|
+
return {
|
|
244
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
245
|
+
isError: true,
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
});
|
|
249
|
+
server.tool('wait', 'Wait for a specified duration', {
|
|
250
|
+
seconds: z.number().min(0.1).max(30).describe('Seconds to wait (0.1 to 30)'),
|
|
251
|
+
}, async ({ seconds }) => {
|
|
252
|
+
await new Promise((resolve) => setTimeout(resolve, seconds * 1000));
|
|
253
|
+
return {
|
|
254
|
+
content: [
|
|
255
|
+
{
|
|
256
|
+
type: 'text',
|
|
257
|
+
text: JSON.stringify({ success: true, waited: seconds }),
|
|
258
|
+
},
|
|
259
|
+
],
|
|
260
|
+
};
|
|
261
|
+
});
|
|
262
|
+
// ===========================================
|
|
263
|
+
// SIMULATOR MANAGEMENT TOOLS
|
|
264
|
+
// ===========================================
|
|
265
|
+
server.tool('list_simulators', 'List available iOS Simulators', {
|
|
266
|
+
state: z
|
|
267
|
+
.enum(['booted', 'shutdown', 'all'])
|
|
268
|
+
.optional()
|
|
269
|
+
.default('all')
|
|
270
|
+
.describe('Filter by state'),
|
|
271
|
+
}, async ({ state = 'all' }) => {
|
|
272
|
+
try {
|
|
273
|
+
let devices = await simctlClient.listDevices();
|
|
274
|
+
if (state !== 'all') {
|
|
275
|
+
const targetState = state === 'booted' ? 'Booted' : 'Shutdown';
|
|
276
|
+
devices = devices.filter((d) => d.state === targetState);
|
|
277
|
+
}
|
|
278
|
+
return {
|
|
279
|
+
content: [
|
|
280
|
+
{
|
|
281
|
+
type: 'text',
|
|
282
|
+
text: JSON.stringify({ simulators: devices, count: devices.length }, null, 2),
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
catch (error) {
|
|
288
|
+
return {
|
|
289
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
290
|
+
isError: true,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
});
|
|
294
|
+
server.tool('launch_app', 'Launch an app on the iOS Simulator', {
|
|
295
|
+
bundleId: z.string().describe('App bundle identifier'),
|
|
296
|
+
args: z.array(z.string()).optional().describe('Launch arguments'),
|
|
297
|
+
terminateExisting: z.boolean().optional().default(true).describe('Terminate existing instance'),
|
|
298
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID'),
|
|
299
|
+
}, async ({ bundleId, args, terminateExisting = true, deviceUdid }) => {
|
|
300
|
+
try {
|
|
301
|
+
await simctlClient.launchApp(bundleId, { args, terminateExisting }, deviceUdid);
|
|
302
|
+
return {
|
|
303
|
+
content: [
|
|
304
|
+
{
|
|
305
|
+
type: 'text',
|
|
306
|
+
text: JSON.stringify({ success: true, bundleId, launched: true }),
|
|
307
|
+
},
|
|
308
|
+
],
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
return {
|
|
313
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
314
|
+
isError: true,
|
|
315
|
+
};
|
|
316
|
+
}
|
|
317
|
+
});
|
|
318
|
+
server.tool('terminate_app', 'Terminate a running app on the iOS Simulator', {
|
|
319
|
+
bundleId: z.string().describe('App bundle identifier'),
|
|
320
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID'),
|
|
321
|
+
}, async ({ bundleId, deviceUdid }) => {
|
|
322
|
+
try {
|
|
323
|
+
await simctlClient.terminateApp(bundleId, deviceUdid);
|
|
324
|
+
return {
|
|
325
|
+
content: [
|
|
326
|
+
{
|
|
327
|
+
type: 'text',
|
|
328
|
+
text: JSON.stringify({
|
|
329
|
+
success: true,
|
|
330
|
+
bundleId,
|
|
331
|
+
terminated: true,
|
|
332
|
+
}),
|
|
333
|
+
},
|
|
334
|
+
],
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
catch (error) {
|
|
338
|
+
return {
|
|
339
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
340
|
+
isError: true,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
});
|
|
344
|
+
server.tool('build_and_run', 'Build Xcode project/workspace by scheme name, install and launch on simulator', {
|
|
345
|
+
scheme: z.string().describe('Xcode scheme name'),
|
|
346
|
+
projectPath: z.string().optional().describe('Path to .xcodeproj or .xcworkspace (auto-detected if omitted)'),
|
|
347
|
+
simulatorName: z.string().optional().default('iPhone 15').describe('Simulator name'),
|
|
348
|
+
configuration: z.enum(['Debug', 'Release']).optional().default('Debug').describe('Build configuration'),
|
|
349
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID (alternative to simulatorName)'),
|
|
350
|
+
}, async ({ scheme, projectPath, simulatorName = 'iPhone 15', configuration = 'Debug', deviceUdid }) => {
|
|
351
|
+
try {
|
|
352
|
+
const { CommandExecutor } = await import('./core/command-executor.js');
|
|
353
|
+
const executor = new CommandExecutor();
|
|
354
|
+
const { join, dirname } = await import('node:path');
|
|
355
|
+
const { existsSync, readdirSync } = await import('node:fs');
|
|
356
|
+
// Find .xcworkspace or .xcodeproj if not specified
|
|
357
|
+
let project = projectPath;
|
|
358
|
+
if (!project) {
|
|
359
|
+
const cwd = process.cwd();
|
|
360
|
+
const files = readdirSync(cwd);
|
|
361
|
+
const workspace = files.find((f) => f.endsWith('.xcworkspace'));
|
|
362
|
+
const xcodeproj = files.find((f) => f.endsWith('.xcodeproj'));
|
|
363
|
+
project = workspace ? join(cwd, workspace) : xcodeproj ? join(cwd, xcodeproj) : undefined;
|
|
364
|
+
}
|
|
365
|
+
if (!project) {
|
|
366
|
+
return {
|
|
367
|
+
content: [
|
|
368
|
+
{
|
|
369
|
+
type: 'text',
|
|
370
|
+
text: JSON.stringify({
|
|
371
|
+
success: false,
|
|
372
|
+
error: 'No .xcworkspace or .xcodeproj found. Specify projectPath.',
|
|
373
|
+
}),
|
|
374
|
+
},
|
|
375
|
+
],
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
const isWorkspace = project.endsWith('.xcworkspace');
|
|
379
|
+
const projectFlag = isWorkspace ? '-workspace' : '-project';
|
|
380
|
+
// Determine simulator
|
|
381
|
+
let targetSimulator = simulatorName;
|
|
382
|
+
if (deviceUdid) {
|
|
383
|
+
const devices = await simctlClient.listDevices();
|
|
384
|
+
const found = devices.find((d) => d.udid === deviceUdid);
|
|
385
|
+
if (found)
|
|
386
|
+
targetSimulator = found.name;
|
|
387
|
+
}
|
|
388
|
+
// Build
|
|
389
|
+
const derivedDataPath = join(dirname(project), 'DerivedData', 'SnapDriveBuild');
|
|
390
|
+
const buildArgs = [
|
|
391
|
+
projectFlag, project,
|
|
392
|
+
'-scheme', scheme,
|
|
393
|
+
'-configuration', configuration,
|
|
394
|
+
'-destination', `platform=iOS Simulator,name=${targetSimulator}`,
|
|
395
|
+
'-derivedDataPath', derivedDataPath,
|
|
396
|
+
'build',
|
|
397
|
+
];
|
|
398
|
+
logger.info(`Building scheme: ${scheme}`);
|
|
399
|
+
const buildResult = await executor.execute('xcodebuild', buildArgs, { timeoutMs: 300000 });
|
|
400
|
+
if (buildResult.exitCode !== 0) {
|
|
401
|
+
return {
|
|
402
|
+
content: [
|
|
403
|
+
{
|
|
404
|
+
type: 'text',
|
|
405
|
+
text: JSON.stringify({
|
|
406
|
+
success: false,
|
|
407
|
+
error: 'Build failed',
|
|
408
|
+
stderr: buildResult.stderr.slice(-1000),
|
|
409
|
+
}),
|
|
410
|
+
},
|
|
411
|
+
],
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
// Find .app bundle
|
|
415
|
+
const productsPath = join(derivedDataPath, 'Build', 'Products', `${configuration}-iphonesimulator`);
|
|
416
|
+
let appPath;
|
|
417
|
+
let bundleId;
|
|
418
|
+
if (existsSync(productsPath)) {
|
|
419
|
+
const products = readdirSync(productsPath);
|
|
420
|
+
const appBundle = products.find((f) => f.endsWith('.app'));
|
|
421
|
+
if (appBundle) {
|
|
422
|
+
appPath = join(productsPath, appBundle);
|
|
423
|
+
// Read bundle ID from Info.plist
|
|
424
|
+
const infoPlistPath = join(appPath, 'Info.plist');
|
|
425
|
+
if (existsSync(infoPlistPath)) {
|
|
426
|
+
const plistResult = await executor.execute('/usr/libexec/PlistBuddy', ['-c', 'Print :CFBundleIdentifier', infoPlistPath]);
|
|
427
|
+
if (plistResult.exitCode === 0) {
|
|
428
|
+
bundleId = plistResult.stdout.trim();
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
if (!appPath || !bundleId) {
|
|
434
|
+
return {
|
|
435
|
+
content: [
|
|
436
|
+
{
|
|
437
|
+
type: 'text',
|
|
438
|
+
text: JSON.stringify({
|
|
439
|
+
success: false,
|
|
440
|
+
error: 'Build succeeded but could not find .app bundle or bundle ID',
|
|
441
|
+
productsPath,
|
|
442
|
+
}),
|
|
443
|
+
},
|
|
444
|
+
],
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
// Boot simulator if needed
|
|
448
|
+
const bootedDevice = await simctlClient.getBootedDevice();
|
|
449
|
+
if (!bootedDevice) {
|
|
450
|
+
const devices = await simctlClient.listDevices();
|
|
451
|
+
const target = devices.find((d) => d.name === targetSimulator);
|
|
452
|
+
if (target) {
|
|
453
|
+
await simctlClient.boot(target.udid);
|
|
454
|
+
await executor.execute('open', ['-a', 'Simulator']);
|
|
455
|
+
// Wait for simulator to be ready
|
|
456
|
+
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
// Install app
|
|
460
|
+
await simctlClient.installApp(appPath, deviceUdid);
|
|
461
|
+
// Launch app
|
|
462
|
+
await simctlClient.launchApp(bundleId, { terminateExisting: true }, deviceUdid);
|
|
463
|
+
return {
|
|
464
|
+
content: [
|
|
465
|
+
{
|
|
466
|
+
type: 'text',
|
|
467
|
+
text: JSON.stringify({
|
|
468
|
+
success: true,
|
|
469
|
+
scheme,
|
|
470
|
+
bundleId,
|
|
471
|
+
appPath,
|
|
472
|
+
simulator: targetSimulator,
|
|
473
|
+
installed: true,
|
|
474
|
+
launched: true,
|
|
475
|
+
}),
|
|
476
|
+
},
|
|
477
|
+
],
|
|
478
|
+
};
|
|
479
|
+
}
|
|
480
|
+
catch (error) {
|
|
481
|
+
return {
|
|
482
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
483
|
+
isError: true,
|
|
484
|
+
};
|
|
485
|
+
}
|
|
486
|
+
});
|
|
487
|
+
server.tool('open_url', 'Open a URL or deep link in the iOS Simulator', {
|
|
488
|
+
url: z.string().describe('URL or deep link to open'),
|
|
489
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID'),
|
|
490
|
+
}, async ({ url, deviceUdid }) => {
|
|
491
|
+
try {
|
|
492
|
+
await simctlClient.openUrl(url, deviceUdid);
|
|
493
|
+
return {
|
|
494
|
+
content: [
|
|
495
|
+
{
|
|
496
|
+
type: 'text',
|
|
497
|
+
text: JSON.stringify({
|
|
498
|
+
success: true,
|
|
499
|
+
url,
|
|
500
|
+
opened: true,
|
|
501
|
+
}),
|
|
502
|
+
},
|
|
503
|
+
],
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
catch (error) {
|
|
507
|
+
return {
|
|
508
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
509
|
+
isError: true,
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
});
|
|
513
|
+
server.tool('set_location', 'Set the simulated GPS location of the iOS Simulator', {
|
|
514
|
+
latitude: z.number().min(-90).max(90).describe('Latitude (-90 to 90)'),
|
|
515
|
+
longitude: z.number().min(-180).max(180).describe('Longitude (-180 to 180)'),
|
|
516
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID'),
|
|
517
|
+
}, async ({ latitude, longitude, deviceUdid }) => {
|
|
518
|
+
try {
|
|
519
|
+
await simctlClient.setLocation(latitude, longitude, deviceUdid);
|
|
520
|
+
return {
|
|
521
|
+
content: [
|
|
522
|
+
{
|
|
523
|
+
type: 'text',
|
|
524
|
+
text: JSON.stringify({
|
|
525
|
+
success: true,
|
|
526
|
+
latitude,
|
|
527
|
+
longitude,
|
|
528
|
+
}),
|
|
529
|
+
},
|
|
530
|
+
],
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
catch (error) {
|
|
534
|
+
return {
|
|
535
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
536
|
+
isError: true,
|
|
537
|
+
};
|
|
538
|
+
}
|
|
539
|
+
});
|
|
540
|
+
server.tool('clear_location', 'Clear the simulated GPS location (revert to default)', {
|
|
541
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID'),
|
|
542
|
+
}, async ({ deviceUdid }) => {
|
|
543
|
+
try {
|
|
544
|
+
await simctlClient.clearLocation(deviceUdid);
|
|
545
|
+
return {
|
|
546
|
+
content: [
|
|
547
|
+
{
|
|
548
|
+
type: 'text',
|
|
549
|
+
text: JSON.stringify({
|
|
550
|
+
success: true,
|
|
551
|
+
cleared: true,
|
|
552
|
+
}),
|
|
553
|
+
},
|
|
554
|
+
],
|
|
555
|
+
};
|
|
556
|
+
}
|
|
557
|
+
catch (error) {
|
|
558
|
+
return {
|
|
559
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
560
|
+
isError: true,
|
|
561
|
+
};
|
|
562
|
+
}
|
|
563
|
+
});
|
|
564
|
+
const waypointSchema = z.object({
|
|
565
|
+
latitude: z.number().min(-90).max(90),
|
|
566
|
+
longitude: z.number().min(-180).max(180),
|
|
567
|
+
});
|
|
568
|
+
server.tool('simulate_route', `Simulate GPS movement along a route (for navigation testing).
|
|
569
|
+
|
|
570
|
+
Provide an array of waypoints with latitude/longitude. The simulator will move through each point sequentially.`, {
|
|
571
|
+
waypoints: z.array(waypointSchema).min(1).describe('Array of {latitude, longitude} waypoints'),
|
|
572
|
+
intervalMs: z.number().optional().default(3000).describe('Time between waypoints in milliseconds (default: 3000 for map rendering)'),
|
|
573
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID'),
|
|
574
|
+
}, async ({ waypoints, intervalMs = 3000, deviceUdid }) => {
|
|
575
|
+
try {
|
|
576
|
+
await simctlClient.simulateRoute(waypoints, { intervalMs }, deviceUdid);
|
|
577
|
+
return {
|
|
578
|
+
content: [
|
|
579
|
+
{
|
|
580
|
+
type: 'text',
|
|
581
|
+
text: JSON.stringify({
|
|
582
|
+
success: true,
|
|
583
|
+
waypointsCount: waypoints.length,
|
|
584
|
+
intervalMs,
|
|
585
|
+
completed: true,
|
|
586
|
+
}),
|
|
587
|
+
},
|
|
588
|
+
],
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
catch (error) {
|
|
592
|
+
return {
|
|
593
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
594
|
+
isError: true,
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
});
|
|
598
|
+
// ===========================================
|
|
599
|
+
// TEST CASE MANAGEMENT TOOLS
|
|
600
|
+
// ===========================================
|
|
601
|
+
server.tool('list_test_cases', 'List all test cases in the .snapdrive directory', {
|
|
602
|
+
snapdriveDir: z.string().optional().describe('Path to .snapdrive directory (defaults to ./.snapdrive)'),
|
|
603
|
+
}, async ({ snapdriveDir }) => {
|
|
604
|
+
try {
|
|
605
|
+
const dir = snapdriveDir ?? join(process.cwd(), '.snapdrive');
|
|
606
|
+
const testCases = await scenarioRunner.listTestCases(dir);
|
|
607
|
+
return {
|
|
608
|
+
content: [
|
|
609
|
+
{
|
|
610
|
+
type: 'text',
|
|
611
|
+
text: JSON.stringify({
|
|
612
|
+
count: testCases.length,
|
|
613
|
+
testCases: testCases.map((tc) => ({
|
|
614
|
+
id: tc.id,
|
|
615
|
+
name: tc.scenario.name,
|
|
616
|
+
description: tc.scenario.description,
|
|
617
|
+
stepsCount: tc.scenario.steps.length,
|
|
618
|
+
path: tc.path,
|
|
619
|
+
})),
|
|
620
|
+
}, null, 2),
|
|
621
|
+
},
|
|
622
|
+
],
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
catch (error) {
|
|
626
|
+
return {
|
|
627
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
628
|
+
isError: true,
|
|
629
|
+
};
|
|
630
|
+
}
|
|
631
|
+
});
|
|
632
|
+
server.tool('run_test_case', 'Run a specific test case by ID or path, compare with baselines, and generate HTML report', {
|
|
633
|
+
testCaseId: z.string().optional().describe('Test case ID (directory name)'),
|
|
634
|
+
testCasePath: z.string().optional().describe('Full path to test case directory'),
|
|
635
|
+
snapdriveDir: z.string().optional().describe('Path to .snapdrive directory'),
|
|
636
|
+
updateBaselines: z.boolean().optional().default(false).describe('Update baselines instead of comparing'),
|
|
637
|
+
generateReport: z.boolean().optional().default(true).describe('Generate HTML report with visual diff'),
|
|
638
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID'),
|
|
639
|
+
}, async ({ testCaseId, testCasePath, snapdriveDir, updateBaselines = false, generateReport = true, deviceUdid }) => {
|
|
640
|
+
try {
|
|
641
|
+
const baseDir = snapdriveDir ?? join(process.cwd(), '.snapdrive');
|
|
642
|
+
let tcPath;
|
|
643
|
+
if (testCasePath) {
|
|
644
|
+
tcPath = testCasePath;
|
|
645
|
+
}
|
|
646
|
+
else if (testCaseId) {
|
|
647
|
+
tcPath = join(baseDir, 'test-cases', testCaseId);
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
return {
|
|
651
|
+
content: [
|
|
652
|
+
{
|
|
653
|
+
type: 'text',
|
|
654
|
+
text: JSON.stringify({
|
|
655
|
+
success: false,
|
|
656
|
+
error: 'Must provide either testCaseId or testCasePath',
|
|
657
|
+
}),
|
|
658
|
+
},
|
|
659
|
+
],
|
|
660
|
+
};
|
|
661
|
+
}
|
|
662
|
+
const startTime = new Date();
|
|
663
|
+
const testCase = await scenarioRunner.loadTestCase(tcPath);
|
|
664
|
+
const result = await scenarioRunner.runTestCase(testCase, {
|
|
665
|
+
deviceUdid,
|
|
666
|
+
updateBaselines,
|
|
667
|
+
resultsDir: context.resultsDir,
|
|
668
|
+
testCasePath: tcPath,
|
|
669
|
+
});
|
|
670
|
+
const endTime = new Date();
|
|
671
|
+
// Generate HTML report
|
|
672
|
+
let reportPath;
|
|
673
|
+
if (generateReport && !updateBaselines) {
|
|
674
|
+
const testRunResult = {
|
|
675
|
+
runId: new Date().toISOString().replace(/[:.]/g, '-'),
|
|
676
|
+
startTime: startTime.toISOString(),
|
|
677
|
+
endTime: endTime.toISOString(),
|
|
678
|
+
durationMs: endTime.getTime() - startTime.getTime(),
|
|
679
|
+
totalTests: 1,
|
|
680
|
+
passed: result.success ? 1 : 0,
|
|
681
|
+
failed: result.success ? 0 : 1,
|
|
682
|
+
results: [result],
|
|
683
|
+
resultsDir: context.resultsDir,
|
|
684
|
+
};
|
|
685
|
+
reportPath = await reportGenerator.generateReport(testRunResult);
|
|
686
|
+
}
|
|
687
|
+
return {
|
|
688
|
+
content: [
|
|
689
|
+
{
|
|
690
|
+
type: 'text',
|
|
691
|
+
text: JSON.stringify({
|
|
692
|
+
success: result.success,
|
|
693
|
+
testCaseName: result.testCaseName,
|
|
694
|
+
durationMs: result.durationMs,
|
|
695
|
+
stepsExecuted: result.steps.length,
|
|
696
|
+
stepsPassed: result.steps.filter((s) => s.success).length,
|
|
697
|
+
checkpoints: result.checkpoints.map((cp) => ({
|
|
698
|
+
name: cp.name,
|
|
699
|
+
match: cp.match,
|
|
700
|
+
differencePercent: cp.differencePercent.toFixed(2),
|
|
701
|
+
})),
|
|
702
|
+
reportPath,
|
|
703
|
+
resultsDir: context.resultsDir,
|
|
704
|
+
}, null, 2),
|
|
705
|
+
},
|
|
706
|
+
],
|
|
707
|
+
};
|
|
708
|
+
}
|
|
709
|
+
catch (error) {
|
|
710
|
+
return {
|
|
711
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
712
|
+
isError: true,
|
|
713
|
+
};
|
|
714
|
+
}
|
|
715
|
+
});
|
|
716
|
+
server.tool('run_all_tests', 'Run all test cases in the .snapdrive directory and generate a report', {
|
|
717
|
+
snapdriveDir: z.string().optional().describe('Path to .snapdrive directory'),
|
|
718
|
+
updateBaselines: z.boolean().optional().default(false).describe('Update baselines instead of comparing'),
|
|
719
|
+
generateReport: z.boolean().optional().default(true).describe('Generate HTML report'),
|
|
720
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID'),
|
|
721
|
+
}, async ({ snapdriveDir, updateBaselines = false, generateReport = true, deviceUdid }) => {
|
|
722
|
+
try {
|
|
723
|
+
const baseDir = snapdriveDir ?? join(process.cwd(), '.snapdrive');
|
|
724
|
+
const testCases = await scenarioRunner.listTestCases(baseDir);
|
|
725
|
+
if (testCases.length === 0) {
|
|
726
|
+
return {
|
|
727
|
+
content: [
|
|
728
|
+
{
|
|
729
|
+
type: 'text',
|
|
730
|
+
text: JSON.stringify({
|
|
731
|
+
success: false,
|
|
732
|
+
error: 'No test cases found',
|
|
733
|
+
searchPath: join(baseDir, 'test-cases'),
|
|
734
|
+
}),
|
|
735
|
+
},
|
|
736
|
+
],
|
|
737
|
+
};
|
|
738
|
+
}
|
|
739
|
+
const startTime = new Date();
|
|
740
|
+
const results = {
|
|
741
|
+
runId: new Date().toISOString().replace(/[:.]/g, '-'),
|
|
742
|
+
startTime: startTime.toISOString(),
|
|
743
|
+
endTime: '',
|
|
744
|
+
durationMs: 0,
|
|
745
|
+
totalTests: testCases.length,
|
|
746
|
+
passed: 0,
|
|
747
|
+
failed: 0,
|
|
748
|
+
results: [],
|
|
749
|
+
resultsDir: context.resultsDir,
|
|
750
|
+
};
|
|
751
|
+
for (const testCase of testCases) {
|
|
752
|
+
const result = await scenarioRunner.runTestCase(testCase, {
|
|
753
|
+
deviceUdid,
|
|
754
|
+
updateBaselines,
|
|
755
|
+
resultsDir: context.resultsDir,
|
|
756
|
+
testCasePath: testCase.path,
|
|
757
|
+
});
|
|
758
|
+
results.results.push(result);
|
|
759
|
+
if (result.success) {
|
|
760
|
+
results.passed++;
|
|
761
|
+
}
|
|
762
|
+
else {
|
|
763
|
+
results.failed++;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
const endTime = new Date();
|
|
767
|
+
results.endTime = endTime.toISOString();
|
|
768
|
+
results.durationMs = endTime.getTime() - startTime.getTime();
|
|
769
|
+
// Generate HTML report
|
|
770
|
+
let reportPath;
|
|
771
|
+
if (generateReport) {
|
|
772
|
+
reportPath = await reportGenerator.generateReport(results);
|
|
773
|
+
results.reportPath = reportPath;
|
|
774
|
+
}
|
|
775
|
+
return {
|
|
776
|
+
content: [
|
|
777
|
+
{
|
|
778
|
+
type: 'text',
|
|
779
|
+
text: JSON.stringify({
|
|
780
|
+
success: results.failed === 0,
|
|
781
|
+
totalTests: results.totalTests,
|
|
782
|
+
passed: results.passed,
|
|
783
|
+
failed: results.failed,
|
|
784
|
+
durationMs: results.durationMs,
|
|
785
|
+
reportPath,
|
|
786
|
+
resultsDir: context.resultsDir,
|
|
787
|
+
}, null, 2),
|
|
788
|
+
},
|
|
789
|
+
],
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
catch (error) {
|
|
793
|
+
return {
|
|
794
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
795
|
+
isError: true,
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
});
|
|
799
|
+
// Step schema for scenario definition
|
|
800
|
+
const stepSchema = z.object({
|
|
801
|
+
action: z.enum([
|
|
802
|
+
'launch_app', 'terminate_app', 'tap', 'swipe', 'type_text',
|
|
803
|
+
'wait', 'checkpoint', 'full_page_checkpoint', 'smart_checkpoint',
|
|
804
|
+
'scroll_to_top', 'scroll_to_bottom', 'open_url',
|
|
805
|
+
'set_location', 'clear_location', 'simulate_route'
|
|
806
|
+
]),
|
|
807
|
+
bundleId: z.string().optional(),
|
|
808
|
+
x: z.number().optional(),
|
|
809
|
+
y: z.number().optional(),
|
|
810
|
+
duration: z.number().optional(),
|
|
811
|
+
direction: z.enum(['up', 'down', 'left', 'right']).optional(),
|
|
812
|
+
startX: z.number().optional(),
|
|
813
|
+
startY: z.number().optional(),
|
|
814
|
+
endX: z.number().optional(),
|
|
815
|
+
endY: z.number().optional(),
|
|
816
|
+
distance: z.number().optional(),
|
|
817
|
+
text: z.string().optional(),
|
|
818
|
+
seconds: z.number().optional(),
|
|
819
|
+
name: z.string().optional(),
|
|
820
|
+
compare: z.boolean().optional(),
|
|
821
|
+
tolerance: z.number().optional(),
|
|
822
|
+
// full_page_checkpoint / smart_checkpoint / scroll_to_top / scroll_to_bottom options
|
|
823
|
+
maxScrolls: z.number().optional(),
|
|
824
|
+
scrollAmount: z.number().optional(),
|
|
825
|
+
stitchImages: z.boolean().optional(),
|
|
826
|
+
url: z.string().optional(),
|
|
827
|
+
// set_location
|
|
828
|
+
latitude: z.number().optional(),
|
|
829
|
+
longitude: z.number().optional(),
|
|
830
|
+
// simulate_route
|
|
831
|
+
waypoints: z.array(waypointSchema).optional(),
|
|
832
|
+
intervalMs: z.number().optional(),
|
|
833
|
+
captureAtWaypoints: z.boolean().optional(),
|
|
834
|
+
captureDelayMs: z.number().optional(),
|
|
835
|
+
waypointCheckpointName: z.string().optional(),
|
|
836
|
+
});
|
|
837
|
+
server.tool('create_test_case', `Create a new test case with scenario steps and capture baseline screenshots.
|
|
838
|
+
|
|
839
|
+
Workflow:
|
|
840
|
+
1. Use screenshot to see the screen, use describe_ui for precise coordinates
|
|
841
|
+
2. Use tap/swipe/type_text to navigate
|
|
842
|
+
3. Use smart_checkpoint for EVERY screen to verify
|
|
843
|
+
|
|
844
|
+
CRITICAL - Scrollable Content:
|
|
845
|
+
- ALWAYS use smart_checkpoint (NOT checkpoint) for screens
|
|
846
|
+
- smart_checkpoint auto-detects scrollable content and captures full page
|
|
847
|
+
- It scrolls through ALL content and stitches screenshots together
|
|
848
|
+
- This ensures content below the fold is also verified
|
|
849
|
+
|
|
850
|
+
Available checkpoint actions:
|
|
851
|
+
- checkpoint: Single screenshot (use only for non-scrollable screens)
|
|
852
|
+
- smart_checkpoint: Auto-detects scroll, captures full page if scrollable (RECOMMENDED)
|
|
853
|
+
- full_page_checkpoint: Forces full page capture with scroll
|
|
854
|
+
|
|
855
|
+
IMPORTANT: Do NOT modify app source code. Only create test scenarios.`, {
|
|
856
|
+
name: z.string().describe('Test case name/ID (used as directory name, e.g., "login-flow")'),
|
|
857
|
+
displayName: z.string().optional().describe('Human-readable name (e.g., "ログインフロー")'),
|
|
858
|
+
description: z.string().optional().describe('Description of what this test case does'),
|
|
859
|
+
steps: z.array(stepSchema).optional().describe('Array of scenario steps. If not provided, creates a template.'),
|
|
860
|
+
createBaselines: z.boolean().optional().default(false).describe('Run the test case immediately to capture baseline screenshots'),
|
|
861
|
+
deviceUdid: z.string().optional().describe('Target simulator UDID (required if createBaselines is true)'),
|
|
862
|
+
snapdriveDir: z.string().optional().describe('Path to .snapdrive directory'),
|
|
863
|
+
}, async ({ name, displayName, description, steps, createBaselines = false, deviceUdid, snapdriveDir }) => {
|
|
864
|
+
try {
|
|
865
|
+
const { writeFile } = await import('node:fs/promises');
|
|
866
|
+
const { stringify } = await import('yaml');
|
|
867
|
+
const baseDir = snapdriveDir ?? join(process.cwd(), '.snapdrive');
|
|
868
|
+
const testCasePath = join(baseDir, 'test-cases', name);
|
|
869
|
+
const scenarioPath = join(testCasePath, 'scenario.yaml');
|
|
870
|
+
const baselinesDir = join(testCasePath, 'baselines');
|
|
871
|
+
// Create directories
|
|
872
|
+
await mkdir(testCasePath, { recursive: true });
|
|
873
|
+
await mkdir(baselinesDir, { recursive: true });
|
|
874
|
+
// Use provided steps or create template
|
|
875
|
+
const rawSteps = steps ?? [
|
|
876
|
+
{ action: 'launch_app', bundleId: 'com.example.app' },
|
|
877
|
+
{ action: 'wait', seconds: 1 },
|
|
878
|
+
{ action: 'checkpoint', name: 'initial_screen', compare: true },
|
|
879
|
+
];
|
|
880
|
+
// Normalize coordinates to 6 decimal places for reproducibility
|
|
881
|
+
const scenarioSteps = normalizeStepCoordinates(rawSteps);
|
|
882
|
+
const scenario = {
|
|
883
|
+
name: displayName ?? name,
|
|
884
|
+
description: description ?? 'Test case description',
|
|
885
|
+
steps: scenarioSteps,
|
|
886
|
+
};
|
|
887
|
+
await writeFile(scenarioPath, stringify(scenario), 'utf-8');
|
|
888
|
+
// Optionally run immediately to create baselines
|
|
889
|
+
let runResult = null;
|
|
890
|
+
if (createBaselines && steps) {
|
|
891
|
+
const testCase = await scenarioRunner.loadTestCase(testCasePath);
|
|
892
|
+
runResult = await scenarioRunner.runTestCase(testCase, {
|
|
893
|
+
deviceUdid,
|
|
894
|
+
updateBaselines: true,
|
|
895
|
+
resultsDir: context.resultsDir,
|
|
896
|
+
testCasePath,
|
|
897
|
+
});
|
|
898
|
+
}
|
|
899
|
+
const message = createBaselines && runResult
|
|
900
|
+
? `Test case created and baselines captured. ${runResult.checkpoints.length} checkpoint(s) saved.`
|
|
901
|
+
: steps
|
|
902
|
+
? `Test case created with ${steps.length} steps. Use createBaselines=true or run separately to capture screenshots.`
|
|
903
|
+
: 'Template created. Edit scenario.yaml or use create_test_case with steps parameter.';
|
|
904
|
+
return {
|
|
905
|
+
content: [
|
|
906
|
+
{
|
|
907
|
+
type: 'text',
|
|
908
|
+
text: JSON.stringify({
|
|
909
|
+
success: true,
|
|
910
|
+
testCasePath,
|
|
911
|
+
scenarioPath,
|
|
912
|
+
baselinesDir,
|
|
913
|
+
stepsCount: scenarioSteps.length,
|
|
914
|
+
baselinesCreated: createBaselines && runResult ? true : false,
|
|
915
|
+
checkpointsCaptured: runResult?.checkpoints.length ?? 0,
|
|
916
|
+
message,
|
|
917
|
+
}, null, 2),
|
|
918
|
+
},
|
|
919
|
+
],
|
|
920
|
+
};
|
|
921
|
+
}
|
|
922
|
+
catch (error) {
|
|
923
|
+
return {
|
|
924
|
+
content: [{ type: 'text', text: `Error: ${String(error)}` }],
|
|
925
|
+
isError: true,
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
});
|
|
929
|
+
logger.info('SnapDrive MCP Server initialized');
|
|
930
|
+
return server;
|
|
931
|
+
}
|
|
932
|
+
export async function startServer() {
|
|
933
|
+
const config = {
|
|
934
|
+
resultsDir: process.env['SNAPDRIVE_RESULTS_DIR'] ?? './results',
|
|
935
|
+
logLevel: process.env['SNAPDRIVE_LOG_LEVEL'] ?? 'info',
|
|
936
|
+
};
|
|
937
|
+
const context = createServerContext(config);
|
|
938
|
+
const server = await createServer(context);
|
|
939
|
+
const transport = new StdioServerTransport();
|
|
940
|
+
await server.connect(transport);
|
|
941
|
+
context.logger.info('SnapDrive MCP Server started');
|
|
942
|
+
}
|
|
943
|
+
//# sourceMappingURL=server.js.map
|