@arcadialdev/arcality 2.4.27 → 2.4.29

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.
@@ -0,0 +1,783 @@
1
+ import { test, expect } from '@playwright/test';
2
+ import { AIAgentHelper, AgentAction } from './ai-agent-helper';
3
+ import { pushKnowledge, pushRule } from '../../src/services/collectiveMemoryService';
4
+ import { SecurityScanner } from '../../src/services/securityScanner';
5
+ import 'dotenv/config';
6
+ import * as fs from 'fs';
7
+ import * as path from 'path';
8
+
9
+ // Cache de reglas ya enviadas en esta ejecución (evita duplicados por el mismo mensaje)
10
+ const _sessionRuleCache = new Set<string>();
11
+
12
+ /** Guarda un error de validación como regla de negocio (fire-and-forget) */
13
+ function captureValidationRule(errorMessage: string, context: string): void {
14
+ const key = errorMessage.substring(0, 80).toLowerCase().trim();
15
+ if (_sessionRuleCache.has(key)) return; // Ya registrado en esta sesión
16
+ _sessionRuleCache.add(key);
17
+
18
+ pushRule({
19
+ rule_type: 'VALIDATION',
20
+ title: `Restricción detectada: ${errorMessage.substring(0, 80)}`,
21
+ description: `La aplicación rechazó la acción con el mensaje: "${errorMessage.substring(0, 300)}". Contexto: ${context.substring(0, 150)}`,
22
+ severity: 'important'
23
+ }).catch(() => { /* silencioso */ });
24
+ }
25
+
26
+ test('Arcality AI Runner', async ({ page }, testInfo) => {
27
+ test.setTimeout(1200000); // 20 minutos para que la IA local no se corte
28
+
29
+ const contextDir = process.env.CONTEXT_DIR || 'out';
30
+ const activeConfig = process.env.ACTIVE_CONFIG || 'Default';
31
+
32
+ if (!process.env.BASE_URL && process.env[`${activeConfig}_URL`]) process.env.BASE_URL = process.env[`${activeConfig}_URL`];
33
+ if (!process.env.LOGIN_USER && process.env[`${activeConfig}_USER`]) process.env.LOGIN_USER = process.env[`${activeConfig}_USER`];
34
+ if (!process.env.LOGIN_PASSWORD && process.env[`${activeConfig}_PASS`]) process.env.LOGIN_PASSWORD = process.env[`${activeConfig}_PASS`];
35
+
36
+ await page.context().grantPermissions(['geolocation']);
37
+ await page.context().setGeolocation({ latitude: 19.4326, longitude: -99.1332 });
38
+
39
+ const agent = new AIAgentHelper(page, contextDir, testInfo);
40
+ const prompt = process.env.SMART_PROMPT || "Navega al inicio y verifica que el sitio cargue";
41
+ const history: string[] = [];
42
+ const urlHistory: string[] = []; // Para rastrear navegación
43
+ const stepsData: any[] = []; // Para la Guía de Éxito
44
+
45
+ // Keywords Globales para el Test (Refinados para evitar falsos positivos)
46
+ // 10. DATA CORRECTION: If you try to save/create a record and an [ERROR_CRÍTICO] message appears (e.g., 'duplicado', 'ya existe', 'repetido', 'incorrecto'), you MUST STOP. Analyze which field caused the error, change its value to something unique, and ONLY THEN try to save again.
47
+ // 11. NEVER IGNORE ERRORS: If you see '🛑 [ERROR_CRÍTICO]', do not assume it will clear upon submission. It is a blocker.
48
+ // 12. SUCCESS VERIFICATION: If your mission is 'Create X', and you see a success message OR you are back on the list/table view, check if X is there. If so, use 'finish: true'. DO NOT START CREATING AGAIN.
49
+ // 13. DUPLICATE ACTION: If your history shows multiple clicks on 'Save' without data changes, you are in a loop. CHANGE THE DATA or use 'report_inability_to_proceed'.
50
+ const successKeywords = /exitosamente|creado correctamente|guardado correctamente|misión cumplida|registered successfully|created successfully|saved successfully|operación exitosa|registro guardado|successfully|correctly/i;
51
+ const failureKeywords = /\berror\b|falló|failed|no se puede|cannot|unable|ya existe|already exists|inválido|invalid|incorrecto|incorrect|ligado|linked|depende|depends|duplicado|duplicate|repetido|repeated|reintentar|retry|missing|required|obligatorio|bad request|400|500/i;
52
+
53
+ let aiMarkedSuccess = false;
54
+ let hasCriticalError = false;
55
+ let resultsSaved = false;
56
+ let isFinished = false;
57
+ let stepCount = 0; // Solo cuenta turnos de IA (no de Guía)
58
+ let guideStepCount = 0; // Turnos de Guía de Éxito (NO cuentan contra maxSteps)
59
+ let stepsDataBackup: any[] = []; // Backup para no perder la Guía de Éxito permanentemente
60
+ const maxSteps = 30; // Aumentado para misiones complejas
61
+ let lastSeenSuccessToast = ""; // Para evitar falsos positivos por mensajes persistentes
62
+
63
+ const saveMissionResults = () => {
64
+ if (resultsSaved) return;
65
+ resultsSaved = true;
66
+
67
+ if (!fs.existsSync(contextDir)) fs.mkdirSync(contextDir, { recursive: true });
68
+ const finalSuccess = aiMarkedSuccess && !hasCriticalError;
69
+
70
+ try {
71
+ const memoryFile = path.join(contextDir, 'memoria-agente.json');
72
+ let memories = [];
73
+ if (fs.existsSync(memoryFile)) {
74
+ const content = fs.readFileSync(memoryFile, 'utf8').trim();
75
+ if (content) {
76
+ try {
77
+ memories = JSON.parse(content);
78
+ } catch (e) {
79
+ console.warn("⚠️ Memoria corrupta, reiniciando...");
80
+ memories = [];
81
+ }
82
+ }
83
+ }
84
+
85
+ const missionData = {
86
+ prompt,
87
+ steps: history,
88
+ steps_data: stepsData,
89
+ success: finalSuccess,
90
+ error: hasCriticalError,
91
+ timestamp: Date.now()
92
+ };
93
+
94
+ memories.push(missionData);
95
+ fs.writeFileSync(memoryFile, JSON.stringify(memories, null, 2));
96
+ console.log(`>>ARCALITY_STATUS>> 🧠 Memoria guardada: ${stepsData.length} pasos nuevos`);
97
+ } catch (err) {
98
+ console.error("❌ Falló al guardar memoria:", err);
99
+ }
100
+
101
+ try {
102
+ const logPath = path.join(contextDir, `agent-log-${Date.now()}.json`);
103
+ fs.writeFileSync(logPath, JSON.stringify({ prompt, history, steps: stepCount, success: finalSuccess, error: hasCriticalError }, null, 2));
104
+ } catch (err) { }
105
+ };
106
+
107
+ // ── Persistencia en Memoria Colectiva (se llama desde TODOS los caminos de éxito) ──
108
+ let collectiveMemoryPersisted = false;
109
+ const persistCollectiveMemory = async () => {
110
+ if (collectiveMemoryPersisted) return; // Evitar doble ejecución
111
+ collectiveMemoryPersisted = true;
112
+
113
+ const pid = process.env.ARCALITY_PROJECT_ID || '';
114
+ const apiUrl = process.env.ARCALITY_API_URL || '';
115
+ const apiKey = process.env.ARCALITY_API_KEY || '';
116
+
117
+ if (!pid || !apiUrl || !apiKey) {
118
+ console.warn(`[CollectiveMemory] ⚠️ Vars faltantes — project_id='${pid}' api_url='${apiUrl}' api_key='${apiKey ? 'OK' : 'MISSING'}'`);
119
+ return;
120
+ }
121
+
122
+ console.log(`🧠 [COLLECTIVE MEMORY] Persistiendo conocimiento (project: ${pid})...`);
123
+ try {
124
+ const stepsNarrative = stepsData
125
+ .map(s => `${s.action_data?.action || 'action'} "${s.componentName}" en ${s.url}`)
126
+ .join(' → ')
127
+ .substring(0, 500);
128
+
129
+ // 1. Flujo completo → PROCESS knowledge
130
+ const knowledgeId = await pushKnowledge({
131
+ doc_type: 'PROCESS',
132
+ title: `Flujo exitoso: ${prompt.substring(0, 100)}`,
133
+ content: stepsNarrative || prompt.substring(0, 500)
134
+ });
135
+ if (knowledgeId) console.log(`🧠 [COLLECTIVE MEMORY] ✅ Knowledge guardado: ${knowledgeId}`);
136
+ else console.warn(`[CollectiveMemory] ⚠️ pushKnowledge retornó null (revisar HTTP status arriba)`);
137
+
138
+ // 2. Patrones de navegación → NAVIGATION rule
139
+ const uniqueUrls = [...new Set(stepsData.map(s => s.url).filter(Boolean))];
140
+ if (uniqueUrls.length > 1) {
141
+ const ruleId = await pushRule({
142
+ rule_type: 'NAVIGATION',
143
+ title: `Flujo de navegación: ${prompt.substring(0, 80)}`,
144
+ description: `Agente navegó ${uniqueUrls.length} páginas: ${uniqueUrls.join(' → ').substring(0, 400)}`,
145
+ severity: 'suggestion'
146
+ });
147
+ if (ruleId) console.log(`🧠 [COLLECTIVE MEMORY] ✅ Regla NAVIGATION guardada: ${ruleId}`);
148
+ }
149
+
150
+ // 3. Si hubo submit de formulario → UI_UX rule
151
+ const submitStep = stepsData.find(s =>
152
+ s.action_data?.action === 'click' &&
153
+ (s.componentName?.toLowerCase().includes('guardar') ||
154
+ s.componentName?.toLowerCase().includes('save') ||
155
+ s.componentName?.toLowerCase().includes('crear') ||
156
+ s.componentName?.toLowerCase().includes('registrar'))
157
+ );
158
+ if (submitStep) {
159
+ const ruleId2 = await pushRule({
160
+ rule_type: 'UI_UX',
161
+ title: `Patrón de guardado: ${prompt.substring(0, 60)}`,
162
+ description: `Flujo exitoso incluyó guardado/creación. Pasos: ${stepsNarrative.substring(0, 400)}`,
163
+ severity: 'important'
164
+ });
165
+ if (ruleId2) console.log(`🧠 [COLLECTIVE MEMORY] ✅ Regla UI_UX guardada: ${ruleId2}`);
166
+ }
167
+ } catch (err: any) {
168
+ console.warn(`[CollectiveMemory] ❌ Error inesperado en persistCollectiveMemory: ${err?.message}`);
169
+ }
170
+ };
171
+
172
+ console.log(`\n🤖 [AGENTE] Iniciando misión: "${prompt}"`);
173
+
174
+ // Standard Login
175
+ const base = process.env.BASE_URL || '';
176
+ const target = process.env.TARGET_PATH || '/';
177
+
178
+ if (process.env.LOGIN_USER && process.env.LOGIN_PASSWORD) {
179
+ console.log(">>ARCALITY_STATUS>> 🔑 Realizando login automático...");
180
+ const loginUrl = base + '/login';
181
+ if (!loginUrl.startsWith('http')) throw new Error(`URL Inválida: "${loginUrl}". Asegúrate de configurar la Base URL con http://`);
182
+ await page.goto(loginUrl);
183
+ try {
184
+ const userInp = page.locator('input[type="email"], input[name="email"], input[name="username"], [placeholder*="usuario" i], [placeholder*="correo" i], [placeholder*="email" i]').first();
185
+ await userInp.waitFor({ state: 'visible', timeout: 10000 });
186
+
187
+ const passInp = page.locator('input[type="password"], input[name="password"], [placeholder*="contraseña" i]').first();
188
+ const subBtn = page.locator('button[type="submit"], button:has-text("Login"), button:has-text("Entrar"), [role="button"]:has-text("Entrar")').first();
189
+
190
+ await userInp.click();
191
+ await userInp.fill(process.env.LOGIN_USER || '');
192
+ await passInp.click();
193
+ await passInp.fill(process.env.LOGIN_PASSWORD || '');
194
+
195
+ await page.waitForTimeout(500);
196
+ await subBtn.click();
197
+
198
+ // Esperar a que la URL cambie (salir del login)
199
+ await page.waitForURL(u => !u.href.includes('/login'), { timeout: 15000 }).catch(() => { });
200
+ await page.waitForLoadState('networkidle').catch(() => { });
201
+ } catch (e: any) {
202
+ console.warn(` ⚠️ Login automático omitido o ya autenticado.`);
203
+ }
204
+
205
+ if (target !== '/' && !page.url().includes(target)) {
206
+ const tgtUrl = target.startsWith('http') ? target : (base + target);
207
+ if (!tgtUrl.startsWith('http')) throw new Error(`URL Inválida: "${tgtUrl}"`);
208
+ await page.goto(tgtUrl).catch(() => { });
209
+ }
210
+ } else {
211
+ const tgtUrl = target.startsWith('http') ? target : (base + target);
212
+ if (!tgtUrl.startsWith('http')) throw new Error(`URL Inválida: "${tgtUrl}". Por favor proporciona una URL válida incluyendo http:// o https://`);
213
+ await page.goto(tgtUrl);
214
+ }
215
+ await page.waitForLoadState('networkidle');
216
+
217
+ // Global Download Listener
218
+ page.on('download', async (download) => {
219
+ aiMarkedSuccess = true;
220
+ isFinished = true;
221
+ saveMissionResults();
222
+ });
223
+
224
+ // MAIN LOOP
225
+ const maxTotalIterations = maxSteps + 50; // Safety: máximo total de iteraciones (IA + Guía)
226
+ let totalIterations = 0;
227
+ while (!isFinished && stepCount < maxSteps) {
228
+ totalIterations++;
229
+ if (totalIterations > maxTotalIterations) {
230
+ console.error(`>>ARCALITY_STATUS>> 🛑 Safety limit: ${totalIterations} iteraciones totales alcanzadas. Abortando.`);
231
+ hasCriticalError = true;
232
+ break;
233
+ }
234
+ let containsError = false;
235
+ // stepCount se incrementa MÁS ABAJO, solo si el turno fue de IA (no de Guía)
236
+ await page.waitForTimeout(1000);
237
+
238
+ // --- DETECCIÓN PROACTIVA DE ÉXITO (Prevención de redundancia) ---
239
+ // Solo se activa tras el Turno 4 Y si ya hubo un submit (ej: guardar/save) en el historial.
240
+ const currentUrl = page.url();
241
+ const historyStr = history.join(' ').toLowerCase();
242
+ const hadSubmitAction = historyStr.includes('guardar') || historyStr.includes('save') || historyStr.includes('registrar');
243
+ const isMultiStep = prompt.toLowerCase().match(/después|luego|posteriormente|pestaña|tabla|doble clic|arrastra|paso|sección|modal|etapa/i);
244
+ const allowRepeatedNames = prompt.toLowerCase().match(/modal|varias|distintos|diferentes|repetir|guardar dos veces/i) || isMultiStep;
245
+
246
+
247
+ if (stepCount > 4 && hadSubmitAction && !isMultiStep) {
248
+ // 1. Chequeo por retorno a lista tras guardar
249
+ const isNowOnList = !currentUrl.includes('/new') && !currentUrl.includes('/create') && !currentUrl.includes('/edit') && !currentUrl.includes('/details') && !currentUrl.includes('/alta');
250
+ const wasInForm = historyStr.includes('nueva') || historyStr.includes('crear') || historyStr.includes('alta') || historyStr.includes('/new') || historyStr.includes('/create');
251
+
252
+ if (wasInForm && isNowOnList) {
253
+ // Escanear SOLO elementos pequeños de feedback, no todo el body
254
+ const feedbackEls = await page.locator('.toast, .alert, [role="status"], [role="alert"], .snackbar, .notification, .success-message, .mat-snack-bar-container').all();
255
+ let foundSuccessToast = false;
256
+ for (const el of feedbackEls) {
257
+ if (await el.isVisible().catch(() => false)) {
258
+ const txt = await el.innerText().catch(() => '');
259
+ if (successKeywords.test(txt.toLowerCase())) {
260
+ foundSuccessToast = true;
261
+ console.log(`>>ARCALITY_STATUS>> ✨ Auto-Success: Toast de éxito detectado: "${txt.substring(0, 60)}"`);
262
+ break;
263
+ }
264
+ }
265
+ }
266
+
267
+ // Si regresamos a la lista Y detectamos un toast de éxito, es éxito.
268
+ // Ya no confiamos solo en "volver a la lista" para evitar falsos positivos en URLs genéricas.
269
+ if (foundSuccessToast) {
270
+ // Solo es éxito si es un mensaje NUEVO o ha pasado tiempo desde el último submit
271
+ console.log(`>>ARCALITY_STATUS>> ✨ Auto-Success: Regreso a lista Y mensaje de éxito detectado.`);
272
+ aiMarkedSuccess = true;
273
+ isFinished = true;
274
+ saveMissionResults();
275
+ break;
276
+ }
277
+ }
278
+ }
279
+
280
+ // --- LIMPIEZA DE MENSAJES DE STATUS PREEXISTENTES (Login) ---
281
+ // Si estamos en el primer turno real post-login, registramos mensajes existentes para ignorarlos
282
+ if (stepCount === 1) {
283
+ const initialToasts = await page.locator('.toast, .alert, [role="status"], [role="alert"]').all();
284
+ for (const t of initialToasts) {
285
+ if (await t.isVisible().catch(() => false)) {
286
+ lastSeenSuccessToast = await t.innerText().catch(() => "");
287
+ if (lastSeenSuccessToast) console.log(`>>ARCALITY_STATUS>> 💡 Nota: Ignorando mensaje preexistente: "${lastSeenSuccessToast.substring(0, 30)}..."`);
288
+ }
289
+ }
290
+ }
291
+ // ----------------------------------------------------------------
292
+
293
+ // 0. Detectar errores antes de cualquier otra lógica
294
+ const rawBody = await page.innerText('body').catch(() => "");
295
+ const bodyTxt = rawBody.toLowerCase();
296
+ const hasVisibleError = failureKeywords.test(bodyTxt);
297
+
298
+ // 1. INTENTAR GUÍA (Solo si NO hay errores visibles)
299
+ let response: AgentAction | null = null;
300
+ if (!hasVisibleError) {
301
+ response = await agent.getActionFromGuia(prompt, stepsData.length);
302
+ } else {
303
+ console.log(`>>ARCALITY_STATUS>> ⚠️ Error detectado en pantalla. Saltando Guía para priorizar corrección.`);
304
+ if (stepsData.length > 0) {
305
+ stepsDataBackup = [...stepsData]; // Guardar backup antes de invalidar
306
+ stepsData.length = 0;
307
+ }
308
+ }
309
+
310
+ if (response) {
311
+ // GUARD: Invalidate guide if it suggests an action already attempted recently
312
+ const guideActionDesc = response.actions?.[0]
313
+ ? `${response.actions[0].action} en "${(response.actions[0] as any).description || ''}"`.toLowerCase()
314
+ : '';
315
+ const recentHistory = history.slice(-4).map(h => h.toLowerCase());
316
+ const isGuideRepeating = guideActionDesc && recentHistory.some(h => h.includes(guideActionDesc.substring(0, 30)));
317
+
318
+ if (isGuideRepeating) {
319
+ console.log(`>>ARCALITY_STATUS>> ⚠️ Guía invalidada: la acción "${guideActionDesc.substring(0, 50)}" ya fue intentada. Delegando a IA.`);
320
+ response = null; // Force IA to decide
321
+ } else {
322
+ guideStepCount++;
323
+ console.log(`>>ARCALITY_STATUS>> ✨ Guía de éxito: Paso validado (Guía #${guideStepCount}, NO consume turno IA)`);
324
+ }
325
+ }
326
+
327
+ if (!response) {
328
+ // 2. CONSULTAR IA — ESTE turno SÍ cuenta contra maxSteps
329
+ stepCount++;
330
+ console.log(`>>ARCALITY_STATUS>> ⏳ Turno IA ${stepCount} de ${maxSteps} (Guía usó ${guideStepCount} turnos gratis)...`);
331
+ response = await agent.askIA(prompt, history, stepCount);
332
+ }
333
+
334
+ console.log(`🧠 Pensamiento: ${response.thought}`);
335
+
336
+ // DETECCIÓN DE LOOPS: Si las últimas 6 acciones muestran un patrón repetitivo, forzar terminación
337
+ if (!response.finish && history.length >= 6) {
338
+ const lastSixActions = history.slice(-6);
339
+ const clickActions = lastSixActions.filter(h => h.includes('click en'));
340
+ const fillActions = lastSixActions.filter(h => h.includes('fill en'));
341
+ const uniqueClicks = new Set(clickActions);
342
+ const uniqueFills = new Set(fillActions);
343
+
344
+ // Si hay 4+ clicks y solo 1-2 elementos únicos = LOOP DETECTADO
345
+ const isClickLoop = clickActions.length >= 4 && uniqueClicks.size <= 2;
346
+ // NEW: Si hay 4+ fills y solo 1-2 elementos únicos = LOOP DETECTADO (time picker pattern)
347
+ const isFillLoop = fillActions.length >= 4 && uniqueFills.size <= 2;
348
+
349
+ if (isClickLoop || isFillLoop) {
350
+ const loopType = isClickLoop ? 'click' : 'fill';
351
+ const uniqueTargets = isClickLoop ? uniqueClicks : uniqueFills;
352
+ console.warn(`\n⚠️ [LOOP DETECTOR] Patrón repetitivo de ${loopType} detectado:`);
353
+ console.warn(` Últimas acciones: ${lastSixActions.join(' → ')}`);
354
+ console.warn(` El agente está atrapado. Forzando terminación con sugerencia.`);
355
+
356
+ response.thought = `🛑 ERROR: Me he quedado atrapado en un bucle repetitivo intentando interactuar con: ${Array.from(uniqueTargets).join(', ')}. No puedo continuar la misión de forma autónoma.\n\n💡 REGLA DE QA: He detectado que mi acción de ${loopType} no está cambiando el estado de la página como esperaba. Esto podría ser un bug de la aplicación o una limitación de mi percepción actual.`;
357
+ response.finish = true;
358
+ response.actions = [];
359
+ aiMarkedSuccess = false;
360
+ hasCriticalError = true;
361
+ }
362
+ }
363
+
364
+ if (response.actions && response.actions.length > 0) {
365
+ for (const step of response.actions) {
366
+ console.log(`>>ARCALITY_STATUS>> 🎯 Acción: ${step.action} en "${(step as any).description || step.selector}"`);
367
+
368
+ try {
369
+ const frame = (step.frameIdx !== undefined && page.frames()[step.frameIdx]) ? page.frames()[step.frameIdx] : page;
370
+ const loc = step.selector ? frame.locator(step.selector).first() : null;
371
+
372
+ const urlBeforeAction = page.url(); // CAPTURA ANTES DE ACCIÓN
373
+
374
+ if ((step.action === 'click' || step.action === 'double_click') && loc) {
375
+ const desc = ((step as any).description || '').toLowerCase();
376
+ const isMenuTrigger = desc.includes('more_vert') || desc.includes('menu') || desc.includes('icono :') || desc.includes('dots') || desc.includes('opciones');
377
+
378
+ // PROFUNDO: BLOQUEO DE DOBLE-SUBMIT
379
+ // Si ya hicimos click en "Guardar" antes, NO LO VOLVEMOS A HACER. Asumimos que la IA está confundida o verificando.
380
+ const isSubmitAction = desc.includes('guardar') || desc.includes('save') || desc.includes('crear') || desc.includes('registrar') || desc.includes('enviar') || desc.includes('aceptar') || desc.includes('confirmar') || desc.includes('update');
381
+ if (isSubmitAction) {
382
+ // Semantic boost for critical feedback
383
+ const rawBodyText = await page.innerText('body').catch(() => "");
384
+ const textLower = rawBodyText.toLowerCase();
385
+ const isCriticalFailure = textLower.includes('error') || textLower.includes('falló') || textLower.includes('no se puede') || textLower.includes('ya existe') || textLower.includes('existe') || textLower.includes('inválido') || textLower.includes('incorrecto') || textLower.includes('ligado') || textLower.includes('depende') || textLower.includes('duplicado') || textLower.includes('repetido') || textLower.includes('no encontrado') || textLower.includes('no existe') || textLower.includes('obligatorio') || textLower.includes('required');
386
+ const isCriticalSuccess = textLower.includes('exitosamente') || textLower.includes('guardado') || textLower.includes('creado') || textLower.includes('success') || textLower.includes('correctamente') || textLower.includes('misión cumplida');
387
+ const isCriticalFeedback = isCriticalFailure || isCriticalSuccess;
388
+
389
+ // Priorizar la detección de fallos/éxitos en elementos pequeños que la IA podría ignorar
390
+ if (!isCriticalFeedback && (step as any).type && ['span', 'p', 'label', 'i', 'svg', 'img'].includes((step as any).type)) {
391
+ const elementText = await loc.innerText().catch(() => "");
392
+ const elementTextLower = elementText.toLowerCase();
393
+ if (failureKeywords.test(elementTextLower)) {
394
+ console.log(`>>ARCALITY_STATUS>> ⚠️ Feedback de ERROR en elemento pequeño: "${elementTextLower.substring(0, 50)}..."`);
395
+ aiMarkedSuccess = false;
396
+ // If a critical error is detected on a small element, it's a strong signal.
397
+ // We should consider this a critical error for the mission.
398
+ hasCriticalError = true;
399
+ isFinished = true;
400
+ saveMissionResults();
401
+ break; // Break out of the actions loop
402
+ } else {
403
+ const isSuccessMatch = successKeywords.test(elementTextLower);
404
+ if (isSuccessMatch) {
405
+ console.log(`>>ARCALITY_STATUS>> ✨ Éxito visual en elemento pequeño: "${elementTextLower.substring(0, 50)}..."`);
406
+ aiMarkedSuccess = true;
407
+ isFinished = true;
408
+ hasCriticalError = false;
409
+ saveMissionResults();
410
+ break;
411
+ }
412
+ }
413
+ }
414
+
415
+ const previousSubmit = history.find(h =>
416
+ h.toLowerCase().includes('click') &&
417
+ (h.toLowerCase().includes('guardar') || h.toLowerCase().includes('save') || h.toLowerCase().includes('crear') || h.toLowerCase().includes('registrar'))
418
+ );
419
+
420
+ if (previousSubmit && !allowRepeatedNames) {
421
+ console.log(`>>ARCALITY_STATUS>> 🛑 Advertencia: Click persistente en botón de acción.`);
422
+ // No matamos el test, solo notificamos al agente en su historia para que lo vea
423
+ history.push(`Turno ${stepCount}: Intentaste hacer click en "${desc}" nuevamente, pero la página sigue mostrando el mismo estado. ¿Hay algún error visible que debas corregir antes?`);
424
+ await page.waitForTimeout(1000);
425
+ }
426
+ }
427
+
428
+ // PROFUNDO: BLOQUEO DE DOBLE-CLICK CONSECUTIVO
429
+ // Si la acción anterior fue exactamente la misma (mismo elemento), y no hubo un cambio significativo, lo bloqueamos.
430
+ const lastAction = history[history.length - 1];
431
+ if (lastAction && lastAction.includes(`click en "${desc}"`)) {
432
+ const isDotsTrigger = desc.includes('dots') || desc.includes('vert') || desc.includes('menu') || desc.includes('opciones');
433
+ const sameActionCount = history.filter(h => h.includes(`click en "${desc}"`)).length;
434
+
435
+ // Si el prompt permite repeticiones (ej. multi-step con varios 'Guardar'), somos más tolerantes
436
+ const toleranceLimit = allowRepeatedNames ? 3 : 1;
437
+
438
+ if (isDotsTrigger && sameActionCount < 2) {
439
+ console.log(` ⚠️ Re-intentando click en menú "${desc}"...`);
440
+ } else if (allowRepeatedNames && sameActionCount < toleranceLimit) {
441
+ console.log(` ⚠️ Click repetido permitido por contexto de misión en "${desc}"...`);
442
+ } else {
443
+ // FALLBACK: Si estamos estancados, ¿quizás el error ya es visible en algun post?
444
+ const bodyText = await page.innerText('body').catch(() => "");
445
+ if (bodyText.match(/ligado|depende|no se puede/i)) {
446
+ console.log(`\n✨ [ESTANCADO] El agente repite click pero el error ya es visible. Finalizando.`);
447
+ isFinished = true;
448
+ aiMarkedSuccess = true;
449
+ break;
450
+ }
451
+ console.log(`\n🛑 [AUTO-STOP] Bloqueando click duplicado en "${desc}". Forzando re-evaluación.`);
452
+ history.push(`Turno ${stepCount}: Bloqueado click repetido en "${desc}" (Evitando bucle)`);
453
+ await page.waitForTimeout(1000);
454
+ break;
455
+ }
456
+ }
457
+
458
+ if (isMenuTrigger && history.some(h => h.includes(desc) && h.includes('click'))) {
459
+ console.log(` ⚠️ El disparador "${desc}" ya fue clicado. Esperando estabilidad extra...`);
460
+ await page.waitForTimeout(1500);
461
+ }
462
+
463
+ await loc.scrollIntoViewIfNeeded({ timeout: 5000 }).catch(() => { });
464
+
465
+ if (step.action === 'double_click') {
466
+ await loc.dblclick({ timeout: 10000, force: true });
467
+ } else {
468
+ // HACK: Si es cerrar toast y falla, ignoramos
469
+ if (desc.includes('toast') || desc.includes('close')) {
470
+ await loc.click({ timeout: 2000, force: true }).catch(() => console.log(" ⚠️ No se pudo cerrar toast, ignorando..."));
471
+ } else {
472
+ await loc.click({ timeout: 10000, force: true });
473
+ }
474
+ }
475
+
476
+ if (isMenuTrigger) {
477
+ console.log(" ⏳ Abriendo menú, esperando 1.5s para renderizado...");
478
+ await page.waitForTimeout(1500);
479
+ }
480
+
481
+ // INTELIGENCIA DE FINALIZACIÓN POST-GUARDADO
482
+ // Si la acción fue "Guardar" o "Crear", verificamos si hubo navegación exitosa
483
+ const actsLikeSubmit = desc.includes('guardar') || desc.includes('save') || desc.includes('crear') || desc.includes('registrar');
484
+
485
+ if (actsLikeSubmit) {
486
+ console.log(" ⏳ Esperando transición post-guardado...");
487
+ try {
488
+ await page.waitForLoadState('networkidle', { timeout: 8000 });
489
+ } catch (e) { console.log(" ⚠️ Timeout esperando red, continuando..."); }
490
+
491
+ // 1. CHEQUEO POR VISUALIZACIÓN DE ÉXITO Y ERRORES (Solo Toasts/Alertas)
492
+ const postSubmitFeedback = await page.locator('.toast, .alert, [role="status"], [role="alert"], .snackbar, .notification, .success-message, .mat-snack-bar-container').all();
493
+ let foundSuccessPost = false;
494
+ for (const fb of postSubmitFeedback) {
495
+ if (await fb.isVisible().catch(() => false)) {
496
+ const fbText = await fb.innerText().catch(() => '');
497
+ const postSuccessKw = /exitosamente|creado correctamente|guardado correctamente|operación exitosa|registro guardado|saved successfully|created successfully/i;
498
+ if (postSuccessKw.test(fbText)) {
499
+ console.log(`>>ARCALITY_STATUS>> ✨ Éxito visual detectado en toast: "${fbText.substring(0, 60)}"`);
500
+ aiMarkedSuccess = true;
501
+ isFinished = true;
502
+ saveMissionResults();
503
+ foundSuccessPost = true;
504
+ break;
505
+ } else if (failureKeywords.test(fbText.toLowerCase())) {
506
+ // ⭐ ERROR DE VALIDACIÓN DE NEGOCIO — Guardar como regla
507
+ console.log(`>>ARCALITY_STATUS>> 📚 [BUSINESS RULE] Error de validación capturado: "${fbText.substring(0, 80)}"`);
508
+ captureValidationRule(fbText.trim(), `Acción: click en "${desc}" en ${page.url()}`);
509
+ }
510
+ }
511
+ }
512
+ if (foundSuccessPost) break;
513
+
514
+
515
+ // 2. CHEQUEO POR CAMBIO DE URL (Backup)
516
+ const currentUrl = page.url();
517
+ const isBackOnList = urlBeforeAction !== currentUrl && !currentUrl.includes('/new') && !currentUrl.includes('/edit') && !currentUrl.includes('/create');
518
+
519
+ if (isBackOnList) {
520
+ console.log(`\n✨ [AUTO-SUCCESS] Navegación detectada tras guardar (${urlBeforeAction} -> ${currentUrl}). Confirmando...`);
521
+ await page.waitForTimeout(2000);
522
+
523
+ const postNavAlerts = await page.locator(
524
+ '.toast, .alert, [role="status"], [role="alert"], .snackbar, .notification, .error-message, .mat-snack-bar-container'
525
+ ).all();
526
+ let hasPostNavError = false;
527
+ for (const alertEl of postNavAlerts) {
528
+ if (await alertEl.isVisible().catch(() => false)) {
529
+ const alertTxt = await alertEl.innerText().catch(() => '');
530
+ if (failureKeywords.test(alertTxt.toLowerCase())) {
531
+ hasPostNavError = true;
532
+ console.log(`>>ARCALITY_STATUS>> ⚠️ Error en alerta post-guardado: "${alertTxt.substring(0, 60)}"`);
533
+ break;
534
+ }
535
+ }
536
+ }
537
+
538
+ if (!hasPostNavError) {
539
+ aiMarkedSuccess = true;
540
+ isFinished = true;
541
+ await persistCollectiveMemory(); // ← Persistir en TODOS los caminos de éxito
542
+ saveMissionResults();
543
+ break;
544
+ } else {
545
+ console.log(`>>ARCALITY_STATUS>> ⚠️ Se detectó navegación pero hay un ERROR en alerta/toast visible.`);
546
+ }
547
+ }
548
+ } else if (desc.includes('siguiente') || desc.includes('next')) {
549
+ await page.waitForTimeout(1000);
550
+ } else {
551
+ await page.waitForTimeout(500); // Click normal, espera corta
552
+ }
553
+ }
554
+ else if (step.action === 'fill' && loc && step.value) {
555
+ // DETECCIÓN INTELIGENTE DE INPUT FILE
556
+ const isFile = await loc.evaluate((el: any) => el.tagName === 'INPUT' && el.type === 'file').catch(() => false);
557
+
558
+ if (isFile) {
559
+ console.log(` 📂 Detectado input de archivo. Subiendo: "${step.value}"`);
560
+ const filePath = step.value.replace(/^"|"$/g, '').trim(); // Limpiar comillas si la IA las puso
561
+ await loc.setInputFiles(filePath);
562
+ } else {
563
+ await loc.click({ timeout: 5000 }).catch(() => { });
564
+ await loc.fill(step.value, { timeout: 10000, force: true });
565
+ await loc.press('Tab').catch(() => { }); // Disparar validaciones/change events
566
+ }
567
+ await page.waitForTimeout(500); // Dar un respiro a la UI
568
+ }
569
+ else if (step.action === 'wait') {
570
+ await page.waitForTimeout(3000);
571
+ }
572
+
573
+ // Registrar en historial para la IA
574
+ const descForHistory = ((step as any).description || step.selector || '').substring(0, 80);
575
+ const valStr = step.value ? ` con valor "${step.value}"` : '';
576
+ history.push(`Turno ${stepCount}: ${step.action} en "${descForHistory}"${valStr}`);
577
+ urlHistory.push(page.url());
578
+
579
+ // Registrar en Guía de Éxito (solo si el componente tiene un nombre razonable)
580
+ const compName = ((step as any).description || '').substring(0, 80);
581
+ if (compName.length < 100) { // Skip guide storage for misidentified giant text blobs
582
+ stepsData.push({
583
+ url: urlBeforeAction, // USAR URL CAPTURADA ANTES
584
+ url_normalized: urlBeforeAction.replace(/\/[a-z]{2}-[A-Z]{2}\//g, '/{locale}/').replace(/\/[a-z]{2}\//g, '/{lang}/'),
585
+ componentName: compName,
586
+ componentType: (step as any).type,
587
+ action_data: { action: step.action, value: step.value },
588
+ finish: response.finish
589
+ });
590
+ }
591
+
592
+ } catch (e: any) {
593
+ console.warn(` ⚠️ Acción fallida: ${e.message}`);
594
+ history.push(`Turno ${stepCount}: ❌ FALLÓ ${step.action} en "${(step as any).description}" - Error: ${e.message}. REGLA MANDATORIA: Debes investigar la causa usando 'inspect_element_details' o 'capture_console_errors' antes de cualquier otro paso.`);
595
+ containsError = true;
596
+
597
+ // Solo matamos si el error es persistente para evitar bucles infinitos
598
+ const errorCount = history.filter(h => h.includes('FALLÓ') && h.includes((step as any).description)).length;
599
+ if (errorCount >= 2) {
600
+ console.error(`>>ARCALITY_STATUS>> 🛑 Demasiados fallos técnicos en este elemento "${(step as any).description}". Abortando misión.`);
601
+ hasCriticalError = true;
602
+ isFinished = true;
603
+ break;
604
+ }
605
+ }
606
+ }
607
+ }
608
+
609
+ // SOLO permitimos finalizar si no hubo errores técnicos en este turno
610
+ if (response.finish) {
611
+ if (!containsError) {
612
+ console.log(">>ARCALITY_STATUS>> ✅ El agente solicitó finalizar misión.");
613
+ isFinished = true;
614
+
615
+ // --- REGISTRO DEL PASO FINAL EN LA GUÍA DE ÉXITO ---
616
+ // Si el agente termina sin acciones (ej. vía create_test_evidence), registramos un paso implícito
617
+ // para que la guía sepa que aquí debe terminar en futuras ejecuciones.
618
+ if (!response.actions || response.actions.length === 0) {
619
+ stepsData.push({
620
+ url: page.url(),
621
+ url_normalized: page.url().replace(/\/[a-z]{2}-[A-Z]{2}\//g, '/{locale}/').replace(/\/[a-z]{2}\//g, '/{lang}/'),
622
+ componentName: "MISSION_END",
623
+ componentType: "FINISH_SIGNAL",
624
+ action_data: { action: 'finish' },
625
+ finish: true
626
+ });
627
+ } else {
628
+ // Si hubo acciones, nos aseguramos que la última tenga el flag 'finish'
629
+ if (stepsData.length > 0) {
630
+ stepsData[stepsData.length - 1].finish = true;
631
+ }
632
+ }
633
+
634
+ // Verificar solo en toasts/alertas VISIBLES, NO en el body completo
635
+ const finishFeedbackEls = await page.locator('.toast, .alert, [role="status"], [role="alert"], .snackbar, .notification, .mat-snack-bar-container, .error-message').all();
636
+ let hasVisibleFailure = false;
637
+ for (const el of finishFeedbackEls) {
638
+ if (await el.isVisible().catch(() => false)) {
639
+ const txt = await el.innerText().catch(() => '');
640
+ if (failureKeywords.test(txt.toLowerCase())) {
641
+ hasVisibleFailure = true;
642
+ console.log(`>>ARCALITY_STATUS>> ⚠️ Error detectado en toast/alerta: "${txt.substring(0, 60)}"`);
643
+ break;
644
+ }
645
+ }
646
+ }
647
+
648
+ if (!hasVisibleFailure) {
649
+ // Marcar éxito: el agente pidió finish y no hay errores visibles
650
+ aiMarkedSuccess = true;
651
+ hasCriticalError = false;
652
+
653
+ // Guardar resultados proactivamente antes de que Playwright cierre el contexto
654
+ saveMissionResults();
655
+
656
+ // Restaurar stepsData si fue invalidada pero la misión se recuperó
657
+ if (stepsData.length === 0 && stepsDataBackup.length > 0) {
658
+ stepsData.push(...stepsDataBackup);
659
+ console.log(`>>ARCALITY_STATUS>> 🔄 Guía de Éxito restaurada (${stepsDataBackup.length} pasos recuperados).`);
660
+ }
661
+ await persistCollectiveMemory(); // ← camino: response.finish sin error
662
+ } else {
663
+ console.log(`>>ARCALITY_STATUS>> ⚠️ El agente intentó finalizar pero se detectó un ERROR en alert/toast.`);
664
+ aiMarkedSuccess = false;
665
+ isFinished = false; // Forzar a que el agente vea el error
666
+ history.push(`Turno ${stepCount}: El agente intentó finalizar pero el sistema detectó un error en un toast/alerta visible.`);
667
+ }
668
+ } else {
669
+ console.warn(">>ARCALITY_STATUS>> ⚠️ El agente intentó finalizar pero hubo fallos en el turno. Continuando para investigación...");
670
+ isFinished = false;
671
+ }
672
+ }
673
+
674
+ // Auto-detección por feedback visual (Toasts, Alertas, o mensajes de estado)
675
+ const feedbackSelector = '.toast, .alert, .message, div[role="status"], .error-message, [class*="alert"], [class*="toast"], .status-message';
676
+ const feedbackLocator = page.locator(feedbackSelector);
677
+
678
+ // Solo autodetectar si estamos más allá del Turno 2
679
+ if (stepCount > 2) {
680
+ const allFeedback = await feedbackLocator.all();
681
+ for (const fb of allFeedback) {
682
+ if (await fb.isVisible()) {
683
+ const rawTxt = await fb.innerText();
684
+ const txt = rawTxt.toLowerCase();
685
+
686
+ if (failureKeywords.test(txt)) {
687
+ console.log(`>>ARCALITY_STATUS>> ⚠️ Feedback de ERROR detectado: "${rawTxt.substring(0, 60)}..."`);
688
+ aiMarkedSuccess = false;
689
+
690
+ // ⭐ ERROR DE VALIDACIÓN / NEGOCIO — Guardar como regla de dominio
691
+ captureValidationRule(rawTxt.trim(), `URL: ${page.url()}`);
692
+
693
+ // CRÍTICO: Si detectamos un error, invalidamos la Guía de Éxito para este run
694
+ if (stepsData.length > 0) {
695
+ console.log(`>>ARCALITY_STATUS>> 🔄 Invalidando Guía de Éxito por feedback de error.`);
696
+ stepsDataBackup = [...stepsData]; // Guardar backup
697
+ stepsData.length = 0;
698
+ }
699
+ }
700
+ else if (successKeywords.test(txt)) {
701
+ if (txt !== lastSeenSuccessToast) {
702
+ console.log(`>>ARCALITY_STATUS>> ✨ Éxito visual detectado: "${txt.substring(0, 50)}..."`);
703
+ aiMarkedSuccess = true;
704
+ isFinished = true;
705
+ hasCriticalError = false;
706
+ await persistCollectiveMemory(); // ← camino: toast de éxito
707
+ saveMissionResults();
708
+ break;
709
+ } else {
710
+ console.log(`>>ARCALITY_STATUS>> 💡 Mensaje de éxito persistente ignorado.`);
711
+ }
712
+ }
713
+ }
714
+ }
715
+ }
716
+ }
717
+
718
+ if (aiMarkedSuccess) {
719
+ hasCriticalError = false;
720
+ // Si no se persistió aún (ej. ningún camino early de success lo hizo), persistir ahora
721
+ await persistCollectiveMemory();
722
+
723
+ console.log("✍️ [HUMANIZANDO] Generando resumen de éxito para el reporte...");
724
+ const summary = await agent.humanizeMissionResult(prompt, history);
725
+ if (summary) {
726
+ await testInfo.attach('success_summary', {
727
+ body: Buffer.from(summary, 'utf-8'),
728
+ contentType: 'text/plain'
729
+ });
730
+ }
731
+
732
+ console.log("🤖 [SMART-YAML] Generating mission card...");
733
+ const smartYaml = await agent.summarizeMissionToYaml(prompt, history, target);
734
+ if (smartYaml) {
735
+ const yamlPath = path.join(contextDir, 'last-mission-smart.yaml');
736
+ fs.writeFileSync(yamlPath, smartYaml);
737
+ console.log(`>>ARCALITY_STATUS>> 📝 Smart Mission Card generated.`);
738
+ await testInfo.attach('smart_mission_card', {
739
+ body: Buffer.from(smartYaml, 'utf-8'),
740
+ contentType: 'text/yaml'
741
+ });
742
+ }
743
+ }
744
+
745
+ // --- SECURITY SCAN ---
746
+ try {
747
+ const scanner = new SecurityScanner(page);
748
+ const securityReport = await scanner.runAllScans();
749
+
750
+ if (securityReport && securityReport.length > 0) {
751
+ await testInfo.attach('security_report', {
752
+ body: JSON.stringify(securityReport, null, 2),
753
+ contentType: 'application/json'
754
+ });
755
+ console.log(`>>ARCALITY_STATUS>> 🛡️ Escaneo de seguridad completado. ${securityReport.length} problemas encontrados.`);
756
+ try {
757
+ // Persistir hallazgos en Memoria Colectiva como reglas de tipo SECURITY
758
+ for (const v of securityReport) {
759
+ const sev = (v.severity || 'Info') as string;
760
+ const mappedSeverity = sev === 'Critical' ? 'critical' : (sev === 'High' ? 'important' : 'suggestion');
761
+ await pushRule({
762
+ rule_type: 'SECURITY',
763
+ title: `Security: ${v.category} (${v.severity})`,
764
+ description: `${v.description}\nEvidence: ${JSON.stringify(v.evidence).substring(0, 400)}`,
765
+ severity: mappedSeverity as any
766
+ }).catch(() => { /* silencioso */ });
767
+ }
768
+ } catch (e) {
769
+ console.warn('>>ARCALITY_STATUS>> ⚠️ Falló persistir hallazgos de seguridad en Memoria Colectiva.');
770
+ }
771
+ } else {
772
+ console.log(`>>ARCALITY_STATUS>> 🛡️ Escaneo de seguridad completado. No se encontraron problemas.`);
773
+ }
774
+ } catch (e: any) {
775
+ console.error('Error in Security Scan:', e.message);
776
+ }
777
+
778
+ saveMissionResults();
779
+
780
+ if (!aiMarkedSuccess) {
781
+ throw new Error("Misión no completada o finalizada con errores.");
782
+ }
783
+ });