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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.ja.md +95 -0
  3. package/README.md +95 -0
  4. package/dist/cli.d.ts +7 -0
  5. package/dist/cli.d.ts.map +1 -0
  6. package/dist/cli.js +265 -0
  7. package/dist/cli.js.map +1 -0
  8. package/dist/core/command-executor.d.ts +15 -0
  9. package/dist/core/command-executor.d.ts.map +1 -0
  10. package/dist/core/command-executor.js +64 -0
  11. package/dist/core/command-executor.js.map +1 -0
  12. package/dist/core/element-finder.d.ts +81 -0
  13. package/dist/core/element-finder.d.ts.map +1 -0
  14. package/dist/core/element-finder.js +246 -0
  15. package/dist/core/element-finder.js.map +1 -0
  16. package/dist/core/idb-client.d.ts +68 -0
  17. package/dist/core/idb-client.d.ts.map +1 -0
  18. package/dist/core/idb-client.js +327 -0
  19. package/dist/core/idb-client.js.map +1 -0
  20. package/dist/core/image-differ.d.ts +55 -0
  21. package/dist/core/image-differ.d.ts.map +1 -0
  22. package/dist/core/image-differ.js +211 -0
  23. package/dist/core/image-differ.js.map +1 -0
  24. package/dist/core/index.d.ts +9 -0
  25. package/dist/core/index.d.ts.map +1 -0
  26. package/dist/core/index.js +9 -0
  27. package/dist/core/index.js.map +1 -0
  28. package/dist/core/report-generator.d.ts +31 -0
  29. package/dist/core/report-generator.d.ts.map +1 -0
  30. package/dist/core/report-generator.js +675 -0
  31. package/dist/core/report-generator.js.map +1 -0
  32. package/dist/core/scenario-runner.d.ts +54 -0
  33. package/dist/core/scenario-runner.d.ts.map +1 -0
  34. package/dist/core/scenario-runner.js +701 -0
  35. package/dist/core/scenario-runner.js.map +1 -0
  36. package/dist/core/simctl-client.d.ts +64 -0
  37. package/dist/core/simctl-client.d.ts.map +1 -0
  38. package/dist/core/simctl-client.js +214 -0
  39. package/dist/core/simctl-client.js.map +1 -0
  40. package/dist/index.d.ts +7 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +11 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/interfaces/config.interface.d.ts +37 -0
  45. package/dist/interfaces/config.interface.d.ts.map +1 -0
  46. package/dist/interfaces/config.interface.js +14 -0
  47. package/dist/interfaces/config.interface.js.map +1 -0
  48. package/dist/interfaces/element.interface.d.ts +49 -0
  49. package/dist/interfaces/element.interface.d.ts.map +1 -0
  50. package/dist/interfaces/element.interface.js +5 -0
  51. package/dist/interfaces/element.interface.js.map +1 -0
  52. package/dist/interfaces/index.d.ts +7 -0
  53. package/dist/interfaces/index.d.ts.map +1 -0
  54. package/dist/interfaces/index.js +7 -0
  55. package/dist/interfaces/index.js.map +1 -0
  56. package/dist/interfaces/scenario.interface.d.ts +101 -0
  57. package/dist/interfaces/scenario.interface.d.ts.map +1 -0
  58. package/dist/interfaces/scenario.interface.js +5 -0
  59. package/dist/interfaces/scenario.interface.js.map +1 -0
  60. package/dist/server.d.ts +28 -0
  61. package/dist/server.d.ts.map +1 -0
  62. package/dist/server.js +943 -0
  63. package/dist/server.js.map +1 -0
  64. package/dist/utils/index.d.ts +5 -0
  65. package/dist/utils/index.d.ts.map +1 -0
  66. package/dist/utils/index.js +5 -0
  67. package/dist/utils/index.js.map +1 -0
  68. package/dist/utils/logger.d.ts +24 -0
  69. package/dist/utils/logger.d.ts.map +1 -0
  70. package/dist/utils/logger.js +50 -0
  71. package/dist/utils/logger.js.map +1 -0
  72. 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