@mseep/ai-tech-app-agent 1.0.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 (67) hide show
  1. package/.env +24 -0
  2. package/.env.example +24 -0
  3. package/Jenkinsfile +210 -0
  4. package/MCP-SERVER-GUIDE.md +405 -0
  5. package/README.MD +450 -0
  6. package/dist/config/app.config.d.ts +65 -0
  7. package/dist/config/app.config.d.ts.map +1 -0
  8. package/dist/config/app.config.js +94 -0
  9. package/dist/config/app.config.js.map +1 -0
  10. package/dist/config/llm.config.d.ts +63 -0
  11. package/dist/config/llm.config.d.ts.map +1 -0
  12. package/dist/config/llm.config.js +158 -0
  13. package/dist/config/llm.config.js.map +1 -0
  14. package/dist/config/mcp.config.d.ts +175 -0
  15. package/dist/config/mcp.config.d.ts.map +1 -0
  16. package/dist/config/mcp.config.js +215 -0
  17. package/dist/config/mcp.config.js.map +1 -0
  18. package/dist/index.d.ts +3 -0
  19. package/dist/index.d.ts.map +1 -0
  20. package/dist/index.js +175 -0
  21. package/dist/index.js.map +1 -0
  22. package/dist/llm/llamaClient.d.ts +14 -0
  23. package/dist/llm/llamaClient.d.ts.map +1 -0
  24. package/dist/llm/llamaClient.js +136 -0
  25. package/dist/llm/llamaClient.js.map +1 -0
  26. package/dist/mcp/mcpClient.d.ts +132 -0
  27. package/dist/mcp/mcpClient.d.ts.map +1 -0
  28. package/dist/mcp/mcpClient.js +784 -0
  29. package/dist/mcp/mcpClient.js.map +1 -0
  30. package/dist/models/testSpec.d.ts +78 -0
  31. package/dist/models/testSpec.d.ts.map +1 -0
  32. package/dist/models/testSpec.js +3 -0
  33. package/dist/models/testSpec.js.map +1 -0
  34. package/dist/orchestrator/aiTestRunner.d.ts +18 -0
  35. package/dist/orchestrator/aiTestRunner.d.ts.map +1 -0
  36. package/dist/orchestrator/aiTestRunner.js +247 -0
  37. package/dist/orchestrator/aiTestRunner.js.map +1 -0
  38. package/dist/utils/logger.d.ts +4 -0
  39. package/dist/utils/logger.d.ts.map +1 -0
  40. package/dist/utils/logger.js +49 -0
  41. package/dist/utils/logger.js.map +1 -0
  42. package/dist/utils/promptBuilder.d.ts +62 -0
  43. package/dist/utils/promptBuilder.d.ts.map +1 -0
  44. package/dist/utils/promptBuilder.js +333 -0
  45. package/dist/utils/promptBuilder.js.map +1 -0
  46. package/knowledge/app-knowledge.txt +100 -0
  47. package/logs/combined.log +486 -0
  48. package/logs/error.log +50 -0
  49. package/package.json +62 -0
  50. package/reports/screenshots/screenshot_1764535110518.png +0 -0
  51. package/reports/test-report.json +106 -0
  52. package/scripts/check-mcp-server.sh +100 -0
  53. package/scripts/extract-pom-knowledge.js +222 -0
  54. package/scripts/pre-test-setup.js +262 -0
  55. package/scripts/start-mcp-server.sh +76 -0
  56. package/src/config/app.config.ts +175 -0
  57. package/src/config/llm.config.ts +220 -0
  58. package/src/config/mcp.config.ts +291 -0
  59. package/src/index.ts +161 -0
  60. package/src/llm/llamaClient.ts +159 -0
  61. package/src/mcp/mcpClient.ts +878 -0
  62. package/src/models/testSpec.ts +85 -0
  63. package/src/orchestrator/aiTestRunner.ts +286 -0
  64. package/src/utils/logger.ts +59 -0
  65. package/src/utils/promptBuilder.ts +384 -0
  66. package/tests/nlp-specs/login-flow.yaml +31 -0
  67. package/tsconfig.json +31 -0
