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.
Files changed (2) hide show
  1. package/dist/index.js +447 -0
  2. 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 || "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "viewgate-mcp",
3
- "version": "1.0.62",
3
+ "version": "1.0.63",
4
4
  "main": "dist/index.js",
5
5
  "bin": {
6
6
  "viewgate-mcp": "./dist/index.js"