mcp-state-machine-test-framework 1.2.6 → 1.2.8

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 (3) hide show
  1. package/bin/init.js +19 -3
  2. package/index.js +300 -727
  3. package/package.json +1 -1
package/bin/init.js CHANGED
@@ -35,9 +35,25 @@ async function init() {
35
35
 
36
36
  // 4. Crear Templates
37
37
  const templates = {
38
- 'maps/template_map.json': { nodos: { HOME: { transiciones: { IR_A_LOGIN: { destino: "LOGIN", accion: "mcp:wdio-mcp/click_element { \"selector\": \"~Login\" }" } } }, LOGIN: { transiciones: { VOLVER: { destino: "HOME", accion: "mcp:wdio-mcp/click_element { \"selector\": \"~Back\" }" } } } } },
39
- 'test_cases/template_case.json': { name: "Template Case", steps: [ { name: "Ir a Login", action: "IR_A_LOGIN" } ] },
40
- 'suites/template_suite.json': { name: "Template Suite", state_map: "template_map.json", tests: ["template_case"] }
38
+ 'maps/template_map.json': {
39
+ nodos: {
40
+ HOME: {
41
+ fingerprint: { selectors: ["~Login_Button"] },
42
+ transiciones: { IR_A_LOGIN: { destino: "LOGIN", accion: "mcp:wdio-mcp/click_element {\"selector\":\"~Login_Button\"}" } }
43
+ },
44
+ LOGIN: {
45
+ fingerprint: { selectors: ["~Back_Button", "id:login_title"] },
46
+ transiciones: { VOLVER: { destino: "HOME", accion: "mcp:wdio-mcp/click_element {\"selector\":\"~Back_Button\"}" } }
47
+ }
48
+ }
49
+ },
50
+ 'test_cases/template_case.json': {
51
+ name: "TC_Template",
52
+ steps: [
53
+ { name: "Navegar a Login", action: "transicion:IR_A_LOGIN", assert: "sh:echo 'Validación de UI exitosa'" }
54
+ ]
55
+ },
56
+ 'suites/template_suite.json': { name: "Suite_Template", state_map: "template_map.json", tests: ["TC_Template"] }
41
57
  };
42
58
 
43
59
  for (const [relPath, content] of Object.entries(templates)) {
package/index.js CHANGED
@@ -1,727 +1,300 @@
1
- #!/usr/bin/env node
2
- import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
- import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
- import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
- import { z } from "zod";
7
- import fs from "fs/promises";
8
- import fsSync from "fs";
9
- import path from "path";
10
- import { fileURLToPath } from "url";
11
- import { exec } from "child_process";
12
- import { promisify } from "util";
13
-
14
- const execAsync = promisify(exec);
15
-
16
- async function log(msg) {
17
- const time = new Date().toISOString();
18
- const line = `[${time}] ${msg}\n`;
19
- await fs.appendFile("sms_server.log", line);
20
- }
21
-
22
- const __filename = fileURLToPath(import.meta.url);
23
- const __dirname = path.dirname(__filename);
24
- const DB_FILE = path.join(__dirname, "maquina_db.json");
25
- const SUITES_DIR = path.join(__dirname, "suites");
26
- const CASOS_DIR = path.join(__dirname, "test_cases");
27
- const REPORTS_ROOT = path.join(__dirname, "reports");
28
- const CONFIG_FILE = path.join(__dirname, "mcp_config.json");
29
-
30
- export class MaquinaDeEstados {
31
- constructor() {
32
- this.nodos = new Map();
33
- this.casosPrueba = new Map();
34
- this.suites = new Map();
35
- this.mcpClients = new Map();
36
- }
37
-
38
- async cargar() {
39
- for (const dir of [CASOS_DIR, SUITES_DIR]) {
40
- if (!fsSync.existsSync(dir)) await fs.mkdir(dir, { recursive: true });
41
- const files = await fs.readdir(dir);
42
- for (const f of files) {
43
- if (f.endsWith(".json")) {
44
- const content = JSON.parse(await fs.readFile(path.join(dir, f), "utf-8"));
45
- if (dir === CASOS_DIR) this.casosPrueba.set(content.name, content);
46
- else this.suites.set(content.name, content);
47
- }
48
- }
49
- }
50
- }
51
-
52
- async getMcpClient(serverName) {
53
- if (this.mcpClients.has(serverName)) return this.mcpClients.get(serverName);
54
- const configRaw = await fs.readFile(CONFIG_FILE, "utf-8");
55
- const config = JSON.parse(configRaw).mcpServers[serverName];
56
- if (!config) throw new Error(`Configuración no encontrada para ${serverName}`);
57
- const transport = new StdioClientTransport({
58
- command: config.command,
59
- args: config.args || [],
60
- env: { ...process.env, ...config.env }
61
- });
62
- const client = new Client({ name: "SMS-Client", version: "11.1.0" }, { capabilities: {} });
63
- await client.connect(transport);
64
- const data = { client, transport };
65
- this.mcpClients.set(serverName, data);
66
- return data;
67
- }
68
-
69
- interpolate(text, data) {
70
- if (!data) return text;
71
- let result = text;
72
- for (const key in data) {
73
- result = result.replace(new RegExp(`{{${key}}}`, 'g'), data[key]);
74
- }
75
- return result;
76
- }
77
-
78
- async loadExternalData(dataPath) {
79
- const fullPath = path.isAbsolute(dataPath) ? dataPath : path.join(__dirname, dataPath);
80
- if (!fsSync.existsSync(fullPath)) return null;
81
-
82
- const content = await fs.readFile(fullPath, "utf-8");
83
- if (fullPath.endsWith(".json")) return JSON.parse(content);
84
-
85
- if (fullPath.endsWith(".csv")) {
86
- const lines = content.split("\n").map(l => l.trim()).filter(l => l.length > 0);
87
- if (lines.length < 2) return [];
88
- const headers = lines[0].split(",").map(h => h.trim().replace(/^["']|["']$/g, ""));
89
- return lines.slice(1).map(line => {
90
- const values = line.split(",");
91
- const obj = {};
92
- headers.forEach((h, i) => {
93
- let val = values[i] ? values[i].trim() : "";
94
- // Quitar comillas si existen
95
- val = val.replace(/^["']|["']$/g, "");
96
- obj[h] = val;
97
- });
98
- return obj;
99
- });
100
- }
101
- return null;
102
- }
103
-
104
- async ejecutarSuite(suiteName) {
105
- const suite = this.suites.get(suiteName);
106
- if (!suite) throw new Error(`Suite '${suiteName}' no encontrada.`);
107
-
108
- if (suite.state_map) {
109
- process.stderr.write(`\n📍 [V12.3] Pre-flight Integrity Check for: ${suite.state_map}\n`);
110
- // Simplified validation: Ensure the file exists and has the required structure
111
- const mapPath = path.join(__dirname, 'maps', suite.state_map);
112
- try {
113
- await fs.access(mapPath);
114
- const mapData = JSON.parse(await fs.readFile(mapPath, 'utf8'));
115
- const nodes = mapData.nodos || mapData;
116
- process.stderr.write(`✅ Map validated (${Object.keys(nodes).length} nodes found).\n`);
117
- } catch (e) {
118
- process.stderr.write(`❌ CRITICAL: State Map '${suite.state_map}' not found or invalid!\n`);
119
- throw new Error(`Integrity Check Failed: ${e.message}`);
120
- }
121
- }
122
-
123
- const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
124
- const reportDir = path.join(REPORTS_ROOT, `exec_${suiteName}_${timestamp}`);
125
- await fs.mkdir(reportDir, { recursive: true });
126
- const results = { suiteName, timestamp, cases: [], hooks: { beforeSuite: [], afterSuite: [] } };
127
-
128
- const maskSecrets = (text) => {
129
- if (typeof text !== "string") return text;
130
- // Enmascarar tokens de seguridad y JWTs
131
- return text.replace(/("securityToken"\s*:\s*")[^"]+(")/g, '$1********$2')
132
- .replace(/("token"\s*:\s*")[^"]+(")/g, '$1********$2')
133
- .replace(/eyJhbGciOi[A-Za-z0-9-_=]+\.[A-Za-z0-9-_=]+\.?[A-Za-z0-9-_.+/=]*/g, '********[JWT_MASKED]********');
134
- };
135
-
136
- const runActions = async (actions, targetRes, data) => {
137
- if (!actions) return;
138
- for (const action of actions) {
139
- const maskedAction = maskSecrets(action);
140
- process.stderr.write(`[EXEC] Running action: ${maskedAction}\n`);
141
- const actionRes = { action: maskedAction, status: "passed" };
142
- targetRes.push(actionRes);
143
- try {
144
- let lastResult = null;
145
- const executeAction = async (act) => {
146
- const subRes = (act === action) ? actionRes : { action: act, status: "passed" };
147
- if (act !== action) targetRes.push(subRes);
148
-
149
- let finalAction = act;
150
- try {
151
- if (act.startsWith("transicion:")) {
152
- const transName = act.replace("transicion:", "").trim();
153
- const mapPath = path.join(__dirname, 'maps', suite.state_map);
154
- const mapData = JSON.parse(await fs.readFile(mapPath, 'utf8'));
155
- const nodes = mapData.nodos || mapData;
156
-
157
- let foundAction = null;
158
- let destNodeName = null;
159
- for (const [nodeName, node] of Object.entries(nodes)) {
160
- if (node.transiciones && node.transiciones[transName]) {
161
- foundAction = node.transiciones[transName].accion;
162
- destNodeName = node.transiciones[transName].destino;
163
- break;
164
- }
165
- }
166
- if (!foundAction) throw new Error(`Transición '${transName}' no encontrada en el mapa '${suite.state_map}'`);
167
-
168
- // Ejecutar la acción de la transición
169
- await executeAction(foundAction);
170
-
171
- // --- VERIFICACIÓN DE IDENTIDAD (FINGERPRINT) ---
172
- const destNode = nodes[destNodeName];
173
- if (destNode && destNode.fingerprint) {
174
- const fp = destNode.fingerprint;
175
- const selectors = fp.selectors || (fp.selector ? [fp.selector] : []);
176
-
177
- if (selectors.length > 0) {
178
- process.stderr.write(`[AUTH] Verificando identidad múltiple del nodo: ${destNodeName} (${selectors.length} vectores)\n`);
179
- const { client } = await this.getMcpClient("wdio-mcp");
180
-
181
- for (const sel of selectors) {
182
- const verifyRes = await client.callTool({
183
- name: "wait_for_element",
184
- arguments: { selector: sel, timeout: fp.timeout || 5000 }
185
- }, undefined, { timeout: 10000 });
186
-
187
- if (verifyRes.isError) {
188
- throw new Error(`Error de Identidad: No se encontró el vector '${sel}' para el nodo '${destNodeName}'.`);
189
- }
190
- }
191
- subRes.output = (subRes.output || "") + `\n✅ Identidad Multivectorial confirmada para '${destNodeName}'.`;
192
- }
193
- }
194
- return;
195
- }
196
-
197
- if (Array.isArray(finalAction)) {
198
- for (const subAct of finalAction) {
199
- await executeAction(subAct);
200
- }
201
- return;
202
- }
203
-
204
- const interpolatedAction = this.interpolate(finalAction, data);
205
-
206
- if (interpolatedAction.startsWith("sh:")) {
207
- const shCmd = interpolatedAction.replace("sh:", "").trim();
208
- process.stderr.write(`[SH] Ejecutando: ${shCmd}\n`);
209
- const { stdout, stderr } = await execAsync(shCmd);
210
- subRes.output = (subRes.output || "") + "\n" + stdout + (stderr ? "\nERR: " + stderr : "");
211
- } else if (interpolatedAction.startsWith("mcp:")) {
212
- const raw = interpolatedAction.replace("mcp:", "").trim();
213
- const slashIndex = raw.indexOf("/");
214
- const serverName = raw.substring(0, slashIndex);
215
- const rest = raw.substring(slashIndex + 1);
216
- const spaceIndex = rest.indexOf(" ");
217
- const toolName = spaceIndex === -1 ? rest : rest.substring(0, spaceIndex);
218
- const toolArgs = JSON.parse(spaceIndex === -1 ? "{}" : rest.substring(spaceIndex + 1));
219
-
220
- const { client } = await this.getMcpClient(serverName);
221
- lastResult = await client.callTool({ name: toolName, arguments: toolArgs }, undefined, { timeout: 600000 });
222
-
223
- if (lastResult.isError && toolName !== "close_session") {
224
- throw new Error(lastResult.content?.[0]?.text || "Error desconocido en herramienta MCP");
225
- }
226
-
227
- if (lastResult.content) {
228
- for (const item of lastResult.content) {
229
- if (item.text) {
230
- subRes.output = (subRes.output || "") + "\n" + maskSecrets(item.text);
231
- }
232
- if (item.type === "image") {
233
- subRes.image = item.data.startsWith("data:") ? item.data : `data:image/png;base64,${item.data}`;
234
- }
235
- }
236
- }
237
- }
238
- } catch (e) {
239
- subRes.status = "failed";
240
- subRes.error = e.message;
241
- throw e;
242
- }
243
- };
244
-
245
- await executeAction(action);
246
- } catch (e) {
247
- actionRes.status = "failed";
248
- actionRes.error = e.message;
249
- throw e;
250
- }
251
- }
252
- };
253
-
254
- try {
255
- await runActions(suite.beforeSuite, results.hooks.beforeSuite);
256
- for (const caseName of suite.tests) {
257
- const testCase = this.casosPrueba.get(caseName);
258
- if (!testCase) continue;
259
-
260
- let dataRows = [null];
261
- if (testCase.data) {
262
- if (Array.isArray(testCase.data)) dataRows = testCase.data;
263
- else if (typeof testCase.data === "string") dataRows = await this.loadExternalData(testCase.data) || [null];
264
- }
265
-
266
- for (let i = 0; i < dataRows.length; i++) {
267
- const row = dataRows[i];
268
- const suffix = row ? ` [Iteration ${i + 1}]` : "";
269
- const caseRes = { name: testCase.name + suffix, steps: [], status: "passed", hooks: { beforeCase: [], afterCase: [] } };
270
- results.cases.push(caseRes);
271
-
272
- try {
273
- await runActions(suite.beforeCase, caseRes.hooks.beforeCase, row);
274
- for (const step of testCase.steps) {
275
- const stepRes = { name: step.name, actions: [], status: "passed" };
276
- caseRes.steps.push(stepRes);
277
- try {
278
- // Hooks de paso
279
- await runActions(suite.beforeStep, stepRes.actions, row);
280
-
281
- // Acción Principal (puede ser 'action' o 'actions')
282
- const mainActions = step.actions || (step.action ? [step.action] : []);
283
- await runActions(mainActions, stepRes.actions, row);
284
-
285
- // --- VALIDACIÓN TÉCNICA (ASSERT) ---
286
- if (step.assert) {
287
- const actionRes = { action: `[ASSERT] ${step.assert}`, status: "passed" };
288
- stepRes.actions.push(actionRes);
289
- try {
290
- // Re-utilizamos runActions para procesar el assert (puede ser mcp: o sh:)
291
- await runActions([step.assert], stepRes.actions, row);
292
- } catch (e) {
293
- actionRes.status = "failed";
294
- actionRes.error = e.message;
295
- throw new Error(`Assert Fallido: ${e.message}`);
296
- }
297
- }
298
- } catch (e) {
299
- stepRes.status = "failed";
300
- stepRes.error = e.message;
301
- throw e;
302
- } finally {
303
- await runActions(suite.afterStep, stepRes.actions, row);
304
- }
305
- }
306
- await runActions(suite.afterCase, caseRes.hooks.afterCase, row);
307
- } catch (e) { caseRes.status = "failed"; caseRes.error = e.message; }
308
- }
309
- }
310
- await runActions(suite.afterSuite, results.hooks.afterSuite);
311
- } finally {
312
- for (const { transport } of this.mcpClients.values()) await transport.close();
313
- this.mcpClients.clear();
314
- await this.finalizarReporte(reportDir, results);
315
- }
316
- return reportDir;
317
- }
318
-
319
- async finalizarReporte(dir, data) {
320
- const renderActions = (actions) => actions.map(a => {
321
- const isAssert = a.action.startsWith("[ASSERT]");
322
- const isIdentity = a.output && a.output.includes("Identidad confirmada");
323
-
324
- return `
325
- <div class="action-card" style="border-left: 4px solid ${a.status === 'passed' ? (isAssert ? '#10b981' : '#3b82f6') : '#ef4444'}; background: ${isAssert ? 'rgba(16, 185, 129, 0.05)' : 'rgba(15, 23, 42, 0.8)'}; padding: 20px; border-radius: 12px; margin-bottom: 15px; border: 1px solid #1e293b;">
326
- <div style="display:flex; justify-content:space-between; align-items:center; margin-bottom: 10px;">
327
- <span style="font-weight: 600; font-size: 0.9em; color: ${isAssert ? '#10b981' : '#94a3b8'};">
328
- ${isAssert ? '🛡️ VALIDACIÓN (ASSERT)' : '⚙️ ACCIÓN'}
329
- </span>
330
- <span class="status-badge" style="background:${a.status === 'passed' ? '#065f46' : '#991b1b'};">${a.status}</span>
331
- </div>
332
- <div style="font-family: 'JetBrains Mono', monospace; font-size: 0.95em; color: #f8fafc; margin-bottom: 10px;">
333
- ${isAssert ? a.action.replace("[ASSERT]", "").trim() : a.action}
334
- </div>
335
- ${isIdentity ? `<div style="background: rgba(16, 185, 129, 0.2); color: #34d399; padding: 8px 12px; border-radius: 6px; font-size: 0.85em; margin-bottom: 10px; border: 1px solid rgba(16, 185, 129, 0.3);">🔍 <b>Identidad:</b> ${a.output.includes("Multivectorial") ? "Sincronización Multivectorial Exitosa" : "Confirmada"}</div>` : ""}
336
- ${a.image ? `<div style="margin-top:15px; text-align:center;"><img src="${a.image}" style="max-width:100%; border-radius:8px; border: 1px solid #334155; cursor: pointer;" onclick="window.open(this.src)"></div>` : ""}
337
- ${a.output && !isIdentity ? `
338
- <details style="margin-top:10px;">
339
- <summary style="cursor:pointer; color:#64748b; font-size:0.8em;">Ver logs técnicos</summary>
340
- <pre style="background:#020617; padding:12px; border-radius:6px; font-size:0.8em; color:#10b981; margin-top:8px; border:1px solid #1e293b; overflow-x:auto;">${a.output}</pre>
341
- </details>` : ""}
342
- ${a.error ? `<pre style="background:#450a0a; padding:12px; border-radius:6px; font-size:0.8em; color:#fca5a5; margin-top:8px;">${a.error}</pre>` : ""}
343
- </div>
344
- `}).join("");
345
-
346
- const html = `<!DOCTYPE html><html lang="es"><head><meta charset="UTF-8"><title>SMS Dashboard V11.1 CSV-Ready</title>
347
- <style>
348
- @import url('https://fonts.googleapis.com/css2?family=Outfit:wght@300;400;600;800&display=swap');
349
- body { font-family: 'Outfit', sans-serif; background: #020617; color: #f8fafc; padding: 60px; line-height: 1.6; }
350
- .container { max-width: 1000px; margin: 0 auto; }
351
- .card { background: #0f172a; border: 1px solid #1e293b; border-radius: 20px; padding: 40px; margin-bottom: 30px; box-shadow: 0 20px 50px rgba(0,0,0,0.3); }
352
- .hook-label { background: #3b82f6; color: white; font-size: 0.7em; font-weight: 800; padding: 5px 12px; border-radius: 50px; text-transform: uppercase; margin-bottom: 15px; display: inline-block; letter-spacing: 1px; }
353
- h1 { font-size: 3.5em; font-weight: 800; background: linear-gradient(to right, #60a5fa, #a855f7); -webkit-background-clip: text; -webkit-text-fill-color: transparent; margin-bottom: 0.2em; }
354
- .step { border-left: 3px solid #3b82f6; padding-left: 30px; margin: 40px 0; }
355
- h2 { font-size: 2em; margin-bottom: 1em; color: #f1f5f9; }
356
- h3 { color: #94a3b8; font-weight: 400; text-transform: uppercase; font-size: 0.9em; letter-spacing: 2px; }
357
- </style></head>
358
- <body><div class="container">
359
- <h1>SMS Dashboard</h1>
360
- <p style="color: #64748b; font-size: 1.1em; margin-bottom: 40px;">Universal Data-Driven Framework • V11.2</p>
361
- ${data.hooks.beforeSuite.length ? `<div class="card"><span class="hook-label">Global Setup</span>${renderActions(data.hooks.beforeSuite)}</div>` : ""}
362
- ${data.cases.map(c => `
363
- <div class="card">
364
- <h3 style="color:#60a5fa; margin-bottom:10px;">TEST CASE EXECUTION</h3>
365
- <h2>${c.name}</h2>
366
- ${c.hooks.beforeCase.length ? `<div><span class="hook-label">Pre-Condition</span>${renderActions(c.hooks.beforeCase)}</div>` : ""}
367
- ${c.steps.map(s => `<div class="step"><h3>STEP: ${s.name}</h3>${renderActions(s.actions)}</div>`).join("")}
368
- ${c.hooks.afterCase.length ? `<div><span class="hook-label">Post-Condition</span>${renderActions(c.hooks.afterCase)}</div>` : ""}
369
- </div>
370
- `).join("")}
371
- ${data.hooks.afterSuite.length ? `<div class="card"><span class="hook-label">Global Teardown</span>${renderActions(data.hooks.afterSuite)}</div>` : ""}
372
- </div></body></html>`;
373
- await fs.writeFile(path.join(dir, "index.html"), html);
374
- await fs.writeFile(path.join(dir, "results.json"), JSON.stringify(data, null, 2));
375
- }
376
- }
377
-
378
- const maquina = new MaquinaDeEstados();
379
- const server = new McpServer({ name: "demo-state-machine", version: "11.1.0" });
380
- server.tool("execute_suite", "Ejecución de Suite Completa", {
381
- name: z.string().describe("Nombre de la suite a ejecutar (ej: 'Suite_Login'). Buscará el archivo en /suites.")
382
- }, async ({ name }) => {
383
- await maquina.cargar();
384
- const dir = await maquina.ejecutarSuite(name);
385
- return { content: [{ type: "text", text: `Reporte: ${dir}` }] };
386
- });
387
-
388
- server.tool("upsert_node", "Añadir o actualizar un nodo en el mapa de estados.", {
389
- mapName: z.string().describe("Nombre del archivo del mapa (ej: 'home_map.json'). El servidor lo buscará en la carpeta /maps."),
390
- nodeName: z.string().describe("Identificador único del nodo (ej: 'LOGIN_PAGE', 'HOME')."),
391
- nodeData: z.object({
392
- transiciones: z.any().optional().describe("Objeto que define las salidas del nodo. Ej: { 'IR_A_LOGIN': { 'destino': 'LOGIN_PAGE', 'accion': 'mcp:wdio-mcp/click_element ...' } }")
393
- }).passthrough().describe("Datos completos del nodo, incluyendo transiciones y metadatos.")
394
- }, async ({ mapName, nodeName, nodeData }) => {
395
- const mapPath = path.join(__dirname, 'maps', mapName);
396
- let map = { nodos: {} };
397
- try {
398
- const content = await fs.readFile(mapPath, 'utf8');
399
- map = JSON.parse(content);
400
- if (!map.nodos) map = { nodos: map };
401
- } catch (e) { }
402
-
403
- map.nodos[nodeName] = nodeData;
404
- await fs.mkdir(path.dirname(mapPath), { recursive: true });
405
- await fs.writeFile(mapPath, JSON.stringify(map, null, 2));
406
-
407
- // Generar/Actualizar Diagrama Mermaid
408
- try {
409
- let mermaid = "graph TD\n";
410
- for (const [name, node] of Object.entries(map.nodos)) {
411
- if (node.transiciones) {
412
- for (const [transName, trans] of Object.entries(node.transiciones)) {
413
- mermaid += ` ${name} -- "${transName}" --> ${trans.destino}\n`;
414
- }
415
- }
416
- }
417
- const mermaidPath = mapPath.replace('.json', '.md');
418
- await fs.writeFile(mermaidPath, `# 🗺️ Diagrama de Estados: ${mapName}\n\n\`\`\`mermaid\n${mermaid}\`\`\``);
419
- } catch (me) {
420
- process.stderr.write(`[WARN] Error generando Mermaid: ${me.message}\n`);
421
- }
422
-
423
- return { content: [{ type: "text", text: `Nodo '${nodeName}' actualizado. Diagrama visual regenerado en maps/${mapName.replace('.json', '.md')}` }] };
424
- });
425
-
426
- server.tool("inspect_framework", "Inspeccionar integridad y listar entidades del framework", {
427
- filter: z.optional(z.string())
428
- }, async () => {
429
- const entities = { maps: [], test_cases: [], suites: [], health: [] };
430
- const getFiles = async (dir) => {
431
- try { return (await fs.readdir(path.join(__dirname, dir))).filter(f => f.endsWith('.json')); }
432
- catch (e) { return []; }
433
- };
434
-
435
- entities.maps = await getFiles('maps');
436
- entities.test_cases = await getFiles('test_cases');
437
- entities.suites = await getFiles('suites');
438
-
439
- // Validación de integridad simple
440
- for (const suiteFile of entities.suites) {
441
- try {
442
- const suite = JSON.parse(await fs.readFile(path.join(__dirname, 'suites', suiteFile), 'utf8'));
443
- if (!entities.maps.includes(suite.state_map)) {
444
- entities.health.push(`❌ Suite '${suiteFile}' referencia a mapa inexistente: ${suite.state_map}`);
445
- }
446
- for (const tc of suite.tests) {
447
- if (!entities.test_cases.includes(`${tc}.json`) && !entities.test_cases.includes(tc)) {
448
- entities.health.push(`❌ Suite '${suiteFile}' referencia a test case inexistente: ${tc}`);
449
- }
450
- }
451
- } catch (e) { entities.health.push(`❌ Error leyendo suite '${suiteFile}': ${e.message}`); }
452
- }
453
-
454
- if (entities.health.length === 0) entities.health.push("✅ Estructura íntegra. Todos los vínculos son correctos.");
455
-
456
- // Validación de integridad de Grafos (Nodos Huérfanos)
457
- for (const mapFile of entities.maps) {
458
- try {
459
- const mapData = JSON.parse(await fs.readFile(path.join(__dirname, 'maps', mapFile), 'utf8'));
460
- const nodos = mapData.nodos || mapData;
461
- if (nodos && nodos["HOME"]) {
462
- const reachable = new Set(["HOME"]);
463
- const stack = ["HOME"];
464
- while (stack.length > 0) {
465
- const current = stack.pop();
466
- const node = nodos[current];
467
- if (node && node.transiciones) {
468
- for (const t of Object.values(node.transiciones)) {
469
- if (t.destino && nodos[t.destino] && !reachable.has(t.destino)) {
470
- reachable.add(t.destino);
471
- stack.push(t.destino);
472
- }
473
- }
474
- }
475
- }
476
- const totalNodes = Object.keys(nodos);
477
- const unreachable = totalNodes.filter(n => !reachable.has(n));
478
- if (unreachable.length > 0) {
479
- entities.health.push(`⚠️ Mapa '${mapFile}': Nodos inalcanzables desde HOME: ${unreachable.join(', ')}`);
480
- }
481
- } else if (nodos && !nodos["HOME"]) {
482
- entities.health.push(`⚠️ Mapa '${mapFile}': No tiene un nodo 'HOME' (inicio), no se puede validar el grafo.`);
483
- }
484
- } catch (e) { entities.health.push(`❌ Error validando grafo de '${mapFile}': ${e.message}`); }
485
- }
486
-
487
- return { content: [{ type: "text", text: JSON.stringify(entities, null, 2) }] };
488
- });
489
-
490
- server.tool("save_test_case", "Crear o actualizar un caso de prueba.", {
491
- name: z.string().describe("Nombre identificador del test (ej: 'TC_Login_Exitoso')."),
492
- steps: z.array(z.object({
493
- name: z.string().optional().describe("Descripción amigable del paso (ej: 'Ingresar Usuario')."),
494
- action: z.string().optional().describe("Acción a realizar. Puede ser un nombre de transición del mapa o un comando mcp:wdio-mcp/...")
495
- }).passthrough()).describe("Lista ordenada de pasos lógicos a ejecutar.")
496
- }, async ({ name, steps }) => {
497
- const fileName = name.endsWith('.json') ? name : `${name}.json`;
498
- const filePath = path.join(__dirname, 'test_cases', fileName);
499
- const testCase = { name: name.replace('.json', ''), steps };
500
- await fs.mkdir(path.dirname(filePath), { recursive: true });
501
- await fs.writeFile(filePath, JSON.stringify(testCase, null, 2));
502
- return { content: [{ type: "text", text: `Caso de prueba '${name}' guardado correctamente.` }] };
503
- });
504
-
505
- server.tool("save_suite", "Crear o actualizar una suite de pruebas.", {
506
- name: z.string().describe("Nombre de la suite (ej: 'Suite_E2E_Sanity')."),
507
- state_map: z.string().describe("Archivo del mapa de estados a usar (ej: 'mob_perfecto_map.json')."),
508
- tests: z.array(z.string()).describe("Lista de nombres de casos de prueba a incluir en la suite."),
509
- beforeSuite: z.array(z.string()).optional().default([]).describe("Acciones globales antes de la suite (ej: iniciar sesión)."),
510
- afterSuite: z.array(z.string()).optional().default([]).describe("Acciones globales tras la suite (ej: cerrar sesión).")
511
- }, async ({ name, state_map, tests, beforeSuite, afterSuite }) => {
512
- const fileName = name.endsWith('.json') ? name : `${name}.json`;
513
- const filePath = path.join(__dirname, 'suites', fileName);
514
- const suite = { name: name.replace('.json', ''), state_map, tests, beforeSuite, afterSuite };
515
- await fs.mkdir(path.dirname(filePath), { recursive: true });
516
- await fs.writeFile(filePath, JSON.stringify(suite, null, 2));
517
- return { content: [{ type: "text", text: `Suite '${name}' guardada correctamente.` }] };
518
- });
519
-
520
- server.tool("design_wizard", "Asistente paso a paso para diseñar la máquina de estados", {
521
- action: z.enum(["start", "add_node", "add_transition", "save"]),
522
- data: z.record(z.any()).optional().describe("Datos según la fase: { mapName }, { nodeName }, { label, destino, accion }")
523
- }, async ({ action, data }) => {
524
- const contextPath = path.join(__dirname, 'data', '.design_context.json');
525
- let context = { step: "IDLE", mapName: "", nodeName: "", transitions: {} };
526
-
527
- try { context = JSON.parse(await fs.readFile(contextPath, 'utf8')); } catch (e) { }
528
-
529
- if (action === "start") {
530
- context = { step: "SELECT_NODE", mapName: data.mapName || "default_map.json", nodeName: "", transitions: {} };
531
- await fs.writeFile(contextPath, JSON.stringify(context, null, 2));
532
- return { content: [{ type: "text", text: `🧙‍♂️ Wizard Iniciado: Trabajando en '${context.mapName}'.\n\nPASO 1: ¿Qué nombre le damos al nodo? (Usa action: 'add_node')` }] };
533
- }
534
-
535
- if (action === "add_node" && context.step === "SELECT_NODE") {
536
- context.nodeName = data.nodeName;
537
- context.step = "DEFINE_FINGERPRINT";
538
- await fs.writeFile(contextPath, JSON.stringify(context, null, 2));
539
- return { content: [{ type: "text", text: `📍 Nodo '${context.nodeName}' identificado.\n\nPASO 2: ¿Cuál es la Huella Digital (Fingerprint) de esta pantalla? (Usa action: 'add_fingerprint' con { selector }).` }] };
540
- }
541
-
542
- if (action === "add_fingerprint" && context.step === "DEFINE_FINGERPRINT") {
543
- if (!context.fingerprint) context.fingerprint = { selectors: [] };
544
- context.fingerprint.selectors.push(data.selector);
545
- context.fingerprint.timeout = data.timeout || 5000;
546
- await fs.writeFile(contextPath, JSON.stringify(context, null, 2));
547
- return { content: [{ type: "text", text: `🧬 Vector añadido: ${data.selector}.\n\n¿Quieres añadir otro detector para este nodo? (Usa 'add_fingerprint') o finaliza la identidad con 'done_fingerprint'.` }] };
548
- }
549
-
550
- if (action === "done_fingerprint" && context.step === "DEFINE_FINGERPRINT") {
551
- context.step = "ADD_TRANSITIONS";
552
- await fs.writeFile(contextPath, JSON.stringify(context, null, 2));
553
- return { content: [{ type: "text", text: `✅ Identidad Multivectorial configurada.\n\nPASO 3: Define una transición. (Usa action: 'add_transition' con { label, destino, accion }).` }] };
554
- }
555
-
556
- if (action === "add_transition" && context.step === "ADD_TRANSITIONS") {
557
- context.transitions[data.label] = { destino: data.destino, accion: data.accion };
558
- await fs.writeFile(contextPath, JSON.stringify(context, null, 2));
559
- return { content: [{ type: "text", text: `🔄 Transición '${data.label}' añadida.\n\n¿Quieres añadir otra o guardar? (Usa action: 'save' para finalizar)` }] };
560
- }
561
-
562
- if (action === "save" && context.step === "ADD_TRANSITIONS") {
563
- const result = await server.callTool("upsert_node", {
564
- mapName: context.mapName,
565
- nodeName: context.nodeName,
566
- nodeData: {
567
- fingerprint: context.fingerprint,
568
- transiciones: context.transitions
569
- }
570
- });
571
- await fs.unlink(contextPath); // Reset wizard
572
- return result;
573
- }
574
-
575
- return { content: [{ type: "text", text: `❌ Acción no permitida en el estado actual (${context.step}).` }] };
576
- });
577
-
578
- server.tool("begin_design", "Iniciar una sesión de diseño guiado para una aplicación", {
579
- appName: z.string().describe("Nombre de la aplicación a diseñar (ej: 'AdvantageShopping').")
580
- }, async ({ appName }) => {
581
- const rules = `
582
- 🎭 HAS ENTRADO EN MODO DISEÑO (SMS ARCHITECT) 🎭
583
- Estás diseñando la estructura para: ${appName}
584
-
585
- REGLAS CRÍTICAS DE SUPERVIVENCIA:
586
- 1. ❌ PROHIBIDO editar archivos JSON manualmente.
587
- 2. ❌ PROHIBIDO usar los campos 'nodes' o 'edges'. NO SOMOS UNA LIBRERÍA DE GRÁFICOS.
588
- 3. ✅ USA EXCLUSIVAMENTE 'upsert_node' para crear o actualizar estados.
589
- 4. ✅ ESTRUCTURA OBLIGATORIA: { "nodos": { "NOMBRE_NODO": { "transiciones": {} } } }
590
- 5. 🔄 FLUJO: Primero propón el diseño en texto -> Pide confirmación -> Usa la herramienta.
591
-
592
- Dime qué pantalla o flujo quieres empezar a mapear para ${appName}.
593
- `;
594
- return { content: [{ type: "text", text: rules }] };
595
- });
596
-
597
- server.tool("init_project", "Inicializa el proyecto con carpetas y archivos de plantilla", {
598
- force: z.optional(z.boolean()).default(false)
599
- }, async ({ force }) => {
600
- await ensureDirectories();
601
-
602
- const templates = {
603
- 'maps/template_map.json': {
604
- nodos: {
605
- HOME: {
606
- fingerprint: { selector: "~Login", timeout: 5000 },
607
- transiciones: {
608
- IR_A_LOGIN: { destino: "LOGIN", accion: "mcp:wdio-mcp/click_element { \"selector\": \"~Login\" }" }
609
- }
610
- },
611
- LOGIN: {
612
- fingerprint: { selector: "~Back", timeout: 5000 },
613
- transiciones: {
614
- VOLVER: { destino: "HOME", accion: "mcp:wdio-mcp/click_element { \"selector\": \"~Back\" }" }
615
- }
616
- }
617
- }
618
- },
619
- 'test_cases/template_case.json': {
620
- name: "Template Case",
621
- steps: [
622
- { name: "Ir a Login", action: "IR_A_LOGIN" },
623
- { name: "Verificar Pantalla", action: "mcp:wdio-mcp/get_screenshot {}" }
624
- ]
625
- },
626
- 'suites/template_suite.json': {
627
- name: "Template Suite",
628
- state_map: "template_map.json",
629
- tests: ["template_case"],
630
- beforeSuite: ["mcp:wdio-mcp/start_session { \"platform\": \"android\", \"provider\": \"perfecto\" }"],
631
- afterSuite: ["mcp:wdio-mcp/close_session {}"]
632
- }
633
- };
634
-
635
- for (const [relPath, content] of Object.entries(templates)) {
636
- const fullPath = path.join(__dirname, relPath);
637
- try {
638
- if (!force) {
639
- try {
640
- await fs.access(fullPath);
641
- continue; // Skip if exists
642
- } catch (e) { }
643
- }
644
- await fs.writeFile(fullPath, JSON.stringify(content, null, 2));
645
- } catch (e) {
646
- process.stderr.write(`[WARN] Error creando template ${relPath}: ${e.message}\n`);
647
- }
648
- }
649
-
650
- return {
651
- content: [{
652
- type: "text",
653
- text: "✅ Instalación completada con éxito.\n\nComo este es un proyecto nuevo, el siguiente paso es definir tu aplicación. Por favor, llama a la herramienta `begin_design` para empezar a mapear tus primeros estados."
654
- }]
655
- };
656
- });
657
-
658
- server.tool("framework_menu", "Panel de control para elegir entre Modo Diseño o Modo Ejecución", {}, async () => {
659
- return {
660
- content: [{
661
- type: "text",
662
- text: "🎮 **Framework Control Panel**\n\n¿Qué deseas hacer ahora?\n1. 🎨 **Modo Diseño**: Añadir o modificar nodos y mapas (Usa `begin_design`).\n2. 🚀 **Modo Ejecución**: Lanzar suites de pruebas existentes (Usa `execute_suite`).\n3. 🔍 **Auditoría**: Revisar la integridad del sistema (Usa `inspect_framework`)."
663
- }]
664
- };
665
- });
666
-
667
- async function ensureDirectories() {
668
- const dirs = ['maps', 'suites', 'test_cases', 'reports', 'data'];
669
- for (const dir of dirs) {
670
- const dirPath = path.join(__dirname, dir);
671
- try {
672
- await fs.mkdir(dirPath, { recursive: true });
673
- } catch (e) {
674
- process.stderr.write(`[WARN] Error creando directorio ${dir}: ${e.message}\n`);
675
- }
676
- }
677
- }
678
-
679
- async function test_wizard(action, data) {
680
- const contextPath = path.join(__dirname, '.test_wizard_context.json');
681
- let context = { step: "START", steps: [] };
682
-
683
- try {
684
- if (await fs.stat(contextPath)) {
685
- context = JSON.parse(await fs.readFile(contextPath, 'utf8'));
686
- }
687
- } catch (e) {}
688
-
689
- if (action === "start") {
690
- context = { step: "STEP_BASIC", testName: data.testName, steps: [] };
691
- await fs.writeFile(contextPath, JSON.stringify(context, null, 2));
692
- return { content: [{ type: "text", text: `📝 Creando Test Case: '${data.testName}'.\n\nPASO 1: Define el nombre y acción del primer paso. (Usa action: 'add_step' con { name, action }).` }] };
693
- }
694
-
695
- if (action === "add_step" && context.step === "STEP_BASIC") {
696
- context.currentStep = { name: data.name, action: data.action };
697
- context.step = "STEP_ASSERT";
698
- await fs.writeFile(contextPath, JSON.stringify(context, null, 2));
699
- return { content: [{ type: "text", text: `✅ Paso '${data.name}' añadido.\n\nPASO 2 (Opcional): ¿Quieres añadir una validación (assert) técnica? (Usa action: 'add_assert' con { assert } o 'add_step' para el siguiente sin assert).` }] };
700
- }
701
-
702
- if (action === "add_assert" && context.step === "STEP_ASSERT") {
703
- context.currentStep.assert = data.assert;
704
- context.steps.push(context.currentStep);
705
- context.step = "STEP_BASIC";
706
- await fs.writeFile(contextPath, JSON.stringify(context, null, 2));
707
- return { content: [{ type: "text", text: `🛡️ Assert añadido al paso.\n\n¿Siguiente paso? (Usa 'add_step') o finaliza con 'save'.` }] };
708
- }
709
-
710
- if (action === "save") {
711
- if (context.currentStep && context.step === "STEP_ASSERT") context.steps.push(context.currentStep);
712
- const result = await server.callTool("save_test_case", {
713
- name: context.testName,
714
- steps: context.steps
715
- });
716
- await fs.unlink(contextPath);
717
- return result;
718
- }
719
- }
720
-
721
- async function main() {
722
- await ensureDirectories();
723
- await maquina.cargar();
724
- const transport = new StdioServerTransport();
725
- await server.connect(transport);
726
- }
727
- main().catch(console.error);
1
+ #!/usr/bin/env node
2
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
3
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
4
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
5
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
+ import { z } from "zod";
7
+ import fs from "fs/promises";
8
+ import fsSync from "fs";
9
+ import path from "path";
10
+ import { fileURLToPath } from "url";
11
+ import { exec } from "child_process";
12
+ import { promisify } from "util";
13
+
14
+ const execAsync = promisify(exec);
15
+ const __filename = fileURLToPath(import.meta.url);
16
+ const __dirname = path.dirname(__filename);
17
+
18
+ const SUITES_DIR = path.join(__dirname, "suites");
19
+ const CASOS_DIR = path.join(__dirname, "test_cases");
20
+ const REPORTS_ROOT = path.join(__dirname, "reports");
21
+ const CONFIG_FILE = path.join(__dirname, "mcp_config.json");
22
+
23
+ export class MaquinaDeEstados {
24
+ constructor() {
25
+ this.nodos = new Map();
26
+ this.casosPrueba = new Map();
27
+ this.suites = new Map();
28
+ this.mcpClients = new Map();
29
+ }
30
+
31
+ async cargar() {
32
+ for (const dir of [CASOS_DIR, SUITES_DIR]) {
33
+ if (!fsSync.existsSync(dir)) await fs.mkdir(dir, { recursive: true });
34
+ const files = await fs.readdir(dir);
35
+ for (const f of files) {
36
+ if (f.endsWith(".json")) {
37
+ const content = JSON.parse(await fs.readFile(path.join(dir, f), "utf-8"));
38
+ if (dir === CASOS_DIR) this.casosPrueba.set(content.name, content);
39
+ else this.suites.set(content.name, content);
40
+ }
41
+ }
42
+ }
43
+ }
44
+
45
+ async getMcpClient(serverName) {
46
+ if (this.mcpClients.has(serverName)) return this.mcpClients.get(serverName);
47
+ const configRaw = await fs.readFile(CONFIG_FILE, "utf-8");
48
+ const config = JSON.parse(configRaw).mcpServers[serverName];
49
+ const transport = new StdioClientTransport({
50
+ command: config.command,
51
+ args: config.args || [],
52
+ env: { ...process.env, ...config.env }
53
+ });
54
+ const client = new Client({ name: "SMS-Client", version: "12.5.0" }, { capabilities: {} });
55
+ await client.connect(transport);
56
+ const data = { client, transport };
57
+ this.mcpClients.set(serverName, data);
58
+ return data;
59
+ }
60
+
61
+ interpolate(text, data) {
62
+ if (!data) return text;
63
+ let result = text;
64
+ for (const key in data) { result = result.replace(new RegExp(`{{${key}}}`, 'g'), data[key]); }
65
+ return result;
66
+ }
67
+
68
+ async ejecutarSuite(suiteName) {
69
+ const suite = this.suites.get(suiteName);
70
+ if (!suite) throw new Error(`Suite '${suiteName}' no encontrada.`);
71
+
72
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
73
+ const reportDir = path.join(REPORTS_ROOT, `exec_${suiteName}_${timestamp}`);
74
+ await fs.mkdir(reportDir, { recursive: true });
75
+ const results = { suiteName, timestamp, cases: [], hooks: { beforeSuite: [], afterSuite: [] } };
76
+
77
+ const runActions = async (actions, targetRes, data) => {
78
+ if (!actions) return;
79
+ for (const action of actions) {
80
+ const actionRes = { action, status: "passed" };
81
+ targetRes.push(actionRes);
82
+ try {
83
+ const executeAction = async (act) => {
84
+ const subRes = (act === action) ? actionRes : { action: act, status: "passed" };
85
+ if (act !== action) targetRes.push(subRes);
86
+
87
+ if (act.startsWith("transicion:")) {
88
+ const transName = act.replace("transicion:", "").trim();
89
+ const mapPath = path.join(__dirname, 'maps', suite.state_map);
90
+ const mapData = JSON.parse(await fs.readFile(mapPath, 'utf8'));
91
+ const nodes = mapData.nodos || mapData;
92
+
93
+ let foundAction, destNodeName;
94
+ for (const [nodeName, node] of Object.entries(nodes)) {
95
+ if (node.transiciones && node.transiciones[transName]) {
96
+ foundAction = node.transiciones[transName].accion;
97
+ destNodeName = node.transiciones[transName].destino;
98
+ break;
99
+ }
100
+ }
101
+ if (!foundAction) throw new Error(`Transición '${transName}' no encontrada.`);
102
+ await executeAction(foundAction);
103
+
104
+ // Identidad
105
+ const destNode = nodes[destNodeName];
106
+ if (destNode && destNode.fingerprint) {
107
+ const selectors = destNode.fingerprint.selectors || [destNode.fingerprint.selector];
108
+ const { client } = await this.getMcpClient("wdio-mcp");
109
+ for (const sel of selectors) {
110
+ const vRes = await client.callTool({ name: "wait_for_element", arguments: { selector: sel, timeout: 5000 } });
111
+ if (vRes.isError) throw new Error(`Identidad fallida: ${sel}`);
112
+ }
113
+ subRes.output = (subRes.output || "") + "\n✅ Identidad Multivectorial confirmada.";
114
+ }
115
+ return;
116
+ }
117
+
118
+ const interpolated = this.interpolate(act, data);
119
+ if (interpolated.startsWith("sh:")) {
120
+ const { stdout, stderr } = await execAsync(interpolated.replace("sh:", "").trim());
121
+ subRes.output = (subRes.output || "") + "\n" + stdout + (stderr ? "\nERR: " + stderr : "");
122
+ } else if (interpolated.startsWith("mcp:")) {
123
+ const raw = interpolated.replace("mcp:", "").trim();
124
+ const slash = raw.indexOf("/");
125
+ const space = raw.indexOf(" ", slash);
126
+ const srv = raw.substring(0, slash);
127
+ const tool = space === -1 ? raw.substring(slash + 1) : raw.substring(slash + 1, space);
128
+ const args = JSON.parse(space === -1 ? "{}" : raw.substring(space + 1));
129
+ const { client } = await this.getMcpClient(srv);
130
+ const res = await client.callTool({ name: tool, arguments: args });
131
+ if (res.content) {
132
+ for (const item of res.content) {
133
+ if (item.text) subRes.output = (subRes.output || "") + "\n" + item.text;
134
+ if (item.type === "image") subRes.image = item.data.startsWith("data:") ? item.data : `data:image/png;base64,${item.data}`;
135
+ }
136
+ }
137
+ }
138
+ };
139
+ await executeAction(action);
140
+ } catch (e) {
141
+ actionRes.status = "failed";
142
+ actionRes.error = e.message;
143
+ throw e;
144
+ }
145
+ }
146
+ };
147
+
148
+ try {
149
+ for (const caseName of suite.tests) {
150
+ const testCase = this.casosPrueba.get(caseName);
151
+ if (!testCase) continue;
152
+ const caseRes = { name: testCase.name, steps: [], status: "passed" };
153
+ results.cases.push(caseRes);
154
+ for (const step of testCase.steps) {
155
+ const stepRes = { name: step.name, actions: [], status: "passed" };
156
+ caseRes.steps.push(stepRes);
157
+ try {
158
+ await runActions(step.actions || [step.action], stepRes.actions);
159
+ if (step.assert) {
160
+ const aRes = { action: `[ASSERT] ${step.assert}`, status: "passed" };
161
+ stepRes.actions.push(aRes);
162
+ await runActions([step.assert], stepRes.actions);
163
+ }
164
+ } catch (e) { stepRes.status = "failed"; throw e; }
165
+ }
166
+ }
167
+ } finally {
168
+ for (const { transport } of this.mcpClients.values()) await transport.close();
169
+ this.mcpClients.clear();
170
+ await this.finalizarReporte(reportDir, results);
171
+ }
172
+ return reportDir;
173
+ }
174
+
175
+ async finalizarReporte(dir, data) {
176
+ const renderActions = (actions) => actions.map(a => {
177
+ const isAssert = a.action.startsWith("[ASSERT]");
178
+ const isIdentity = a.output && a.output.includes("Identidad");
179
+ return `
180
+ <div style="border-left: 4px solid ${a.status === 'passed' ? (isAssert ? '#10b981' : '#3b82f6') : '#ef4444'}; background: rgba(15, 23, 42, 0.8); padding: 20px; border-radius: 12px; margin-bottom: 15px; border: 1px solid #1e293b;">
181
+ <div style="display:flex; justify-content:space-between; margin-bottom: 10px;">
182
+ <span style="font-weight: 600; color: ${isAssert ? '#10b981' : '#94a3b8'};">${isAssert ? '🛡️ VALIDACIÓN' : '⚙️ ACCIÓN'}</span>
183
+ <span style="background:${a.status === 'passed' ? '#065f46' : '#991b1b'}; padding: 2px 8px; border-radius: 4px; font-size: 0.8em;">${a.status}</span>
184
+ </div>
185
+ <div style="color: #f8fafc; margin-bottom: 10px;">${isAssert ? a.action.replace("[ASSERT]", "").trim() : a.action}</div>
186
+ ${isIdentity ? `<div style="color: #34d399; font-size: 0.85em;">🔍 Identidad Multivectorial Confirmada</div>` : ""}
187
+ ${a.image ? `<div style="margin-top:10px; text-align:center;"><img src="${a.image}" style="max-width:100%; border-radius:8px;"></div>` : ""}
188
+ ${a.error ? `<pre style="color:#fca5a5; font-size:0.8em;">${a.error}</pre>` : ""}
189
+ </div>`;
190
+ }).join("");
191
+
192
+ const html = `<html><body style="background:#020617; color:#f8fafc; font-family:sans-serif; padding:50px;">
193
+ <h1 style="color:#60a5fa;">SMS Framework V12.5</h1>
194
+ ${data.cases.map(c => `
195
+ <div style="background:#0f172a; padding:30px; border-radius:20px; margin-bottom:30px; border:1px solid #1e293b;">
196
+ <h2>CASE: ${c.name}</h2>
197
+ ${c.steps.map(s => `<div><h3>STEP: ${s.name}</h3>${renderActions(s.actions)}</div>`).join("")}
198
+ </div>`).join("")}
199
+ </body></html>`;
200
+ await fs.writeFile(path.join(dir, "index.html"), html);
201
+ }
202
+ }
203
+
204
+ const maquina = new MaquinaDeEstados();
205
+ const server = new McpServer({ name: "demo-state-machine", version: "12.5.0" });
206
+
207
+ server.tool("init_project", "Instalación Inicial. Crea carpetas y archivos de plantilla.", {}, async () => {
208
+ const templates = {
209
+ 'maps/template_map.json': { nodos: { HOME: { fingerprint: { selectors: ["~Login"] }, transiciones: { IR_A_LOGIN: { destino: "LOGIN", accion: "mcp:wdio-mcp/click_element {\"selector\":\"~Login\"}" } } } } },
210
+ 'test_cases/template_case.json': { name: "TC_Ejemplo", steps: [{ name: "Ir a Login", action: "transicion:IR_A_LOGIN", assert: "sh:ls" }] },
211
+ 'suites/template_suite.json': { name: "Suite_Ejemplo", state_map: "template_map.json", tests: ["TC_Ejemplo"] }
212
+ };
213
+ for (const [f, c] of Object.entries(templates)) {
214
+ const p = path.join(__dirname, f);
215
+ if (!fsSync.existsSync(p)) { await fs.mkdir(path.dirname(p), { recursive: true }); await fs.writeFile(p, JSON.stringify(c, null, 2)); }
216
+ }
217
+ return { content: [{ type: "text", text: "✨ Entorno Inicializado. Usa 'sms_builder' para empezar el diseño." }] };
218
+ });
219
+
220
+ server.tool("sms_builder", "Diseño Guiado: Nodos -> Tests -> Suite.", {
221
+ action: z.enum(["start", "add_node", "add_fingerprint", "done_node", "add_test", "add_step", "add_assert", "save_test", "assemble_suite"]),
222
+ data: z.record(z.any())
223
+ }, async ({ action, data }) => {
224
+ const ctxPath = path.join(__dirname, '.sms_builder_context.json');
225
+ let ctx = { step: "IDLE", nodes: {}, tests: [] };
226
+ try { ctx = JSON.parse(fsSync.readFileSync(ctxPath, 'utf8')); } catch (e) {}
227
+
228
+ switch (action) {
229
+ case "start":
230
+ ctx = { step: "DESIGN", mapName: data.mapName, nodes: {}, tests: [] };
231
+ fsSync.writeFileSync(ctxPath, JSON.stringify(ctx, null, 2));
232
+ return { content: [{ type: "text", text: `🏗️ Diseñando Mapa: ${data.mapName}. Añade un nodo (action: 'add_node' { nodeName }).` }] };
233
+
234
+ case "add_node":
235
+ ctx.currentNode = { name: data.nodeName, transiciones: {}, fingerprint: null };
236
+ fsSync.writeFileSync(ctxPath, JSON.stringify(ctx, null, 2));
237
+ return { content: [{ type: "text", text: `📍 Nodo '${data.nodeName}'. MANDATORIO: Añade huella (action: 'add_fingerprint' { selector }).` }] };
238
+
239
+ case "add_fingerprint":
240
+ if (!ctx.currentNode.fingerprint) ctx.currentNode.fingerprint = { selectors: [] };
241
+ ctx.currentNode.fingerprint.selectors.push(data.selector);
242
+ fsSync.writeFileSync(ctxPath, JSON.stringify(ctx, null, 2));
243
+ return { content: [{ type: "text", text: `🧬 Vector añadido. ¿Otro o 'done_node'?` }] };
244
+
245
+ case "done_node":
246
+ ctx.nodes[ctx.currentNode.name] = { fingerprint: ctx.currentNode.fingerprint, transiciones: ctx.currentNode.transiciones };
247
+ await fs.writeFile(path.join(__dirname, 'maps', `${ctx.mapName}.json`), JSON.stringify({ nodos: ctx.nodes }, null, 2));
248
+ delete ctx.currentNode;
249
+ fsSync.writeFileSync(ctxPath, JSON.stringify(ctx, null, 2));
250
+ return { content: [{ type: "text", text: `✅ Nodo guardado. ¿Otro ('add_node') o Tests ('add_test')?` }] };
251
+
252
+ case "add_test":
253
+ ctx.currentTest = { name: data.testName, steps: [] };
254
+ fsSync.writeFileSync(ctxPath, JSON.stringify(ctx, null, 2));
255
+ return { content: [{ type: "text", text: `🧪 Test '${data.testName}'. Añade pasos (action: 'add_step' { name, action }).` }] };
256
+
257
+ case "add_step":
258
+ ctx.currentStep = { name: data.name, action: data.action };
259
+ fsSync.writeFileSync(ctxPath, JSON.stringify(ctx, null, 2));
260
+ return { content: [{ type: "text", text: `✅ Paso añadido. ¿Assert? (action: 'add_assert' { assert }) o siguiente ('add_step').` }] };
261
+
262
+ case "add_assert":
263
+ ctx.currentStep.assert = data.assert;
264
+ ctx.currentTest.steps.push(ctx.currentStep);
265
+ delete ctx.currentStep;
266
+ fsSync.writeFileSync(ctxPath, JSON.stringify(ctx, null, 2));
267
+ return { content: [{ type: "text", text: `🛡️ Assert añadido. ¿Siguiente paso ('add_step') o guardar ('save_test')?` }] };
268
+
269
+ case "save_test":
270
+ if (ctx.currentStep) ctx.currentTest.steps.push(ctx.currentStep);
271
+ await fs.writeFile(path.join(__dirname, 'test_cases', `${ctx.currentTest.name}.json`), JSON.stringify(ctx.currentTest, null, 2));
272
+ ctx.tests.push(ctx.currentTest.name);
273
+ delete ctx.currentTest;
274
+ fsSync.writeFileSync(ctxPath, JSON.stringify(ctx, null, 2));
275
+ return { content: [{ type: "text", text: `✅ Test guardado. ¿Otro ('add_test') o Suite ('assemble_suite')?` }] };
276
+
277
+ case "assemble_suite":
278
+ const suite = { name: data.suiteName, state_map: `${ctx.mapName}.json`, tests: ctx.tests };
279
+ await fs.writeFile(path.join(__dirname, 'suites', `${data.suiteName}.json`), JSON.stringify(suite, null, 2));
280
+ fsSync.unlinkSync(ctxPath);
281
+ return { content: [{ type: "text", text: `🎊 Suite '${data.suiteName}' lista. Corre con 'sms_executor'.` }] };
282
+ }
283
+ });
284
+
285
+ server.tool("sms_executor", "Corre una suite y genera reporte.", { suiteName: z.string() }, async ({ suiteName }) => {
286
+ await maquina.cargar();
287
+ const dir = await maquina.ejecutarSuite(suiteName);
288
+ return { content: [{ type: "text", text: `🚀 Reporte: ${dir}` }] };
289
+ });
290
+
291
+ async function main() {
292
+ for (const d of ['maps', 'suites', 'test_cases', 'reports']) {
293
+ const p = path.join(__dirname, d);
294
+ if (!fsSync.existsSync(p)) await fs.mkdir(p, { recursive: true });
295
+ }
296
+ await maquina.cargar();
297
+ const transport = new StdioServerTransport();
298
+ await server.connect(transport);
299
+ }
300
+ main().catch(console.error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-state-machine-test-framework",
3
- "version": "1.2.6",
3
+ "version": "1.2.8",
4
4
  "description": "High-fidelity State Machine MCP Server for autonomous E2E testing orchestration.",
5
5
  "main": "index.js",
6
6
  "type": "module",