@@ -0,0 +1,878 @@
1
+ import { spawn, ChildProcess } from 'child_process';
2
+ import axios, { AxiosInstance } from 'axios';
3
+ import { XMLParser } from 'fast-xml-parser';
4
+ import fs from 'fs';
5
+ import path from 'path';
6
+ import mcpConfig, { isToolEnabled } from '../config/mcp.config';
7
+ import appConfig from '../config/app.config';
8
+ import logger from '../utils/logger';
9
+ import { Action, UIContext, UIElement, Selector } from '../models/testSpec';
10
+
11
+ export class MCPClient {
12
+ private mcpProcess: ChildProcess | null = null;
13
+ private sessionId: string | null = null;
14
+ private appiumServerUrl: string;
15
+ private appiumClient: AxiosInstance;
16
+ private xmlParser: XMLParser;
17
+ private platform: 'android' | 'ios' | null = null;
18
+
19
+ constructor(sessionId?: string) {
20
+ this.appiumServerUrl = mcpConfig.appium.serverUrl;
21
+ this.sessionId = sessionId || null;
22
+
23
+ // Initialize Appium HTTP client
24
+ this.appiumClient = axios.create({
25
+ baseURL: this.appiumServerUrl,
26
+ timeout: 120000, // Increased to 2 minutes for LambdaTest session creation
27
+ headers: {
28
+ 'Content-Type': 'application/json',
29
+ },
30
+ });
31
+
32
+ // Initialize XML parser
33
+ this.xmlParser = new XMLParser({
34
+ ignoreAttributes: false,
35
+ attributeNamePrefix: '',
36
+ textNodeName: 'text',
37
+ parseAttributeValue: true,
38
+ });
39
+
40
+ logger.info('MCPClient initialized');
41
+ }
42
+
43
+ /**
44
+ * Initialize MCP server and establish connection
45
+ */
46
+ async initialize(): Promise<void> {
47
+ try {
48
+ logger.info('Initializing MCP-Appium server...');
49
+
50
+ // If using stdio protocol, spawn the MCP server process
51
+ if (mcpConfig.server.protocol === 'stdio') {
52
+ await this.startMCPProcess();
53
+ }
54
+
55
+ // Verify Appium connection
56
+ await this.verifyAppiumConnection();
57
+
58
+ // If no session ID provided, create a new session
59
+ if (!this.sessionId) {
60
+ await this.createSession();
61
+ }
62
+
63
+ logger.info('MCP-Appium server initialized successfully');
64
+ } catch (error: any) {
65
+ logger.error(`Failed to initialize MCP server: ${error.message}`);
66
+ throw error;
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Start MCP server process (if using stdio)
72
+ */
73
+ private async startMCPProcess(): Promise<void> {
74
+ return new Promise((resolve, reject) => {
75
+ try {
76
+ // Start mcp-appium server
77
+ this.mcpProcess = spawn('npx', ['mcp-appium'], {
78
+ env: {
79
+ ...process.env,
80
+ APPIUM_SERVER_URL: this.appiumServerUrl,
81
+ APPIUM_SESSION_ID: this.sessionId || '',
82
+ },
83
+ stdio: ['pipe', 'pipe', 'pipe'],
84
+ });
85
+
86
+ this.mcpProcess.stdout?.on('data', (data) => {
87
+ logger.debug(`MCP stdout: ${data.toString()}`);
88
+ });
89
+
90
+ this.mcpProcess.stderr?.on('data', (data) => {
91
+ logger.debug(`MCP stderr: ${data.toString()}`);
92
+ });
93
+
94
+ this.mcpProcess.on('error', (error) => {
95
+ logger.error(`MCP process error: ${error.message}`);
96
+ reject(error);
97
+ });
98
+
99
+ // Give it a moment to start
100
+ setTimeout(() => resolve(), 2000);
101
+ } catch (error) {
102
+ reject(error);
103
+ }
104
+ });
105
+ }
106
+
107
+ /**
108
+ * Verify Appium server connection
109
+ */
110
+ private async verifyAppiumConnection(): Promise<void> {
111
+ try {
112
+ const response = await this.appiumClient.get('/status');
113
+ logger.info('Appium server is reachable');
114
+ logger.debug(`Appium status: ${JSON.stringify(response.data)}`);
115
+ } catch (error: any) {
116
+ throw new Error(`Cannot connect to Appium server at ${this.appiumServerUrl}: ${error.message}`);
117
+ }
118
+ }
119
+
120
+ /**
121
+ * Create a new Appium session with device capabilities
122
+ */
123
+ private async createSession(): Promise<void> {
124
+ const maxRetries = 3;
125
+ let lastError: any;
126
+
127
+ for (let attempt = 1; attempt <= maxRetries; attempt++) {
128
+ try {
129
+ logger.info(`Creating new Appium session (attempt ${attempt}/${maxRetries})...`);
130
+
131
+ // LambdaTest capabilities matching the provided format
132
+ const capabilities: any = {
133
+ platformName: 'Android',
134
+ 'appium:platformVersion': '13',
135
+ 'appium:deviceName': 'Galaxy S23',
136
+ 'appium:automationName': 'UiAutomator2',
137
+ 'appium:app': 'lt://APP1016060671764436009495779', // Upload your app to LambdaTest
138
+ 'appium:isRealMobile': true,
139
+ 'appium:newCommandTimeout': 300,
140
+ 'appium:autoGrantPermissions': true,
141
+ 'appium:noReset': true,
142
+ 'appium:fullReset': false,
143
+
144
+ // LambdaTest authentication (kept for backward compatibility)
145
+ username: appConfig.lambdatest.username,
146
+ accessKey: appConfig.lambdatest.accessKey,
147
+
148
+ // LambdaTest specific capabilities
149
+ 'lt:options': {
150
+ build: 'Hybrid AI Automation',
151
+ name: 'AI Test Session - ' + Date.now(),
152
+ video: true,
153
+ visual: true,
154
+ network: false,
155
+ console: true,
156
+ w3c: true,
157
+ retry: 0 // No retry
158
+ }
159
+ };
160
+
161
+ const sessionPayload = {
162
+ desiredCapabilities: capabilities
163
+ };
164
+
165
+ logger.debug(`Creating session with capabilities: ${JSON.stringify(sessionPayload, null, 2)}`);
166
+
167
+ const response = await this.appiumClient.post('/session', sessionPayload);
168
+
169
+ this.sessionId = response.data.sessionId || response.data.value?.sessionId;
170
+
171
+ if (!this.sessionId) {
172
+ throw new Error('Failed to get session ID from response');
173
+ }
174
+
175
+ // Auto-detect platform
176
+ this.platform = this.detectPlatformFromCapabilities(capabilities);
177
+
178
+ logger.info(`Appium session created successfully: ${this.sessionId}`);
179
+ logger.info(`Platform detected: ${this.platform}`);
180
+ return; // Success, exit the retry loop
181
+ } catch (error: any) {
182
+ lastError = error;
183
+
184
+ if (error.response) {
185
+ logger.error(`Session creation failed with status ${error.response.status}`);
186
+ logger.error(`Response body: ${JSON.stringify(error.response.data, null, 2)}`);
187
+ } else if (error.code === 'ECONNABORTED') {
188
+ logger.error(`Session creation timed out (attempt ${attempt}/${maxRetries})`);
189
+ } else {
190
+ logger.error(`Session creation failed: ${error.message} (attempt ${attempt}/${maxRetries})`);
191
+ }
192
+
193
+ // If not the last attempt, wait before retrying
194
+ if (attempt < maxRetries) {
195
+ const waitTime = attempt * 10000; // 10s, 20s backoff
196
+ logger.info(`Waiting ${waitTime/1000}s before retry...`);
197
+ await this.delay(waitTime);
198
+ }
199
+ }
200
+ }
201
+
202
+ // All retries failed
203
+ throw new Error(`Failed to create Appium session after ${maxRetries} attempts: ${lastError.message}`);
204
+ }
205
+
206
+ /**
207
+ * Detect platform from capabilities
208
+ */
209
+ private detectPlatformFromCapabilities(capabilities: any): 'android' | 'ios' {
210
+ const platformName = (capabilities.platformName || '').toLowerCase();
211
+ return platformName.includes('ios') ? 'ios' : 'android';
212
+ }
213
+
214
+ /**
215
+ * Set the active Appium session ID
216
+ */
217
+ setSessionId(sessionId: string, platform?: 'android' | 'ios'): void {
218
+ this.sessionId = sessionId;
219
+ this.platform = platform || null;
220
+ logger.info(`MCP client configured with session ID: ${sessionId}, platform: ${platform || 'auto-detect'}`);
221
+ }
222
+
223
+ /**
224
+ * Get current UI context (page source + parsed elements)
225
+ */
226
+ async getUIContext(): Promise<UIContext> {
227
+ try {
228
+ if (!this.sessionId) {
229
+ throw new Error('No active Appium session');
230
+ }
231
+
232
+ logger.debug('Fetching UI context from Appium...');
233
+
234
+ // Get page source (XML hierarchy)
235
+ const pageSource = await this.getPageSource();
236
+
237
+ // Parse visible elements from page source
238
+ const visibleElements = mcpConfig.uiContext.parseXML
239
+ ? this.parseUIElements(pageSource)
240
+ : [];
241
+
242
+ // Get current activity/package (Android) or bundle ID (iOS)
243
+ const currentActivity = await this.getCurrentActivity();
244
+
245
+ // Auto-detect platform if not set
246
+ if (!this.platform) {
247
+ this.platform = this.detectPlatformFromPageSource(pageSource);
248
+ }
249
+
250
+ const uiContext: UIContext = {
251
+ pageSource,
252
+ currentActivity,
253
+ visibleElements,
254
+ currentPackage: this.platform === 'android' ? await this.getCurrentPackage() : undefined,
255
+ };
256
+
257
+ logger.debug(`UI context fetched: ${visibleElements.length} elements found, activity: ${currentActivity}`);
258
+ return uiContext;
259
+ } catch (error: any) {
260
+ logger.error(`Failed to get UI context: ${error.message}`);
261
+ throw error;
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Get page source (XML hierarchy)
267
+ */
268
+ private async getPageSource(): Promise<string> {
269
+ try {
270
+ const response = await this.appiumClient.get(`/session/${this.sessionId}/source`);
271
+ return response.data.value || '';
272
+ } catch (error: any) {
273
+ logger.error(`Failed to get page source: ${error.message}`);
274
+ throw error;
275
+ }
276
+ }
277
+
278
+ /**
279
+ * Get current activity (Android) or current view (iOS)
280
+ */
281
+ private async getCurrentActivity(): Promise<string> {
282
+ try {
283
+ if (!this.platform) {
284
+ this.platform = await this.detectPlatform();
285
+ }
286
+
287
+ if (this.platform === 'android') {
288
+ const response = await this.appiumClient.get(`/session/${this.sessionId}/appium/device/current_activity`);
289
+ return response.data.value || 'Unknown';
290
+ } else {
291
+ // For iOS, we can get the active app bundle ID
292
+ const response = await this.appiumClient.get(`/session/${this.sessionId}/appium/device/current_package`);
293
+ return response.data.value || 'Unknown';
294
+ }
295
+ } catch (error: any) {
296
+ logger.warn(`Could not get current activity: ${error.message}`);
297
+ return 'Unknown';
298
+ }
299
+ }
300
+
301
+ /**
302
+ * Get current package (Android only)
303
+ */
304
+ private async getCurrentPackage(): Promise<string | undefined> {
305
+ try {
306
+ if (this.platform === 'android') {
307
+ const response = await this.appiumClient.get(`/session/${this.sessionId}/appium/device/current_package`);
308
+ return response.data.value;
309
+ }
310
+ } catch (error: any) {
311
+ logger.warn(`Could not get current package: ${error.message}`);
312
+ }
313
+ return undefined;
314
+ }
315
+
316
+ /**
317
+ * Detect platform from session capabilities
318
+ */
319
+ private async detectPlatform(): Promise<'android' | 'ios'> {
320
+ try {
321
+ const response = await this.appiumClient.get(`/session/${this.sessionId}`);
322
+ const caps = response.data.value || response.data;
323
+ const platformName = (caps.platformName || '').toLowerCase();
324
+
325
+ if (platformName.includes('android')) {
326
+ return 'android';
327
+ } else if (platformName.includes('ios')) {
328
+ return 'ios';
329
+ }
330
+ } catch (error: any) {
331
+ logger.warn(`Could not detect platform: ${error.message}`);
332
+ }
333
+
334
+ // Default to android
335
+ return 'android';
336
+ }
337
+
338
+ /**
339
+ * Detect platform from page source XML
340
+ */
341
+ private detectPlatformFromPageSource(pageSource: string): 'android' | 'ios' {
342
+ if (pageSource.includes('XCUIElement') || pageSource.includes('AppiumAUT')) {
343
+ return 'ios';
344
+ }
345
+ return 'android';
346
+ }
347
+
348
+ /**
349
+ * Parse UI elements from XML page source
350
+ */
351
+ private parseUIElements(pageSource: string): UIElement[] {
352
+ try {
353
+ const parsed = this.xmlParser.parse(pageSource);
354
+ const elements: UIElement[] = [];
355
+
356
+ // Recursively extract elements
357
+ this.extractElementsRecursive(parsed, elements, 0);
358
+
359
+ // Apply filters
360
+ return this.filterElements(elements);
361
+ } catch (error: any) {
362
+ logger.error(`Failed to parse XML: ${error.message}`);
363
+ return [];
364
+ }
365
+ }
366
+
367
+ /**
368
+ * Recursively extract elements from parsed XML
369
+ */
370
+ private extractElementsRecursive(node: any, elements: UIElement[], depth: number): void {
371
+ if (depth > mcpConfig.uiContext.maxElementDepth) {
372
+ return;
373
+ }
374
+
375
+ if (!node || typeof node !== 'object') {
376
+ return;
377
+ }
378
+
379
+ // Check if this is an element node
380
+ if (node.class || node['resource-id'] || node['content-desc'] || node.name) {
381
+ const element: UIElement = {
382
+ type: node.class || node.type || 'Unknown',
383
+ text: node.text || node.label || node.value,
384
+ resourceId: node['resource-id'] || node.name,
385
+ contentDesc: node['content-desc'] || node.label,
386
+ className: node.class || node.type,
387
+ bounds: node.bounds,
388
+ clickable: node.clickable === 'true' || node.clickable === true,
389
+ enabled: node.enabled === 'true' || node.enabled === true || node.enabled === undefined,
390
+ index: node.index,
391
+ };
392
+
393
+ elements.push(element);
394
+ }
395
+
396
+ // Recursively process children
397
+ Object.keys(node).forEach(key => {
398
+ if (typeof node[key] === 'object') {
399
+ if (Array.isArray(node[key])) {
400
+ node[key].forEach((child: any) => {
401
+ this.extractElementsRecursive(child, elements, depth + 1);
402
+ });
403
+ } else {
404
+ this.extractElementsRecursive(node[key], elements, depth + 1);
405
+ }
406
+ }
407
+ });
408
+ }
409
+
410
+ /**
411
+ * Filter elements based on configuration
412
+ */
413
+ private filterElements(elements: UIElement[]): UIElement[] {
414
+ const { excludeClasses, includeOnlyInteractive, minTextLength } = mcpConfig.uiContext.elementFilters;
415
+
416
+ return elements.filter(element => {
417
+ // Exclude certain classes
418
+ if (excludeClasses.some(cls => element.className?.includes(cls))) {
419
+ return false;
420
+ }
421
+
422
+ // Include only interactive elements
423
+ if (includeOnlyInteractive && !element.clickable && !element.type.includes('Edit')) {
424
+ return false;
425
+ }
426
+
427
+ // Filter by text length
428
+ if (element.text && element.text.length < minTextLength) {
429
+ return false;
430
+ }
431
+
432
+ return true;
433
+ });
434
+ }
435
+
436
+ /**
437
+ * Execute an action
438
+ */
439
+ async executeAction(action: Action): Promise<boolean> {
440
+ try {
441
+ logger.info(`Executing action: ${action.type}`);
442
+
443
+ switch (action.type) {
444
+ case 'tap':
445
+ return await this.tap(action);
446
+ case 'type':
447
+ return await this.type(action);
448
+ case 'scroll':
449
+ return await this.scroll(action);
450
+ case 'swipe':
451
+ return await this.swipe(action);
452
+ case 'wait':
453
+ return await this.wait(action);
454
+ case 'assert':
455
+ return await this.assert(action);
456
+ case 'screenshot':
457
+ return await this.screenshot();
458
+ case 'launch':
459
+ return await this.launch(action);
460
+ default:
461
+ logger.warn(`Unknown action type: ${action.type}`);
462
+ return false;
463
+ }
464
+ } catch (error: any) {
465
+ logger.error(`Action execution failed: ${error.message}`);
466
+
467
+ if (mcpConfig.errorHandling.captureScreenshotOnError) {
468
+ await this.screenshot().catch(() => {});
469
+ }
470
+
471
+ return false;
472
+ }
473
+ }
474
+
475
+ /**
476
+ * Tap/click on an element
477
+ */
478
+ private async tap(action: Action): Promise<boolean> {
479
+ if (!action.selector) {
480
+ logger.error('Tap action requires a selector');
481
+ return false;
482
+ }
483
+
484
+ logger.debug(`Tapping element: ${action.selector.strategy}=${action.selector.value}`);
485
+
486
+ try {
487
+ const element = await this.findElement(action.selector);
488
+
489
+ if (!element) {
490
+ throw new Error('Element not found');
491
+ }
492
+
493
+ await this.appiumClient.post(`/session/${this.sessionId}/element/${element}/click`, {});
494
+
495
+ // Small delay after tap
496
+ await this.delay(appConfig.interaction.actionDelay);
497
+
498
+ return true;
499
+ } catch (error: any) {
500
+ logger.error(`Tap failed: ${error.message}`);
501
+ return false;
502
+ }
503
+ }
504
+
505
+ /**
506
+ * Type text into an element
507
+ */
508
+ private async type(action: Action): Promise<boolean> {
509
+ if (!action.selector || !action.value) {
510
+ logger.error('Type action requires selector and value');
511
+ return false;
512
+ }
513
+
514
+ logger.debug(`Typing "${action.value}" into element: ${action.selector.strategy}=${action.selector.value}`);
515
+
516
+ try {
517
+ const element = await this.findElement(action.selector);
518
+
519
+ if (!element) {
520
+ throw new Error('Element not found');
521
+ }
522
+
523
+ // Clear existing text if needed
524
+ await this.appiumClient.post(`/session/${this.sessionId}/element/${element}/clear`, {});
525
+
526
+ // Type the text
527
+ await this.appiumClient.post(`/session/${this.sessionId}/element/${element}/value`, {
528
+ text: action.value,
529
+ value: action.value.split(''),
530
+ });
531
+
532
+ await this.delay(appConfig.interaction.actionDelay);
533
+
534
+ return true;
535
+ } catch (error: any) {
536
+ logger.error(`Type failed: ${error.message}`);
537
+ return false;
538
+ }
539
+ }
540
+
541
+ /**
542
+ * Scroll the screen
543
+ */
544
+ private async scroll(action: Action): Promise<boolean> {
545
+ const direction = action.direction || 'down';
546
+ logger.debug(`Scrolling ${direction}`);
547
+
548
+ try {
549
+ // Get screen size
550
+ const size = await this.getScreenSize();
551
+ const { width, height } = size;
552
+
553
+ const centerX = width / 2;
554
+ const startY = direction === 'down' ? height * 0.8 : height * 0.2;
555
+ const endY = direction === 'down' ? height * 0.2 : height * 0.8;
556
+
557
+ await this.performSwipeGesture(centerX, startY, centerX, endY, appConfig.interaction.swipeSpeed);
558
+
559
+ return true;
560
+ } catch (error: any) {
561
+ logger.error(`Scroll failed: ${error.message}`);
562
+ return false;
563
+ }
564
+ }
565
+
566
+ /**
567
+ * Swipe gesture
568
+ */
569
+ private async swipe(action: Action): Promise<boolean> {
570
+ const direction = action.direction || 'left';
571
+ logger.debug(`Swiping ${direction}`);
572
+
573
+ try {
574
+ const size = await this.getScreenSize();
575
+ const { width, height } = size;
576
+
577
+ let startX, startY, endX, endY;
578
+
579
+ switch (direction) {
580
+ case 'left':
581
+ startX = width * 0.8;
582
+ startY = height / 2;
583
+ endX = width * 0.2;
584
+ endY = height / 2;
585
+ break;
586
+ case 'right':
587
+ startX = width * 0.2;
588
+ startY = height / 2;
589
+ endX = width * 0.8;
590
+ endY = height / 2;
591
+ break;
592
+ case 'up':
593
+ startX = width / 2;
594
+ startY = height * 0.8;
595
+ endX = width / 2;
596
+ endY = height * 0.2;
597
+ break;
598
+ case 'down':
599
+ startX = width / 2;
600
+ startY = height * 0.2;
601
+ endX = width / 2;
602
+ endY = height * 0.8;
603
+ break;
604
+ default:
605
+ startX = width / 2;
606
+ startY = height / 2;
607
+ endX = width / 2;
608
+ endY = height / 2;
609
+ }
610
+
611
+ await this.performSwipeGesture(startX, startY, endX, endY, action.duration || appConfig.interaction.swipeSpeed);
612
+
613
+ return true;
614
+ } catch (error: any) {
615
+ logger.error(`Swipe failed: ${error.message}`);
616
+ return false;
617
+ }
618
+ }
619
+
620
+ /**
621
+ * Perform swipe gesture using touch actions
622
+ */
623
+ private async performSwipeGesture(startX: number, startY: number, endX: number, endY: number, duration: number): Promise<void> {
624
+ const actions = [
625
+ {
626
+ type: 'pointer',
627
+ id: 'finger1',
628
+ parameters: { pointerType: 'touch' },
629
+ actions: [
630
+ { type: 'pointerMove', duration: 0, x: Math.round(startX), y: Math.round(startY) },
631
+ { type: 'pointerDown', button: 0 },
632
+ { type: 'pause', duration: 100 },
633
+ { type: 'pointerMove', duration, x: Math.round(endX), y: Math.round(endY) },
634
+ { type: 'pointerUp', button: 0 },
635
+ ],
636
+ },
637
+ ];
638
+
639
+ await this.appiumClient.post(`/session/${this.sessionId}/actions`, { actions });
640
+ }
641
+
642
+ /**
643
+ * Wait for duration or element
644
+ */
645
+ private async wait(action: Action): Promise<boolean> {
646
+ const duration = action.duration || 2000;
647
+ logger.debug(`Waiting for ${duration}ms`);
648
+
649
+ await this.delay(duration);
650
+ return true;
651
+ }
652
+
653
+ /**
654
+ * Assert element state
655
+ */
656
+ private async assert(action: Action): Promise<boolean> {
657
+ if (!action.selector || !action.assertionType) {
658
+ logger.error('Assert action requires selector and assertionType');
659
+ return false;
660
+ }
661
+
662
+ logger.debug(`Asserting: ${action.assertionType} on ${action.selector.value}`);
663
+
664
+ try {
665
+ const element = await this.findElement(action.selector);
666
+
667
+ switch (action.assertionType) {
668
+ case 'exists':
669
+ return element !== null;
670
+
671
+ case 'visible':
672
+ if (!element) return false;
673
+ const displayed = await this.appiumClient.get(`/session/${this.sessionId}/element/${element}/displayed`);
674
+ return displayed.data.value === true;
675
+
676
+ case 'enabled':
677
+ if (!element) return false;
678
+ const enabled = await this.appiumClient.get(`/session/${this.sessionId}/element/${element}/enabled`);
679
+ return enabled.data.value === true;
680
+
681
+ case 'text':
682
+ if (!element) return false;
683
+ const text = await this.appiumClient.get(`/session/${this.sessionId}/element/${element}/text`);
684
+ return text.data.value === action.expectedValue;
685
+
686
+ default:
687
+ logger.warn(`Unknown assertion type: ${action.assertionType}`);
688
+ return false;
689
+ }
690
+ } catch (error: any) {
691
+ logger.error(`Assertion failed: ${error.message}`);
692
+ return false;
693
+ }
694
+ }
695
+
696
+ /**
697
+ * Launch an app or return to home screen
698
+ */
699
+ private async launch(action: Action): Promise<boolean> {
700
+ logger.debug('Launching app or returning to home');
701
+
702
+ try {
703
+ // If no specific app package is specified, we're already in a session
704
+ // Just return true as the device is ready
705
+ if (!action.value) {
706
+ logger.info('Device session ready - no specific app launch required');
707
+ return true;
708
+ }
709
+
710
+ // If a specific app package is provided, try to launch it
711
+ const appPackage = action.value;
712
+ logger.debug(`Attempting to launch app: ${appPackage}`);
713
+
714
+ // For Android, we can try to activate the app
715
+ if (this.platform === 'android') {
716
+ await this.appiumClient.post(`/session/${this.sessionId}/appium/device/activate_app`, {
717
+ appId: appPackage
718
+ });
719
+ } else {
720
+ // For iOS, we can try to launch the app by bundle ID
721
+ await this.appiumClient.post(`/session/${this.sessionId}/appium/device/activate_app`, {
722
+ bundleId: appPackage
723
+ });
724
+ }
725
+
726
+ await this.delay(appConfig.interaction.actionDelay);
727
+ return true;
728
+ } catch (error: any) {
729
+ logger.warn(`Launch action completed with warning: ${error.message}`);
730
+ // Return true anyway as the session is established
731
+ return true;
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Take screenshot
737
+ */
738
+ private async screenshot(): Promise<boolean> {
739
+ logger.debug('Taking screenshot');
740
+
741
+ try {
742
+ const response = await this.appiumClient.get(`/session/${this.sessionId}/screenshot`);
743
+ const screenshotData = response.data.value;
744
+
745
+ // Save screenshot
746
+ const timestamp = Date.now();
747
+ const filename = `screenshot_${timestamp}.png`;
748
+ const filepath = path.join(appConfig.paths.screenshots, filename);
749
+
750
+ // Ensure directory exists
751
+ if (!fs.existsSync(appConfig.paths.screenshots)) {
752
+ fs.mkdirSync(appConfig.paths.screenshots, { recursive: true });
753
+ }
754
+
755
+ fs.writeFileSync(filepath, screenshotData, 'base64');
756
+ logger.info(`Screenshot saved: ${filepath}`);
757
+
758
+ return true;
759
+ } catch (error: any) {
760
+ logger.error(`Screenshot failed: ${error.message}`);
761
+ return false;
762
+ }
763
+ }
764
+
765
+ /**
766
+ * Find element using selector
767
+ */
768
+ private async findElement(selector: Selector): Promise<string | null> {
769
+ try {
770
+ const { strategy, value, index } = selector;
771
+
772
+ // Map our strategy names to Appium's
773
+ const strategyMap: Record<string, string> = {
774
+ 'text': this.platform === 'android' ? 'xpath' : '-ios predicate string',
775
+ 'id': this.platform === 'android' ? 'id' : 'accessibility id',
776
+ 'accessibility-id': 'accessibility id',
777
+ 'xpath': 'xpath',
778
+ 'class': 'class name',
779
+ };
780
+
781
+ let appiumStrategy = strategyMap[strategy] || strategy;
782
+ let appiumValue = value;
783
+
784
+ // Special handling for text search
785
+ if (strategy === 'text') {
786
+ if (this.platform === 'android') {
787
+ appiumStrategy = 'xpath';
788
+ appiumValue = `//*[@text='${value}' or contains(@text,'${value}')]`;
789
+ } else {
790
+ appiumStrategy = '-ios predicate string';
791
+ appiumValue = `label == "${value}" OR name == "${value}"`;
792
+ }
793
+ }
794
+
795
+ const payload = {
796
+ using: appiumStrategy,
797
+ value: appiumValue,
798
+ };
799
+
800
+ const response = await this.appiumClient.post(`/session/${this.sessionId}/element`, payload);
801
+
802
+ if (index !== undefined && index > 0) {
803
+ // If index is specified, find multiple and return the one at index
804
+ const multiResponse = await this.appiumClient.post(`/session/${this.sessionId}/elements`, payload);
805
+ const elements = multiResponse.data.value;
806
+
807
+ if (elements && elements.length > index) {
808
+ return elements[index].ELEMENT || elements[index]['element-6066-11e4-a52e-4f735466cecf'];
809
+ }
810
+ return null;
811
+ }
812
+
813
+ const elementId = response.data.value?.ELEMENT || response.data.value?.['element-6066-11e4-a52e-4f735466cecf'];
814
+ return elementId || null;
815
+ } catch (error: any) {
816
+ logger.debug(`Element not found: ${selector.strategy}=${selector.value}`);
817
+ return null;
818
+ }
819
+ }
820
+
821
+ /**
822
+ * Get screen size
823
+ */
824
+ private async getScreenSize(): Promise<{ width: number; height: number }> {
825
+ try {
826
+ const response = await this.appiumClient.get(`/session/${this.sessionId}/window/rect`);
827
+ return {
828
+ width: response.data.value.width,
829
+ height: response.data.value.height,
830
+ };
831
+ } catch (error: any) {
832
+ logger.warn(`Could not get screen size: ${error.message}`);
833
+ // Default fallback
834
+ return { width: 1080, height: 1920 };
835
+ }
836
+ }
837
+
838
+ /**
839
+ * Delay helper
840
+ */
841
+ private delay(ms: number): Promise<void> {
842
+ return new Promise(resolve => setTimeout(resolve, ms));
843
+ }
844
+
845
+ /**
846
+ * Stop the current Appium session
847
+ */
848
+ async stopSession(): Promise<void> {
849
+ if (this.sessionId) {
850
+ try {
851
+ logger.info(`Stopping Appium session: ${this.sessionId}`);
852
+ await this.appiumClient.delete(`/session/${this.sessionId}`);
853
+ logger.info('Appium session stopped successfully');
854
+ } catch (error: any) {
855
+ logger.warn(`Failed to stop session: ${error.message}`);
856
+ } finally {
857
+ this.sessionId = null;
858
+ this.platform = null;
859
+ }
860
+ }
861
+ }
862
+
863
+ /**
864
+ * Cleanup and shutdown
865
+ */
866
+ async cleanup(): Promise<void> {
867
+ // Stop the Appium session first
868
+ await this.stopSession();
869
+
870
+ if (this.mcpProcess) {
871
+ logger.info('Shutting down MCP server...');
872
+ this.mcpProcess.kill();
873
+ this.mcpProcess = null;
874
+ }
875
+ }
876
+ }
877
+
878
+ export default MCPClient;