viewgate-mcp 1.0.62 → 1.0.63
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/dist/index.js +447 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -81,7 +81,11 @@ function createMcpServer(apiKey, personalKey) {
|
|
|
81
81
|
return "sync";
|
|
82
82
|
case "get_synced_endpoints":
|
|
83
83
|
case "get_ai_resolved_tickets":
|
|
84
|
+
case "get_e2e_flows":
|
|
85
|
+
case "export_e2e_flow":
|
|
84
86
|
return "idle";
|
|
87
|
+
case "process_e2e_events":
|
|
88
|
+
return "e2e";
|
|
85
89
|
default:
|
|
86
90
|
return "idle";
|
|
87
91
|
}
|
|
@@ -126,6 +130,10 @@ function createMcpServer(apiKey, personalKey) {
|
|
|
126
130
|
guard.flow = "sync";
|
|
127
131
|
guard.step = 1;
|
|
128
132
|
break;
|
|
133
|
+
case "process_e2e_events":
|
|
134
|
+
guard.flow = "e2e";
|
|
135
|
+
guard.step = 1;
|
|
136
|
+
break;
|
|
129
137
|
default:
|
|
130
138
|
if (guard.flow === "idle") {
|
|
131
139
|
throw new Error(`TOOL_CALL_BLOCKED: tool '${toolName}' not allowed in idle. Please start a workflow with 'get_annotations', 'get_ui_components', or 'get_ui_improvements'.`);
|
|
@@ -214,6 +222,14 @@ function createMcpServer(apiKey, personalKey) {
|
|
|
214
222
|
throw new Error("TOOL_CALL_BLOCKED: tool not allowed in active flow");
|
|
215
223
|
}
|
|
216
224
|
}
|
|
225
|
+
else if (guard.flow === "e2e") {
|
|
226
|
+
if (toolName === "process_e2e_events") {
|
|
227
|
+
guard.step = 1;
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
throw new Error("TOOL_CALL_BLOCKED: tool not allowed in active e2e flow");
|
|
231
|
+
}
|
|
232
|
+
}
|
|
217
233
|
guard.lastTool = toolName;
|
|
218
234
|
guard.lastActivityAt = now;
|
|
219
235
|
}
|
|
@@ -368,6 +384,41 @@ function createMcpServer(apiKey, personalKey) {
|
|
|
368
384
|
name: "get_ai_resolved_tickets",
|
|
369
385
|
description: "Retrieves the list of ticket IDs that have already been resolved by the AI in the current sprint.",
|
|
370
386
|
inputSchema: { type: "object", properties: {} },
|
|
387
|
+
},
|
|
388
|
+
{
|
|
389
|
+
name: "get_e2e_flows",
|
|
390
|
+
description: "Fetch all E2E test flows for a project. Returns generated test flows from user interactions.",
|
|
391
|
+
inputSchema: {
|
|
392
|
+
type: "object",
|
|
393
|
+
properties: {
|
|
394
|
+
projectId: { type: "string", description: "Project ID to filter flows." },
|
|
395
|
+
status: { type: "string", description: "Filter by status (active, degraded, needs_review, archived).", default: "active" }
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
},
|
|
399
|
+
{
|
|
400
|
+
name: "process_e2e_events",
|
|
401
|
+
description: "Process raw DOM events and generate a Test Flow. Transforms captured user interactions into structured E2E tests.",
|
|
402
|
+
inputSchema: {
|
|
403
|
+
type: "object",
|
|
404
|
+
properties: {
|
|
405
|
+
sessionId: { type: "string", description: "Session ID containing the events to process." },
|
|
406
|
+
flowName: { type: "string", description: "Suggested name for the flow (e.g., 'Login Flow')." },
|
|
407
|
+
projectId: { type: "string", description: "Project ID to associate with the flow." }
|
|
408
|
+
},
|
|
409
|
+
required: ["sessionId", "flowName"]
|
|
410
|
+
}
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
name: "export_e2e_flow",
|
|
414
|
+
description: "Export a test flow as Playwright code. Generates executable TypeScript test code.",
|
|
415
|
+
inputSchema: {
|
|
416
|
+
type: "object",
|
|
417
|
+
properties: {
|
|
418
|
+
flowId: { type: "string", description: "ID of the flow to export." }
|
|
419
|
+
},
|
|
420
|
+
required: ["flowId"]
|
|
421
|
+
}
|
|
371
422
|
}
|
|
372
423
|
],
|
|
373
424
|
};
|
|
@@ -837,6 +888,114 @@ Lang: ${rawData.preferredLanguage === 'es' ? 'ES' : 'EN'}
|
|
|
837
888
|
: (data.preferredLanguage === 'en' ? "\n*** [INSTRUCTION: Provide all comments and analysis in English.] ***\n\n\n\n" : "");
|
|
838
889
|
return { content: [{ type: "text", text: langHint + JSON.stringify(data, null, 2) }] };
|
|
839
890
|
}
|
|
891
|
+
case "get_e2e_flows": {
|
|
892
|
+
const args = argsAny;
|
|
893
|
+
const fetchUrl = new URL(`${BACKEND_URL}/api/e2e/flows`);
|
|
894
|
+
if (args.projectId)
|
|
895
|
+
fetchUrl.searchParams.append("projectId", args.projectId);
|
|
896
|
+
if (args.status)
|
|
897
|
+
fetchUrl.searchParams.append("status", args.status);
|
|
898
|
+
const response = await fetch(fetchUrl, {
|
|
899
|
+
headers: { 'x-api-key': apiKey, 'x-mcp-tool-name': toolName, ...(personalKey ? { 'x-personal-key': personalKey } : {}) }
|
|
900
|
+
});
|
|
901
|
+
if (!response.ok)
|
|
902
|
+
throw new Error(`Backend responded with ${response.status}`);
|
|
903
|
+
const data = (await response.json());
|
|
904
|
+
return { content: [{ type: "text", text: JSON.stringify(data, null, 2) }] };
|
|
905
|
+
}
|
|
906
|
+
case "process_e2e_events": {
|
|
907
|
+
const args = argsAny;
|
|
908
|
+
// Fetch UNPROCESSED events for the session (only new events)
|
|
909
|
+
const eventsResponse = await fetch(`${BACKEND_URL}/api/e2e/sessions/${args.sessionId}/events?unprocessed=true&limit=${args.limit || 50}`, {
|
|
910
|
+
headers: { 'x-api-key': apiKey, 'x-mcp-tool-name': toolName, ...(personalKey ? { 'x-personal-key': personalKey } : {}) }
|
|
911
|
+
});
|
|
912
|
+
if (!eventsResponse.ok)
|
|
913
|
+
throw new Error(`Failed to fetch events: ${eventsResponse.status}`);
|
|
914
|
+
const { events, totalUnprocessed } = await eventsResponse.json();
|
|
915
|
+
if (!events || events.length === 0) {
|
|
916
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
917
|
+
message: "No new events to process - all events already in flow",
|
|
918
|
+
sessionId: args.sessionId,
|
|
919
|
+
processed: true
|
|
920
|
+
}, null, 2) }] };
|
|
921
|
+
}
|
|
922
|
+
console.log(`[MCP E2E] Processing ${events.length} unprocessed events (${totalUnprocessed} total pending)`);
|
|
923
|
+
const workspaceId = args.workspaceId || events[0]?.workspace?.toString() || '';
|
|
924
|
+
const projectId = args.projectId || events[0]?.project?.toString() || '';
|
|
925
|
+
if (!workspaceId || !projectId) {
|
|
926
|
+
return { content: [{ type: "text", text: JSON.stringify({ error: "Could not determine workspace/project ID" }, null, 2) }] };
|
|
927
|
+
}
|
|
928
|
+
// Transform ONLY new events to steps
|
|
929
|
+
const newSteps = events.map(event => transformSingleEventToStep(event));
|
|
930
|
+
// Update flow with just the new steps
|
|
931
|
+
const updateResponse = await fetch(`${BACKEND_URL}/api/e2e/flows`, {
|
|
932
|
+
method: 'POST',
|
|
933
|
+
headers: {
|
|
934
|
+
'Content-Type': 'application/json',
|
|
935
|
+
'x-api-key': apiKey,
|
|
936
|
+
'x-internal-key': process.env.INTERNAL_API_KEY || ''
|
|
937
|
+
},
|
|
938
|
+
body: JSON.stringify({
|
|
939
|
+
workspaceId,
|
|
940
|
+
projectId,
|
|
941
|
+
name: args.flowName || `E2E Flow - ${new Date().toLocaleString()}`,
|
|
942
|
+
steps: newSteps,
|
|
943
|
+
metadata: {
|
|
944
|
+
processedAt: new Date().toISOString(),
|
|
945
|
+
eventsProcessed: events.length,
|
|
946
|
+
mcpGenerated: true
|
|
947
|
+
},
|
|
948
|
+
sessionId: args.sessionId,
|
|
949
|
+
markAsProcessed: true // Tell backend to mark these events as processed
|
|
950
|
+
})
|
|
951
|
+
});
|
|
952
|
+
if (!updateResponse.ok) {
|
|
953
|
+
const error = await updateResponse.text();
|
|
954
|
+
throw new Error(`Failed to update flow: ${error}`);
|
|
955
|
+
}
|
|
956
|
+
const result = (await updateResponse.json());
|
|
957
|
+
resetGuard();
|
|
958
|
+
return { content: [{ type: "text", text: JSON.stringify({
|
|
959
|
+
success: true,
|
|
960
|
+
message: `Added ${newSteps.length} new steps to E2E flow (processed ${events.length} events)`,
|
|
961
|
+
flow: result.flow,
|
|
962
|
+
newStepsCount: newSteps.length,
|
|
963
|
+
remainingUnprocessed: Math.max(0, totalUnprocessed - events.length)
|
|
964
|
+
}, null, 2) }] };
|
|
965
|
+
}
|
|
966
|
+
case "export_e2e_flow": {
|
|
967
|
+
const args = argsAny;
|
|
968
|
+
// Fetch project settings to get default testing framework if not specified
|
|
969
|
+
let framework = args.testingFramework;
|
|
970
|
+
if (!framework) {
|
|
971
|
+
try {
|
|
972
|
+
const projectResponse = await fetch(`${BACKEND_URL}/api/projects/current`, {
|
|
973
|
+
headers: { 'x-api-key': apiKey, 'x-mcp-tool-name': toolName, ...(personalKey ? { 'x-personal-key': personalKey } : {}) }
|
|
974
|
+
});
|
|
975
|
+
if (projectResponse.ok) {
|
|
976
|
+
const projectData = await projectResponse.json();
|
|
977
|
+
framework = projectData.project?.settings?.testingFramework || 'playwright';
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
catch (e) {
|
|
981
|
+
framework = 'playwright';
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
const response = await fetch(`${BACKEND_URL}/api/e2e/flows/${args.flowId}/export`, {
|
|
985
|
+
method: 'POST',
|
|
986
|
+
headers: {
|
|
987
|
+
'Content-Type': 'application/json',
|
|
988
|
+
'x-api-key': apiKey,
|
|
989
|
+
'x-mcp-tool-name': toolName,
|
|
990
|
+
...(personalKey ? { 'x-personal-key': personalKey } : {})
|
|
991
|
+
},
|
|
992
|
+
body: JSON.stringify({ testingFramework: framework })
|
|
993
|
+
});
|
|
994
|
+
if (!response.ok)
|
|
995
|
+
throw new Error(`Backend responded with ${response.status}`);
|
|
996
|
+
const data = (await response.json());
|
|
997
|
+
return { content: [{ type: "text", text: JSON.stringify({ ...data, testingFramework: framework }, null, 2) }] };
|
|
998
|
+
}
|
|
840
999
|
default:
|
|
841
1000
|
throw new Error("Unknown tool");
|
|
842
1001
|
}
|
|
@@ -848,6 +1007,294 @@ Lang: ${rawData.preferredLanguage === 'es' ? 'ES' : 'EN'}
|
|
|
848
1007
|
});
|
|
849
1008
|
return server;
|
|
850
1009
|
}
|
|
1010
|
+
/**
|
|
1011
|
+
* Transform raw DOM events into a structured Test Flow (IR)
|
|
1012
|
+
* Following the priority: data-testid > getByRole > getByLabel > text > CSS > XPath
|
|
1013
|
+
*/
|
|
1014
|
+
function transformEventsToTestFlow(events, flowName, workspaceId) {
|
|
1015
|
+
const steps = [];
|
|
1016
|
+
let lastUrl = '';
|
|
1017
|
+
for (const event of events) {
|
|
1018
|
+
const step = transformEventToStep(event, lastUrl);
|
|
1019
|
+
if (step) {
|
|
1020
|
+
steps.push(step);
|
|
1021
|
+
if (event.type === 'navigation' || step.type === 'goto') {
|
|
1022
|
+
lastUrl = event.url;
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
// Add automatic assertions for state changes
|
|
1027
|
+
const stepsWithAsserts = addImplicitAssertions(steps);
|
|
1028
|
+
// Calculate confidence based on selector quality
|
|
1029
|
+
const confidence = calculateFlowConfidence(stepsWithAsserts);
|
|
1030
|
+
return {
|
|
1031
|
+
name: flowName,
|
|
1032
|
+
workspaceId: workspaceId,
|
|
1033
|
+
steps: stepsWithAsserts,
|
|
1034
|
+
metadata: {
|
|
1035
|
+
createdAt: new Date().toISOString(),
|
|
1036
|
+
updatedAt: new Date().toISOString(),
|
|
1037
|
+
confidence,
|
|
1038
|
+
urlPattern: extractUrlPattern(events.map(e => e.url)),
|
|
1039
|
+
eventCount: events.length
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
}
|
|
1043
|
+
/**
|
|
1044
|
+
* Infer step type from user description using keyword analysis
|
|
1045
|
+
* Supports English and Spanish keywords
|
|
1046
|
+
*/
|
|
1047
|
+
function inferStepTypeFromDescription(description) {
|
|
1048
|
+
if (!description)
|
|
1049
|
+
return null;
|
|
1050
|
+
const desc = description.toLowerCase();
|
|
1051
|
+
// Assertion keywords (verify, validate, check, assert)
|
|
1052
|
+
if (/\b(verificar|validar|comprobar|asegurar|chequear|confirmar|assert|verify|validate|check|ensure|see|expect|should|must)\b/.test(desc)) {
|
|
1053
|
+
if (/\b(texto|text|contiene|contains|dice|says|muestra|shows|display)\b/.test(desc)) {
|
|
1054
|
+
return 'assertText';
|
|
1055
|
+
}
|
|
1056
|
+
return 'assertVisible';
|
|
1057
|
+
}
|
|
1058
|
+
// Fill/Type keywords
|
|
1059
|
+
if (/\b(escribir|type|rellenar|fill|ingresar|enter|input|escribe|completar)\b/.test(desc)) {
|
|
1060
|
+
return 'fill';
|
|
1061
|
+
}
|
|
1062
|
+
// Navigation keywords
|
|
1063
|
+
if (/\b(navegar|navega|ir a|go to|acceder|accede|entrar|entra|visit|open|abrir)\b/.test(desc)) {
|
|
1064
|
+
return 'goto';
|
|
1065
|
+
}
|
|
1066
|
+
// Wait keywords
|
|
1067
|
+
if (/\b(esperar|wait|pausa|pause|delay|sleep|detener|stop)\b/.test(desc)) {
|
|
1068
|
+
return 'wait';
|
|
1069
|
+
}
|
|
1070
|
+
// Select keywords
|
|
1071
|
+
if (/\b(seleccionar|select|selecciona|elegir|choose|dropdown|opción|option)\b/.test(desc)) {
|
|
1072
|
+
return 'select';
|
|
1073
|
+
}
|
|
1074
|
+
// Press key keywords
|
|
1075
|
+
if (/\b(presionar|press|presiona|tecla|key|enter|escape|tab|enviar|submit)\b/.test(desc)) {
|
|
1076
|
+
return 'press';
|
|
1077
|
+
}
|
|
1078
|
+
// Hover keywords
|
|
1079
|
+
if (/\b(hover|pasar|sobre|encima|mouse over|cursor)\b/.test(desc)) {
|
|
1080
|
+
return 'assertVisible'; // Hover usually implies checking visibility
|
|
1081
|
+
}
|
|
1082
|
+
return null;
|
|
1083
|
+
}
|
|
1084
|
+
function transformEventToStep(event, lastUrl) {
|
|
1085
|
+
const { type, element, value, url } = event;
|
|
1086
|
+
const userDescription = event.description || '';
|
|
1087
|
+
// Handle navigation
|
|
1088
|
+
if (type === 'navigation' || url !== lastUrl) {
|
|
1089
|
+
return {
|
|
1090
|
+
type: 'goto',
|
|
1091
|
+
selector: url,
|
|
1092
|
+
selectorType: 'css',
|
|
1093
|
+
value: url,
|
|
1094
|
+
description: userDescription || `Navigate to ${url}`,
|
|
1095
|
+
confidence: 1.0,
|
|
1096
|
+
originalEvent: event
|
|
1097
|
+
};
|
|
1098
|
+
}
|
|
1099
|
+
// Generate selector following priority order
|
|
1100
|
+
const selectorInfo = generateBestSelector(element);
|
|
1101
|
+
// Try to infer step type from user description
|
|
1102
|
+
const inferredType = inferStepTypeFromDescription(userDescription);
|
|
1103
|
+
// Log the inference for debugging
|
|
1104
|
+
if (inferredType && userDescription) {
|
|
1105
|
+
console.log(`[MCP E2E] Inferred step type "${inferredType}" from description: "${userDescription}"`);
|
|
1106
|
+
}
|
|
1107
|
+
// Use inferred type or fall back to event type
|
|
1108
|
+
const effectiveType = inferredType || type;
|
|
1109
|
+
switch (effectiveType) {
|
|
1110
|
+
case 'click':
|
|
1111
|
+
return {
|
|
1112
|
+
type: 'click',
|
|
1113
|
+
selector: selectorInfo.selector,
|
|
1114
|
+
selectorType: selectorInfo.type,
|
|
1115
|
+
description: userDescription || `Click on ${element.text || element.tag || 'element'}`,
|
|
1116
|
+
confidence: selectorInfo.confidence,
|
|
1117
|
+
originalEvent: event
|
|
1118
|
+
};
|
|
1119
|
+
case 'fill':
|
|
1120
|
+
return {
|
|
1121
|
+
type: 'fill',
|
|
1122
|
+
selector: selectorInfo.selector,
|
|
1123
|
+
selectorType: selectorInfo.type,
|
|
1124
|
+
value: value || '',
|
|
1125
|
+
description: userDescription || `Fill "${value}" in ${element.aria?.label || element.attributes?.placeholder || 'input'}`,
|
|
1126
|
+
confidence: selectorInfo.confidence,
|
|
1127
|
+
originalEvent: event
|
|
1128
|
+
};
|
|
1129
|
+
case 'press':
|
|
1130
|
+
return {
|
|
1131
|
+
type: 'press',
|
|
1132
|
+
selector: value || 'Enter',
|
|
1133
|
+
selectorType: 'css',
|
|
1134
|
+
value: value || 'Enter',
|
|
1135
|
+
description: userDescription || `Press ${value || 'Enter'} key`,
|
|
1136
|
+
confidence: 1.0,
|
|
1137
|
+
originalEvent: event
|
|
1138
|
+
};
|
|
1139
|
+
case 'assertVisible':
|
|
1140
|
+
return {
|
|
1141
|
+
type: 'assertVisible',
|
|
1142
|
+
selector: selectorInfo.selector,
|
|
1143
|
+
selectorType: selectorInfo.type,
|
|
1144
|
+
description: userDescription || `Assert ${element.text || 'element'} is visible`,
|
|
1145
|
+
confidence: selectorInfo.confidence,
|
|
1146
|
+
originalEvent: event
|
|
1147
|
+
};
|
|
1148
|
+
case 'assertText':
|
|
1149
|
+
return {
|
|
1150
|
+
type: 'assertText',
|
|
1151
|
+
selector: selectorInfo.selector,
|
|
1152
|
+
selectorType: selectorInfo.type,
|
|
1153
|
+
value: value || '',
|
|
1154
|
+
description: userDescription || `Assert text is "${value}"`,
|
|
1155
|
+
confidence: selectorInfo.confidence,
|
|
1156
|
+
originalEvent: event
|
|
1157
|
+
};
|
|
1158
|
+
case 'wait':
|
|
1159
|
+
return {
|
|
1160
|
+
type: 'wait',
|
|
1161
|
+
selector: 'body',
|
|
1162
|
+
selectorType: 'css',
|
|
1163
|
+
value: value || '1000',
|
|
1164
|
+
description: userDescription || `Wait ${value || '1000'}ms`,
|
|
1165
|
+
confidence: 1.0,
|
|
1166
|
+
originalEvent: event
|
|
1167
|
+
};
|
|
1168
|
+
case 'select':
|
|
1169
|
+
return {
|
|
1170
|
+
type: 'select',
|
|
1171
|
+
selector: selectorInfo.selector,
|
|
1172
|
+
selectorType: selectorInfo.type,
|
|
1173
|
+
value: value || '',
|
|
1174
|
+
description: userDescription || `Select "${value}"`,
|
|
1175
|
+
confidence: selectorInfo.confidence,
|
|
1176
|
+
originalEvent: event
|
|
1177
|
+
};
|
|
1178
|
+
default:
|
|
1179
|
+
// Default to click for unknown types
|
|
1180
|
+
return {
|
|
1181
|
+
type: 'click',
|
|
1182
|
+
selector: selectorInfo.selector,
|
|
1183
|
+
selectorType: selectorInfo.type,
|
|
1184
|
+
description: userDescription || `Click on ${element.text || element.tag || 'element'}`,
|
|
1185
|
+
confidence: selectorInfo.confidence * 0.8, // Lower confidence for defaulted steps
|
|
1186
|
+
originalEvent: event
|
|
1187
|
+
};
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
// Wrapper for processing individual events without navigation context
|
|
1191
|
+
function transformSingleEventToStep(event) {
|
|
1192
|
+
// For single event processing, we treat navigation as goto if URL changed
|
|
1193
|
+
const lastUrl = '';
|
|
1194
|
+
return transformEventToStep(event, lastUrl);
|
|
1195
|
+
}
|
|
1196
|
+
function generateBestSelector(element) {
|
|
1197
|
+
const attrs = element.attributes || {};
|
|
1198
|
+
// Priority 1: data-testid (most stable)
|
|
1199
|
+
if (attrs['data-testid']) {
|
|
1200
|
+
return { selector: attrs['data-testid'], type: 'data-testid', confidence: 1.0 };
|
|
1201
|
+
}
|
|
1202
|
+
// Priority 2: ARIA role
|
|
1203
|
+
if (element.aria?.role && element.aria?.label) {
|
|
1204
|
+
return { selector: `${element.aria.role}[name="${element.aria.label}"]`, type: 'role', confidence: 0.95 };
|
|
1205
|
+
}
|
|
1206
|
+
if (element.aria?.role) {
|
|
1207
|
+
return { selector: element.aria.role, type: 'role', confidence: 0.9 };
|
|
1208
|
+
}
|
|
1209
|
+
// Priority 3: ARIA label
|
|
1210
|
+
if (element.aria?.label) {
|
|
1211
|
+
return { selector: element.aria.label, type: 'label', confidence: 0.9 };
|
|
1212
|
+
}
|
|
1213
|
+
// Priority 4: ID (if stable)
|
|
1214
|
+
if (attrs.id && !attrs.id.match(/\d+/)) {
|
|
1215
|
+
return { selector: `#${attrs.id}`, type: 'css', confidence: 0.85 };
|
|
1216
|
+
}
|
|
1217
|
+
// Priority 5: Text content (for buttons/links)
|
|
1218
|
+
if (element.text && element.text.length < 50 && ['button', 'a', 'span'].includes(element.tag || '')) {
|
|
1219
|
+
return { selector: element.text.trim(), type: 'text', confidence: 0.8 };
|
|
1220
|
+
}
|
|
1221
|
+
// Priority 6: CSS stable classes (avoiding dynamic classes)
|
|
1222
|
+
if (attrs.class) {
|
|
1223
|
+
const stableClasses = attrs.class
|
|
1224
|
+
.split(' ')
|
|
1225
|
+
.filter(c => !c.match(/\d+/) && c.length > 3)
|
|
1226
|
+
.slice(0, 3)
|
|
1227
|
+
.join('.');
|
|
1228
|
+
if (stableClasses) {
|
|
1229
|
+
return { selector: `.${stableClasses}`, type: 'css', confidence: 0.7 };
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
// Priority 7: XPath (last resort)
|
|
1233
|
+
if (element.xpath) {
|
|
1234
|
+
return { selector: element.xpath, type: 'xpath', confidence: 0.5 };
|
|
1235
|
+
}
|
|
1236
|
+
// Fallback: tag name
|
|
1237
|
+
return { selector: element.tag || 'body', type: 'css', confidence: 0.4 };
|
|
1238
|
+
}
|
|
1239
|
+
function addImplicitAssertions(steps) {
|
|
1240
|
+
const result = [];
|
|
1241
|
+
for (let i = 0; i < steps.length; i++) {
|
|
1242
|
+
const step = steps[i];
|
|
1243
|
+
result.push(step);
|
|
1244
|
+
// After navigation, assert page loaded
|
|
1245
|
+
if (step.type === 'goto') {
|
|
1246
|
+
result.push({
|
|
1247
|
+
type: 'assertVisible',
|
|
1248
|
+
selector: 'body',
|
|
1249
|
+
selectorType: 'css',
|
|
1250
|
+
description: 'Assert page is loaded',
|
|
1251
|
+
confidence: 1.0
|
|
1252
|
+
});
|
|
1253
|
+
}
|
|
1254
|
+
// After fill on input, assert value changed
|
|
1255
|
+
if (step.type === 'fill' && i < steps.length - 1) {
|
|
1256
|
+
const nextStep = steps[i + 1];
|
|
1257
|
+
if (nextStep.type === 'click' || nextStep.type === 'press') {
|
|
1258
|
+
result.push({
|
|
1259
|
+
type: 'assertVisible',
|
|
1260
|
+
selector: step.selector,
|
|
1261
|
+
selectorType: step.selectorType,
|
|
1262
|
+
description: `Assert input has value "${step.value}"`,
|
|
1263
|
+
confidence: 0.9
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
}
|
|
1267
|
+
}
|
|
1268
|
+
return result;
|
|
1269
|
+
}
|
|
1270
|
+
function calculateFlowConfidence(steps) {
|
|
1271
|
+
if (steps.length === 0)
|
|
1272
|
+
return 0;
|
|
1273
|
+
const totalConfidence = steps.reduce((sum, step) => sum + (step.confidence || 0.5), 0);
|
|
1274
|
+
return Math.round((totalConfidence / steps.length) * 100) / 100;
|
|
1275
|
+
}
|
|
1276
|
+
function extractUrlPattern(urls) {
|
|
1277
|
+
if (urls.length === 0)
|
|
1278
|
+
return '';
|
|
1279
|
+
// Find common pattern
|
|
1280
|
+
const uniqueUrls = [...new Set(urls)];
|
|
1281
|
+
if (uniqueUrls.length === 1)
|
|
1282
|
+
return uniqueUrls[0];
|
|
1283
|
+
// Try to extract route pattern (e.g., /user/:id)
|
|
1284
|
+
const patterns = uniqueUrls.map(url => {
|
|
1285
|
+
try {
|
|
1286
|
+
const pathname = new URL(url).pathname;
|
|
1287
|
+
return pathname.replace(/\/[0-9a-f]{24}/g, '/:id').replace(/\/\d+/g, '/:id');
|
|
1288
|
+
}
|
|
1289
|
+
catch {
|
|
1290
|
+
return url;
|
|
1291
|
+
}
|
|
1292
|
+
});
|
|
1293
|
+
return [...new Set(patterns)].join(' -> ');
|
|
1294
|
+
}
|
|
1295
|
+
// ============================================
|
|
1296
|
+
// END E2E TRANSFORMATION FUNCTIONS
|
|
1297
|
+
// ============================================
|
|
851
1298
|
const useSSE = process.argv.includes("--sse") || process.env.MCP_TRANSPORT === "sse";
|
|
852
1299
|
if (!useSSE) {
|
|
853
1300
|
const apiKey = process.env.VIEWGATE_API_KEY || process.env.API_KEY || "";
|