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