@arcadialdev/arcality 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/.agents/skills/e2e-testing-expert/SKILL.md +28 -0
  2. package/.agents/skills/frontend-design/LICENSE.txt +177 -0
  3. package/.agents/skills/frontend-design/SKILL.md +42 -0
  4. package/.agents/skills/nodejs-backend-patterns/SKILL.md +639 -0
  5. package/.agents/skills/nodejs-backend-patterns/references/advanced-patterns.md +430 -0
  6. package/.agents/skills/playwright-best-practices/LICENSE.md +7 -0
  7. package/.agents/skills/playwright-best-practices/README.md +147 -0
  8. package/.agents/skills/playwright-best-practices/SKILL.md +303 -0
  9. package/.agents/skills/playwright-best-practices/advanced/authentication-flows.md +360 -0
  10. package/.agents/skills/playwright-best-practices/advanced/authentication.md +871 -0
  11. package/.agents/skills/playwright-best-practices/advanced/clock-mocking.md +364 -0
  12. package/.agents/skills/playwright-best-practices/advanced/mobile-testing.md +409 -0
  13. package/.agents/skills/playwright-best-practices/advanced/multi-context.md +288 -0
  14. package/.agents/skills/playwright-best-practices/advanced/multi-user.md +393 -0
  15. package/.agents/skills/playwright-best-practices/advanced/network-advanced.md +452 -0
  16. package/.agents/skills/playwright-best-practices/advanced/third-party.md +464 -0
  17. package/.agents/skills/playwright-best-practices/architecture/pom-vs-fixtures.md +363 -0
  18. package/.agents/skills/playwright-best-practices/architecture/test-architecture.md +369 -0
  19. package/.agents/skills/playwright-best-practices/architecture/when-to-mock.md +383 -0
  20. package/.agents/skills/playwright-best-practices/browser-apis/browser-apis.md +391 -0
  21. package/.agents/skills/playwright-best-practices/browser-apis/iframes.md +403 -0
  22. package/.agents/skills/playwright-best-practices/browser-apis/service-workers.md +504 -0
  23. package/.agents/skills/playwright-best-practices/browser-apis/websockets.md +403 -0
  24. package/.agents/skills/playwright-best-practices/core/annotations.md +424 -0
  25. package/.agents/skills/playwright-best-practices/core/assertions-waiting.md +361 -0
  26. package/.agents/skills/playwright-best-practices/core/configuration.md +452 -0
  27. package/.agents/skills/playwright-best-practices/core/fixtures-hooks.md +417 -0
  28. package/.agents/skills/playwright-best-practices/core/global-setup.md +434 -0
  29. package/.agents/skills/playwright-best-practices/core/locators.md +242 -0
  30. package/.agents/skills/playwright-best-practices/core/page-object-model.md +315 -0
  31. package/.agents/skills/playwright-best-practices/core/projects-dependencies.md +453 -0
  32. package/.agents/skills/playwright-best-practices/core/test-data.md +492 -0
  33. package/.agents/skills/playwright-best-practices/core/test-suite-structure.md +361 -0
  34. package/.agents/skills/playwright-best-practices/core/test-tags.md +298 -0
  35. package/.agents/skills/playwright-best-practices/debugging/console-errors.md +420 -0
  36. package/.agents/skills/playwright-best-practices/debugging/debugging.md +504 -0
  37. package/.agents/skills/playwright-best-practices/debugging/error-testing.md +360 -0
  38. package/.agents/skills/playwright-best-practices/debugging/flaky-tests.md +496 -0
  39. package/.agents/skills/playwright-best-practices/frameworks/angular.md +530 -0
  40. package/.agents/skills/playwright-best-practices/frameworks/nextjs.md +469 -0
  41. package/.agents/skills/playwright-best-practices/frameworks/react.md +531 -0
  42. package/.agents/skills/playwright-best-practices/frameworks/vue.md +574 -0
  43. package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/ci-cd.md +468 -0
  44. package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/docker.md +283 -0
  45. package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/github-actions.md +546 -0
  46. package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/gitlab.md +397 -0
  47. package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/other-providers.md +521 -0
  48. package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/parallel-sharding.md +371 -0
  49. package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/performance.md +453 -0
  50. package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/reporting.md +424 -0
  51. package/.agents/skills/playwright-best-practices/infrastructure-ci-cd/test-coverage.md +497 -0
  52. package/.agents/skills/playwright-best-practices/testing-patterns/accessibility.md +359 -0
  53. package/.agents/skills/playwright-best-practices/testing-patterns/api-testing.md +719 -0
  54. package/.agents/skills/playwright-best-practices/testing-patterns/browser-extensions.md +506 -0
  55. package/.agents/skills/playwright-best-practices/testing-patterns/canvas-webgl.md +493 -0
  56. package/.agents/skills/playwright-best-practices/testing-patterns/component-testing.md +500 -0
  57. package/.agents/skills/playwright-best-practices/testing-patterns/drag-drop.md +576 -0
  58. package/.agents/skills/playwright-best-practices/testing-patterns/electron.md +509 -0
  59. package/.agents/skills/playwright-best-practices/testing-patterns/file-operations.md +377 -0
  60. package/.agents/skills/playwright-best-practices/testing-patterns/file-upload-download.md +562 -0
  61. package/.agents/skills/playwright-best-practices/testing-patterns/forms-validation.md +561 -0
  62. package/.agents/skills/playwright-best-practices/testing-patterns/graphql-testing.md +331 -0
  63. package/.agents/skills/playwright-best-practices/testing-patterns/i18n.md +508 -0
  64. package/.agents/skills/playwright-best-practices/testing-patterns/performance-testing.md +476 -0
  65. package/.agents/skills/playwright-best-practices/testing-patterns/security-testing.md +430 -0
  66. package/.agents/skills/playwright-best-practices/testing-patterns/visual-regression.md +634 -0
  67. package/.env.example +21 -0
  68. package/README.md +30 -0
  69. package/bin/arcality.mjs +86 -0
  70. package/package.json +66 -0
  71. package/playwright.config.ts +12 -0
  72. package/scripts/cleanup-qmsdev.mjs +63 -0
  73. package/scripts/discover-view.mjs +52 -0
  74. package/scripts/extract-view.mjs +64 -0
  75. package/scripts/gen-and-run.mjs +838 -0
  76. package/scripts/init.mjs +290 -0
  77. package/scripts/migrate-to-central-out.mjs +157 -0
  78. package/scripts/postinstall.mjs +63 -0
  79. package/scripts/rebrand-report.mjs +241 -0
  80. package/scripts/setup.mjs +166 -0
  81. package/src/KnowledgeService.ts +239 -0
  82. package/src/arcalityClient.mjs +266 -0
  83. package/src/configLoader.mjs +179 -0
  84. package/src/configManager.mjs +172 -0
  85. package/src/consoleBanner.ts +32 -0
  86. package/src/envSetup.ts +205 -0
  87. package/src/index.ts +25 -0
  88. package/src/projectInspector.ts +42 -0
  89. package/src/services/collectiveMemoryService.ts +178 -0
  90. package/src/testRunner.ts +201 -0
  91. package/tests/_helpers/ArcalityReporter.ts +490 -0
  92. package/tests/_helpers/agentic-runner.spec.ts +741 -0
  93. package/tests/_helpers/ai-agent-helper.ts +1573 -0
  94. package/tests/_helpers/discover-view.spec.ts +238 -0
  95. package/tests/_helpers/extract-view.spec.ts +118 -0
  96. package/tests/_helpers/qa-tools.ts +333 -0
  97. package/tests/_helpers/smart-action.spec.ts +1458 -0
@@ -0,0 +1,1458 @@
1
+
2
+ import { test, expect, Page, Locator } from '@playwright/test';
3
+ import 'dotenv/config';
4
+
5
+ /**
6
+ * ✅ Detectores robustos de overlays REALES (no incluye [data-overlay-container] porque en tu portal existe siempre)
7
+ */
8
+ const OVERLAY_VISIBLE = [
9
+ // Modales / dialogs
10
+ '[role="dialog"][aria-modal="true"]:visible',
11
+ '[role="dialog"]:visible',
12
+
13
+ // Popovers
14
+ '.heroui-popover:visible',
15
+ '.nextui-popover:visible',
16
+
17
+ // Listbox (autocompletes)
18
+ '[role="listbox"]:visible',
19
+ '.heroui-listbox:visible',
20
+
21
+ // Backdrops genéricos
22
+ '[data-overlay]:visible',
23
+ '[data-backdrop]:visible',
24
+ ].join(', ');
25
+
26
+ const OVERLAY_PORTAL = '[data-overlay-container]';
27
+
28
+ const SEARCH_INPUT_VISIBLE = 'input[type="search"]:visible, input[type="text"]:visible';
29
+ const LISTBOX_VISIBLE = '[role="listbox"]:visible, .heroui-listbox:visible';
30
+ const OPTION_VISIBLE =
31
+ '[role="option"]:visible, li:visible, .heroui-listbox-item:visible, .heroui-select-option:visible';
32
+
33
+ /**
34
+ * Señales (en portal) que suelen aparecer cuando un SelectorModal/Modal custom está abierto.
35
+ * OJO: el portal existe siempre; lo que cambia es su contenido visible.
36
+ */
37
+ const PORTAL_SIGNALS_SELECTORS = [
38
+ '[role="dialog"]',
39
+ '[aria-modal="true"]',
40
+ '.modal-content',
41
+ '.heroui-modal',
42
+ '.nextui-modal',
43
+ '.heroui-popover',
44
+ '.nextui-popover',
45
+ '[data-overlay]',
46
+ '[data-backdrop]',
47
+ '[role="listbox"]',
48
+ '.heroui-listbox',
49
+ 'input[type="search"]',
50
+ 'input[type="text"]',
51
+ ].join(', ');
52
+
53
+ const PORTAL_OPTION_SELECTORS = [
54
+ '[role="option"]',
55
+ '.heroui-listbox-item',
56
+ '.heroui-select-option',
57
+ '[data-key]',
58
+ 'li',
59
+ '[role="row"]',
60
+ '[role="gridcell"]',
61
+ 'div[role="button"]',
62
+ ].join(', ');
63
+
64
+ /**
65
+ * ✅ Cierra overlays reales sin tumbar el wizard si queda algo raro.
66
+ */
67
+ async function ensureNoOverlay(page: Page) {
68
+ const overlay = page.locator(OVERLAY_VISIBLE);
69
+
70
+ if (!(await overlay.count())) return;
71
+
72
+ // Intento 1: Escape
73
+ await page.keyboard.press('Escape').catch(() => { });
74
+ await page.waitForTimeout(150);
75
+ if (!(await overlay.count())) return;
76
+
77
+ // Intento 2: click fuera
78
+ await page.mouse.click(5, 5).catch(() => { });
79
+ await page.waitForTimeout(150);
80
+ if (!(await overlay.count())) return;
81
+
82
+ // Intento 3: botón cerrar si existe
83
+ const closeBtn = page
84
+ .locator(
85
+ `${OVERLAY_VISIBLE} button:visible[aria-label*="cerr" i], ${OVERLAY_VISIBLE} button:visible[aria-label*="close" i], ${OVERLAY_VISIBLE} button:visible:has-text("×")`
86
+ )
87
+ .first();
88
+
89
+ if (await closeBtn.count()) {
90
+ await closeBtn.click({ force: true });
91
+ await page.waitForTimeout(200);
92
+ }
93
+
94
+ if (await overlay.count()) {
95
+ console.log(' ⚠️ Overlay sigue visible; continúo para no bloquear el wizard.');
96
+ }
97
+ }
98
+
99
+ /**
100
+ * Cuenta cuántas señales VISIBLES hay dentro del portal persistente.
101
+ * Esto es CLAVE para tu ZoneSelector: el modal se monta en el portal, no necesariamente como dialog estándar.
102
+ */
103
+ async function countPortalSignals(page: Page): Promise<number> {
104
+ const portal = page.locator(OVERLAY_PORTAL).first();
105
+ if (!(await portal.count())) return 0;
106
+
107
+ return await portal.evaluate((root) => {
108
+ const isVisible = (el: Element) => {
109
+ const st = getComputedStyle(el as HTMLElement);
110
+ if (!st) return false;
111
+ if (st.display === 'none' || st.visibility === 'hidden' || Number(st.opacity) === 0) return false;
112
+ const r = (el as HTMLElement).getBoundingClientRect();
113
+ return r.width > 0 && r.height > 0;
114
+ };
115
+
116
+ const selectors = [
117
+ '[role="dialog"]',
118
+ '[aria-modal="true"]',
119
+ '.modal-content',
120
+ '.heroui-modal',
121
+ '.nextui-modal',
122
+ '.heroui-popover',
123
+ '.nextui-popover',
124
+ '[data-overlay]',
125
+ '[data-backdrop]',
126
+ '[role="listbox"]',
127
+ '.heroui-listbox',
128
+ 'input[type="search"]',
129
+ 'input[type="text"]',
130
+ ].join(',');
131
+
132
+ const nodes = Array.from(root.querySelectorAll(selectors));
133
+ return nodes.filter(isVisible).length;
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Espera a que el portal cambie (apertura modal) sin depender de role=dialog.
139
+ * - beforeSignals: baseline previo al click
140
+ * - Consideramos “abierto” si señales aumentan al menos +1.
141
+ */
142
+ async function waitPortalSignalsIncrease(page: Page, beforeSignals: number, timeout = 12000) {
143
+ await page.waitForFunction(
144
+ (arg: { before: number; selectors: string }) => {
145
+ const root = document.querySelector('[data-overlay-container]');
146
+ if (!root) return false;
147
+
148
+ const isVisible = (el: Element) => {
149
+ const st = getComputedStyle(el as HTMLElement);
150
+ if (!st) return false;
151
+ if (st.display === 'none' || st.visibility === 'hidden' || Number(st.opacity) === 0) return false;
152
+ const r = (el as HTMLElement).getBoundingClientRect();
153
+ return r.width > 0 && r.height > 0;
154
+ };
155
+
156
+ const nodes = Array.from(root.querySelectorAll(arg.selectors));
157
+ const visibleCount = nodes.filter(isVisible).length;
158
+
159
+ // abierto si sube (aunque sea poquito)
160
+ return visibleCount > arg.before;
161
+ },
162
+ { before: beforeSignals, selectors: PORTAL_SIGNALS_SELECTORS },
163
+ { timeout }
164
+ );
165
+ }
166
+
167
+ /**
168
+ * Espera a que el portal “vuelva” cerca del baseline (cierre modal).
169
+ * No exigimos 0 porque el portal puede tener otras cosas; sólo volvemos a <= baseline.
170
+ */
171
+ async function waitPortalSignalsBackTo(page: Page, baseline: number, timeout = 8000) {
172
+ await page
173
+ .waitForFunction(
174
+ (arg: { baseline: number; selectors: string }) => {
175
+ const root = document.querySelector('[data-overlay-container]');
176
+ if (!root) return true;
177
+
178
+ const isVisible = (el: Element) => {
179
+ const st = getComputedStyle(el as HTMLElement);
180
+ if (!st) return false;
181
+ if (st.display === 'none' || st.visibility === 'hidden' || Number(st.opacity) === 0) return false;
182
+ const r = (el as HTMLElement).getBoundingClientRect();
183
+ return r.width > 0 && r.height > 0;
184
+ };
185
+
186
+ const nodes = Array.from(root.querySelectorAll(arg.selectors));
187
+ const visibleCount = nodes.filter(isVisible).length;
188
+
189
+ return visibleCount <= arg.baseline;
190
+ },
191
+ { baseline, selectors: PORTAL_SIGNALS_SELECTORS },
192
+ { timeout }
193
+ )
194
+ .catch(() => {
195
+ // No tronamos el wizard si el portal no baja perfecto; solo avisamos.
196
+ });
197
+ }
198
+
199
+ /**
200
+ * Encuentra un “root” útil dentro del portal para buscar input/opciones.
201
+ * - Preferimos un contenedor más interno que tenga search/listbox/opciones.
202
+ * - Si no, usamos el portal completo (acota para no agarrar campos del form).
203
+ */
204
+ async function getPortalRoot(page: Page): Promise<Locator> {
205
+ const portal = page.locator(OVERLAY_PORTAL).first();
206
+ if (!(await portal.count())) return page.locator('body');
207
+
208
+ // 1. Preferimos el diálogo completo o sección del modal (contiene todo)
209
+ const dialog = portal.locator('section[role="dialog"], [role="dialog"]').filter({ visible: true }).last();
210
+ if (await dialog.count()) return dialog;
211
+
212
+ // 2. Si no, el contenedor de la lista
213
+ const listContainer = portal.locator('[aria-label*="options list"], [role="listbox"]').filter({ visible: true }).last();
214
+ if (await listContainer.count()) return listContainer;
215
+
216
+ return portal;
217
+ }
218
+
219
+ /**
220
+ * MODAL CUSTOM (Zona / SelectorModal):
221
+ * - click al trigger (role=button)
222
+ * - espera aumento de señales en portal
223
+ * - ESCRIBE "a" en el search global del modal para activar filtrado
224
+ * - selecciona la primera opción visible dentro del portal
225
+ * - intenta cerrar y espera volver al baseline
226
+ */
227
+ async function selectFirstFromZoneSelectorModal(page: Page, trigger: Locator, label: string) {
228
+ await ensureNoOverlay(page);
229
+ const baseline = await countPortalSignals(page);
230
+
231
+ // 1. Abrir modal
232
+ console.log(` 🖱️ Abriendo selector para "${label}"...`);
233
+ await trigger.click({ force: true });
234
+
235
+ // 2. Esperar a que el modal esté presente
236
+ await waitPortalSignalsIncrease(page, baseline, 8000).catch(() => { });
237
+ const modal = page.locator('section[role="dialog"]').filter({ visible: true }).last();
238
+
239
+ // 3. Buscar y llenar el input de búsqueda (basado en tu HTML exacto)
240
+ const searchInput = modal.locator('input[aria-label*="Search" i], input[data-slot="input"]').first();
241
+ try {
242
+ await searchInput.waitFor({ state: 'visible', timeout: 5000 });
243
+ await searchInput.click({ force: true });
244
+ await searchInput.fill('a'); // ACTIVADOR CLAVE
245
+ await page.waitForTimeout(1200); // Esperar filtrado de React
246
+ } catch (e) {
247
+ console.log(` ⚠️ No se pudo interactuar con el buscador: ${e instanceof Error ? e.message : String(e)}`);
248
+ }
249
+
250
+ // 4. Seleccionar la opción DENTRO del contenedor de lista
251
+ // IMPORTANTE: Buscamos específicamente en el div con el aria-label de la lista
252
+ const listContainer = modal.locator('[aria-label*="options list"]').first();
253
+ const options = listContainer.locator('[role="button"]').filter({ visible: true }).filter({ hasText: /.+/ });
254
+
255
+ // Fallback por si el aria-label falla
256
+ let target = options.first();
257
+ if (!(await target.count())) {
258
+ target = modal.locator('div[role="button"]').filter({ visible: true }).filter({ hasNotText: /Close|Refresh|Refresh-CW/i }).first();
259
+ }
260
+
261
+ await target.waitFor({ state: 'visible', timeout: 12000 });
262
+ const optionText = (await target.innerText().catch(() => '')).trim().split('\n')[0];
263
+ console.log(` ✅ [${label}] Seleccionando opción: ${optionText}`);
264
+
265
+ // Click ultra-robusto
266
+ await target.click({ force: true, timeout: 5000 }).catch(async () => {
267
+ const box = await target.boundingBox();
268
+ if (box) await page.mouse.click(box.x + box.width / 2, box.y + box.height / 2);
269
+ });
270
+
271
+ // 5. Cerrar y asegurar retorno
272
+ await page.waitForTimeout(300);
273
+ await page.keyboard.press('Escape').catch(() => { });
274
+ await page.mouse.click(5, 5).catch(() => { });
275
+ await waitPortalSignalsBackTo(page, baseline, 5000);
276
+ await ensureNoOverlay(page);
277
+ }
278
+
279
+ /**
280
+ * ✅ AUTOCOMPLETE genérico:
281
+ * - si es combobox, escribe 'a', espera listbox, selecciona primera opción
282
+ * - si no aparece listbox, NO falla: regresa false
283
+ */
284
+ async function tryHandleAutocomplete(page: Page, combobox: Locator, label: string): Promise<boolean> {
285
+ await ensureNoOverlay(page);
286
+
287
+ await combobox.scrollIntoViewIfNeeded().catch(() => { });
288
+ await combobox.click({ force: true });
289
+ await combobox.fill('a');
290
+
291
+ const listbox = page.locator(LISTBOX_VISIBLE).last();
292
+ const appeared = await listbox.isVisible().catch(() => false);
293
+
294
+ if (!appeared) return false;
295
+
296
+ const opt = listbox.locator(OPTION_VISIBLE).filter({ hasText: /.+/ }).first();
297
+ await expect(opt).toBeVisible({ timeout: 8000 });
298
+
299
+ const optionText = (await opt.innerText().catch(() => '')).trim();
300
+ console.log(` ✅ [${label}] Seleccionando (autocomplete): ${optionText || '(sin texto)'}`);
301
+ await opt.click({ force: true });
302
+
303
+ await page.waitForTimeout(150);
304
+ return true;
305
+ }
306
+
307
+ /**
308
+ * ✅ DETECTOR GENÉRICO DE COMPONENTES CUSTOM (SelectorModal, Autocomplete, ZoneSelector, etc.)
309
+ * Busca elementos que actúan como disparadores (triggers) y que aún no han sido seleccionados.
310
+ */
311
+ async function fillCustomPickersIfPresent(page: Page, formScope: Locator, filledFieldsIds: Set<string>): Promise<number> {
312
+ // Buscamos cualquier botón o trigger que contenga texto de "Selecciona" o "Elegir"
313
+ const potentialTriggers = await formScope.locator('div[role="button"]:visible, [data-slot="trigger"]:visible, .heroui-autocomplete-input-wrapper:visible').all();
314
+ let count = 0;
315
+
316
+ for (const trigger of potentialTriggers) {
317
+ const text = await trigger.innerText().catch(() => '');
318
+ // El patrón es: Contiene "Selecciona" o "Elegir" (indica que es un picker vacío)
319
+ if (/selecciona|elegir|select|uno/i.test(text)) {
320
+ const label = await getFieldLabel(trigger);
321
+ const id = await getStableFieldId(trigger);
322
+
323
+ if (filledFieldsIds.has(id)) continue;
324
+
325
+ console.log(` - [custom/generic] "${label}"`);
326
+
327
+ // Esperar si el componente está en estado Loading (común en HeroUI)
328
+ const loader = trigger.locator('[data-slot="spinner"], .animate-spin').first();
329
+ if (await loader.count()) {
330
+ console.log(` ⌛ Esperando carga de "${label}"...`);
331
+ await expect(loader).not.toBeVisible({ timeout: 10000 }).catch(() => { });
332
+ }
333
+
334
+ try {
335
+ // Usamos tu lógica robusta de portal para seleccionar
336
+ await selectFirstFromZoneSelectorModal(page, trigger, label);
337
+ filledFieldsIds.add(id);
338
+ count++;
339
+ } catch (e) {
340
+ console.log(` ⚠️ Fallo en picker custom "${label}": ${e instanceof Error ? e.message : String(e)}`);
341
+ }
342
+ }
343
+ }
344
+ return count;
345
+ }
346
+
347
+ /**
348
+ * ✅ MANEJADOR DE CARGA DE ARCHIVOS (DropImage / File inputs)
349
+ * Detecta inputs de tipo file y sube una imagen dummy para cubrir validaciones.
350
+ */
351
+ /**
352
+ * ✅ MANEJADOR DE CARGA DE ARCHIVOS (DropImage / File inputs)
353
+ * Detecta inputs de tipo file y usa archivos REALES si vienen en el prompt, o dummy si no.
354
+ */
355
+ async function handleFileUploads(page: Page, formScope: Locator, filledFieldsIds: Set<string>) {
356
+ // 1. Parsear contexto de archivos reales del Prompt
357
+ const prompt = process.env.SMART_PROMPT || '';
358
+ const fileMap: Record<string, string> = {}; // "nombre.png" -> "C:\Path\Absoluto\nombre.png"
359
+
360
+ const contextMatch = prompt.match(/AVAILABLE FILES[\s\S]*$/);
361
+ if (contextMatch) {
362
+ const lines = contextMatch[0].split('\n');
363
+ for (const line of lines) {
364
+ // Regex para extraer: File "nombre.ext" -> Use path: "Ruta"
365
+ const match = line.match(/File "([^"]+)" -> Use path: "([^"]+)"/);
366
+ if (match) {
367
+ fileMap[match[1].toLowerCase()] = match[2]; // Mapa insensible a mayúsculas
368
+ }
369
+ }
370
+ }
371
+ const availableFiles = Object.values(fileMap); // Lista de rutas absolutas disponibles
372
+
373
+ // Función auxiliar para subir archivo
374
+ const uploadFileToInput = async (input: Locator, label: string) => {
375
+ try {
376
+ // Estrategia:
377
+ // 1. Si hay una imagen en el mapa que coincida con el label del input (ej: input "Foto" -> "foto.png"), usarla.
378
+ // 2. Si no hay coincidencia exacta de nombre, usar la PRIMERA imagen disponible del YAML.
379
+ // 3. Si no hay imágenes en YAML, usar dummy 1x1 buffer.
380
+
381
+ let fileToUse: { name: string, mimeType: string, buffer?: Buffer, path?: string } | null = null;
382
+
383
+ // Buscar por coincidencia parcial de nombre en el label (ej: label="Subir Logo", file="logo.png")
384
+ const matchingFileKey = Object.keys(fileMap).find(k => label.toLowerCase().includes(k.split('.')[0]));
385
+
386
+ if (matchingFileKey) {
387
+ console.log(` 📂 Usando archivo coincidente por nombre para "${label}": ${matchingFileKey}`);
388
+ await input.setInputFiles(fileMap[matchingFileKey]);
389
+ return;
390
+ }
391
+
392
+ // Fallback: Usar el primer archivo disponible del YAML (si existe)
393
+ if (availableFiles.length > 0) {
394
+ console.log(` 📂 Usando primer archivo disponible para "${label}": ${availableFiles[0]}`);
395
+ await input.setInputFiles(availableFiles[0]);
396
+ return;
397
+ }
398
+
399
+ // Fallback final: Dummy Buffer
400
+ console.log(` 🔹 Usando imagen dummy generada para "${label}" (no se provee archivo real)`);
401
+ await input.setInputFiles({
402
+ name: 'test-image.png',
403
+ mimeType: 'image/png',
404
+ buffer: Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==', 'base64')
405
+ });
406
+
407
+ } catch (e) {
408
+ console.log(` ⚠️ No se pudo cargar imagen en "${label}": ${e instanceof Error ? e.message : String(e)}`);
409
+ }
410
+ };
411
+
412
+ // 1. Buscamos contenedores que parezcan DropImage por su texto (Basado en tu imagen)
413
+ const markers = await formScope.locator('text=/arrastra|explora|formatos|drop|browse/i').all();
414
+
415
+ for (const marker of markers) {
416
+ const container = marker.locator('xpath=./ancestor::div[contains(@class, "border-dashed") or .//input[@type="file"]]').first();
417
+ if (!(await container.count())) continue;
418
+
419
+ const fileInput = container.locator('input[type="file"]').first();
420
+ if (!(await fileInput.count())) continue;
421
+
422
+ const id = await getStableFieldId(fileInput);
423
+ if (filledFieldsIds.has(id)) continue;
424
+
425
+ const label = await getFieldLabel(fileInput);
426
+ console.log(` - [file/image] "${label}"`);
427
+
428
+ await uploadFileToInput(fileInput, label);
429
+ filledFieldsIds.add(id);
430
+ await page.waitForTimeout(1000);
431
+ }
432
+
433
+ // 2. Fallback para inputs de archivo estándar
434
+ const allFileInputs = await formScope.locator('input[type="file"]').filter({ visible: false }).all();
435
+ // Nota: filter visible:false porque los inputs file suelen estar ocultos. Si Playwright no los encuentra con .all(), usamos hidden.
436
+
437
+ for (const input of allFileInputs) {
438
+ const id = await getStableFieldId(input);
439
+ if (filledFieldsIds.has(id)) continue;
440
+
441
+ const isParentVisible = await input.evaluate((el) => {
442
+ const p = el.parentElement;
443
+ return p ? (p.getClientRects().length > 0) : false;
444
+ });
445
+ if (!isParentVisible) continue;
446
+
447
+ console.log(` - [file-generic] Input estándar detectado`);
448
+ await uploadFileToInput(input, 'Generic Input');
449
+ filledFieldsIds.add(id);
450
+ }
451
+ }
452
+
453
+ async function getStableFieldId(field: Locator): Promise<string> {
454
+ const idAttr = await field.getAttribute('id');
455
+ if (idAttr) return `id:${idAttr}`;
456
+
457
+ const box = await field.evaluate((el) => {
458
+ const r = el.getBoundingClientRect();
459
+ return { top: Math.round(r.top), left: Math.round(r.left) };
460
+ });
461
+ return `pos:${box.top}:${box.left}`;
462
+ }
463
+
464
+ async function getFieldLabel(field: Locator): Promise<string> {
465
+ return (
466
+ (await field
467
+ .evaluate((el) => {
468
+ const parent = el.closest('[data-slot="base"], [data-slot="main-wrapper"], fieldset > div, .relative');
469
+ const innerLabel = parent?.querySelector('[data-slot="label"], label, .heroui-label, .nextui-label, .label');
470
+ if (innerLabel?.textContent) return innerLabel.textContent.trim();
471
+
472
+ const p = el.getAttribute('placeholder') || el.getAttribute('name') || el.getAttribute('aria-label');
473
+ if (p) return p;
474
+
475
+ return (el.textContent || '').split('\n')[0].trim().slice(0, 50) || 'campo';
476
+ })
477
+ .catch(() => 'campo')) || 'campo'
478
+ );
479
+ }
480
+
481
+ async function isFlowButton(field: Locator): Promise<boolean> {
482
+ const txt = ((await field.innerText().catch(() => '')) || '').toLowerCase();
483
+ return (
484
+ txt.includes('siguiente') ||
485
+ txt.includes('next') ||
486
+ txt.includes('guardar') ||
487
+ txt.includes('save') ||
488
+ txt.includes('finalizar') ||
489
+ txt.includes('create') ||
490
+ txt.includes('atrás') ||
491
+ txt.includes('back')
492
+ );
493
+ }
494
+
495
+ async function isNavOrHeaderNoise(field: Locator): Promise<boolean> {
496
+ const cls = ((await field.getAttribute('class')) || '').toLowerCase();
497
+ const id = ((await field.getAttribute('id')) || '').toLowerCase();
498
+
499
+ return (
500
+ cls.includes('nav') ||
501
+ cls.includes('menu') ||
502
+ cls.includes('sidebar') ||
503
+ cls.includes('navbar') ||
504
+ cls.includes('header') ||
505
+ id.includes('search') ||
506
+ id.includes('nav')
507
+ );
508
+ }
509
+
510
+ function valueFor(label: string, type: string): string {
511
+ const l = label.toLowerCase();
512
+
513
+ if (type === 'email') return `test.${Date.now()}@example.com`;
514
+ if (type === 'number') return '10';
515
+
516
+ // ✅ Para campos de fecha o datepickers: Siempre fecha FUTURA (hoy + 3 días)
517
+ if (type === 'date' || /fecha|date|nacimiento/i.test(l)) {
518
+ const future = new Date();
519
+ future.setDate(future.getDate() + 3);
520
+ return future.toISOString().split('T')[0];
521
+ }
522
+
523
+ if (/código postal|cp|postal/i.test(l)) return '12345';
524
+ if (/código|code/i.test(l)) return `QA-${Math.floor(Math.random() * 1000)}`;
525
+ if (/latitud/i.test(l)) return '19.4326';
526
+ if (/longitud/i.test(l)) return '-99.1332';
527
+
528
+ return 'Dato Prueba';
529
+ }
530
+
531
+ test('Smart Action Runner', async ({ page }) => {
532
+ test.setTimeout(60000); // Aumento timeout a 60s
533
+ const target = process.env.TARGET_PATH || '/';
534
+ const base = process.env.BASE_URL || '';
535
+ const user = process.env.LOGIN_USER || '';
536
+ const pass = process.env.LOGIN_PASSWORD || '';
537
+
538
+ // 1. Monitor de Red (Centinela)
539
+ // Vigila que no haya errores 403 (Prohibido) o 500+ (Error servidor) en llamadas API
540
+ const networkErrors: string[] = [];
541
+ page.on('response', response => {
542
+ const status = response.status();
543
+ const url = response.url();
544
+
545
+ // Filtramos errores críticos: 403 (Forbidden), 413 (Too Large), 500+ (Server Errors)
546
+ const resourceType = response.request().resourceType();
547
+ if ((status === 403 || status === 413 || status >= 500) && ['fetch', 'xhr', 'document'].includes(resourceType)) {
548
+ let msg = `⚠️ [NETWORK ERROR] ${status} en: ${url}`;
549
+ if (status === 413) msg += ' (El archivo subido es demasiado pesado para el servidor)';
550
+ console.log(msg);
551
+ networkErrors.push(`[${status}] ${url} ${status === 413 ? '(Entity Too Large)' : ''}`);
552
+ }
553
+ });
554
+
555
+ // 1.1 Login
556
+ await page.goto(base + '/login', { waitUntil: 'domcontentloaded' });
557
+ await page.locator('input[type="email"]').fill(user);
558
+ await page.locator('input[type="password"]').fill(pass);
559
+ await page.locator('button[type="submit"]').click();
560
+ await expect(page).not.toHaveURL(/\/login/, { timeout: 15000 });
561
+
562
+ // 2. Ir a la lista (🚫 evitar networkidle)
563
+ console.log('→ Navegando a:', base + target);
564
+ await page.goto(base + target, { waitUntil: 'domcontentloaded' });
565
+ await page.waitForLoadState('load').catch(() => { });
566
+ await page.waitForTimeout(500);
567
+
568
+ // 3. Selección de Acción: ¿Creación, Edición, Desactivación, Filtro, Ordenar o Columnas?
569
+ const prompt = (process.env.SMART_PROMPT || '').toLowerCase();
570
+ const isFilterMode = /filtro|filtrar|buscar|search|filtra /i.test(prompt);
571
+ const isSortMode = /ordenar|ordena |sort|order\s+by/i.test(prompt);
572
+ const isColumnsMode = /columnas?|columns?|selecciona\s+(?:la\s+)?opci[oó]n/i.test(prompt);
573
+ const isEditMode = /\bedit|actualiz|modific|update|cambiar/i.test(prompt);
574
+ const isDeactivateMode = /desactiv|elimin|borr|delete|remove/i.test(prompt);
575
+ // CREACIÓN solo si explícitamente dice crear/nuevo/new/add (no como fallback)
576
+ const isCreateMode = /\bcrear\b|\bnuevo\b|\bnueva\b|\bnew\b|\badd\b|\bagregar\b/i.test(prompt);
577
+
578
+ // Determinar modo (prioridad: Columnas > Ordenar > Filtro > Desactivación > Edición > Creación > Genérico)
579
+ let detectedMode = 'GENÉRICO';
580
+ if (isColumnsMode) detectedMode = 'COLUMNAS';
581
+ else if (isSortMode) detectedMode = 'ORDENAR';
582
+ else if (isFilterMode) detectedMode = 'FILTRO';
583
+ else if (isDeactivateMode) detectedMode = 'DESACTIVACIÓN';
584
+ else if (isEditMode) detectedMode = 'EDICIÓN';
585
+ else if (isCreateMode) detectedMode = 'CREACIÓN';
586
+
587
+ console.log(`🔍 [SMART-MODE] Prompt recibido: "${prompt}"`);
588
+ console.log(`🔍 [SMART-MODE] Modo detectado: ${detectedMode}`);
589
+
590
+ // =============== MODO ORDENAR ===============
591
+ if (isSortMode) {
592
+ console.log('→ Entrando en modo ORDENAR...');
593
+
594
+ // 3.1 Buscar y hacer clic en botón de Ordenar
595
+ const sortBtn = page
596
+ .getByRole('button', { name: /ordenar|sort/i })
597
+ .or(page.locator('button:has-text("Ordenar")'))
598
+ .or(page.locator('button:has-text("Sort")'))
599
+ .filter({ visible: true })
600
+ .first();
601
+
602
+ await expect(sortBtn).toBeVisible({ timeout: 10000 }).catch(() => {
603
+ throw new Error('No se encontró el botón de Ordenar en la vista.');
604
+ });
605
+
606
+ await sortBtn.click();
607
+ console.log('✅ Clic en botón de Ordenar realizado.');
608
+
609
+ // 3.2 Esperar que se abra el menú desplegable
610
+ await page.waitForTimeout(800);
611
+
612
+ // 3.3 Extraer la opción de ordenamiento del prompt
613
+ // Patrones: "ordena por [OPCION]", "ordenar por [OPCION]", "opción de [OPCION]"
614
+ const sortOptionPatterns = [
615
+ /(?:ordena|ordenar)\s+por\s+(?:la\s+)?(?:opci[oó]n\s+(?:de\s+)?)?(.+?)(?:\.|$)/i,
616
+ /opci[oó]n\s+(?:de\s+)?(.+?)(?:\.|$)/i,
617
+ /(?:sort|order)\s+by\s+(.+?)(?:\.|$)/i
618
+ ];
619
+
620
+ let sortOption = '';
621
+ for (const pattern of sortOptionPatterns) {
622
+ const match = prompt.match(pattern);
623
+ if (match && match[1]) {
624
+ sortOption = match[1].trim();
625
+ break;
626
+ }
627
+ }
628
+
629
+ console.log(`📋 Opción de ordenamiento detectada: "${sortOption}"`);
630
+
631
+ if (sortOption) {
632
+ // 3.4 Buscar y hacer clic en la opción del menú
633
+ // El menú usa role="menu" con li[role="menuitem"]
634
+ const menuOption = page
635
+ .locator('[role="menu"] [role="menuitem"]')
636
+ .filter({ hasText: new RegExp(sortOption, 'i') })
637
+ .first()
638
+ .or(page.locator('[role="listbox"] [role="option"]').filter({ hasText: new RegExp(sortOption, 'i') }).first())
639
+ .or(page.locator('li, div').filter({ hasText: new RegExp(`^${sortOption}$`, 'i') }).first());
640
+
641
+ if (await menuOption.isVisible({ timeout: 3000 }).catch(() => false)) {
642
+ await menuOption.click();
643
+ console.log(`✅ Opción "${sortOption}" seleccionada.`);
644
+ } else {
645
+ console.log(`⚠️ No se encontró la opción "${sortOption}" en el menú.`);
646
+ // Listar opciones disponibles para debug
647
+ const options = await page.locator('[role="menu"] [role="menuitem"]').allTextContents();
648
+ console.log(` 📋 Opciones disponibles: ${options.join(', ')}`);
649
+ }
650
+ } else {
651
+ console.log('⚠️ No se especificó opción de ordenamiento en el prompt.');
652
+ }
653
+
654
+ await page.waitForTimeout(500);
655
+ console.log('🎉 Modo ORDENAR completado.');
656
+ return;
657
+ }
658
+
659
+ // =============== MODO COLUMNAS ===============
660
+ if (isColumnsMode) {
661
+ console.log('→ Entrando en modo COLUMNAS...');
662
+
663
+ // 3.1 Buscar y hacer clic en botón de Columnas
664
+ const columnsBtn = page
665
+ .getByRole('button', { name: /columnas?|columns?/i })
666
+ .or(page.locator('button:has-text("Columnas")'))
667
+ .or(page.locator('button:has-text("Columns")'))
668
+ .filter({ visible: true })
669
+ .first();
670
+
671
+ await expect(columnsBtn).toBeVisible({ timeout: 10000 }).catch(() => {
672
+ throw new Error('No se encontró el botón de Columnas en la vista.');
673
+ });
674
+
675
+ await columnsBtn.click();
676
+ console.log('✅ Clic en botón de Columnas realizado.');
677
+
678
+ // 3.2 Esperar que se abra el menú desplegable
679
+ await page.waitForTimeout(800);
680
+
681
+ // 3.3 Extraer las opciones del prompt
682
+ // El patrón clave es: "selecciona la opción de [OPCIONES]" donde OPCIONES viene DESPUÉS de "de"
683
+ let optionsText = '';
684
+
685
+ // Patrón específico: buscar "opción de [X]" o "opciones de [X]"
686
+ const optionMatch = prompt.match(/opci[oó]n(?:es)?\s+(?:de\s+)?([^.]+)/i);
687
+ if (optionMatch && optionMatch[1]) {
688
+ optionsText = optionMatch[1].trim();
689
+ }
690
+
691
+ // Si no encontró con ese patrón, buscar después de "columnas" o "selecciona"
692
+ if (!optionsText) {
693
+ const fallbackMatch = prompt.match(/(?:columnas|selecciona)\s+(.+?)(?:\.|$)/i);
694
+ if (fallbackMatch && fallbackMatch[1]) {
695
+ optionsText = fallbackMatch[1].trim()
696
+ .replace(/^(?:la\s+)?opci[oó]n(?:es)?\s+(?:de\s+)?/i, ''); // Limpiar prefijo si quedó
697
+ }
698
+ }
699
+
700
+ // Separar múltiples opciones por "y", "and", ","
701
+ const optionsList = optionsText
702
+ .split(/\s+y\s+|\s+and\s+|,\s*/)
703
+ .map(o => o.trim().replace(/['"]/g, '')) // Limpiar comillas
704
+ .filter(o => o.length > 0 && o.length < 50); // Filtrar opciones demasiado largas
705
+
706
+ console.log(`📋 Opciones detectadas: [${optionsList.join('], [')}]`);
707
+
708
+ for (const optionName of optionsList) {
709
+ console.log(` → Buscando opción "${optionName}"...`);
710
+
711
+ // Estrategia HeroUI: Las opciones están en spans dentro de li[role="menuitem"]
712
+ const menuOption = page
713
+ .locator('[role="menu"]')
714
+ .locator('li[role="menuitem"]')
715
+ .filter({ hasText: new RegExp(optionName, 'i') })
716
+ .first();
717
+
718
+ if (await menuOption.isVisible({ timeout: 2000 }).catch(() => false)) {
719
+ await menuOption.click();
720
+ console.log(` ✅ Opción "${optionName}" seleccionada.`);
721
+ await page.waitForTimeout(300);
722
+ } else {
723
+ // Fallback 1: Buscar en labels (HeroUI usa labels para checkboxes)
724
+ const labelOption = page.locator(`[role="menu"] label`).filter({ hasText: new RegExp(optionName, 'i') }).first();
725
+ if (await labelOption.isVisible({ timeout: 1000 }).catch(() => false)) {
726
+ await labelOption.click();
727
+ console.log(` ✅ Opción "${optionName}" seleccionada (via label).`);
728
+ await page.waitForTimeout(300);
729
+ } else {
730
+ console.log(` ⚠️ No se encontró la opción "${optionName}" en el menú.`);
731
+ // Debug: listar opciones disponibles
732
+ const availableOptions = await page.locator('[role="menu"] li[role="menuitem"], [role="menu"] label').allTextContents();
733
+ const uniqueOptions = [...new Set(availableOptions.filter(o => o.trim().length > 0))].map(o => o.trim().split('\n')[0]);
734
+ console.log(` 📋 Opciones disponibles (primeros 10): ${uniqueOptions.slice(0, 10).join(', ')}`);
735
+ }
736
+ }
737
+ }
738
+
739
+ if (optionsList.length === 0) {
740
+ console.log('⚠️ No se especificaron opciones en el prompt.');
741
+ const options = await page.locator('[role="menu"] [role="menuitem"], [role="menu"] label').allTextContents();
742
+ console.log(` 📋 Opciones disponibles: ${options.map(o => o.trim().split('\n')[0]).join(', ')}`);
743
+ }
744
+
745
+ await page.waitForTimeout(500);
746
+ console.log('🎉 Modo COLUMNAS completado.');
747
+ return;
748
+ }
749
+
750
+ // =============== MODO FILTRO ===============
751
+ if (isFilterMode) {
752
+ console.log('→ Entrando en modo FILTRO...');
753
+
754
+ // 3.1 Buscar y hacer clic en botón de Filtros
755
+ const filterBtn = page
756
+ .getByRole('button', { name: /filtro|filter/i })
757
+ .or(page.locator('button:has-text("Filtros")'))
758
+ .or(page.locator('button:has-text("Filter")'))
759
+ .filter({ visible: true })
760
+ .first();
761
+
762
+ await expect(filterBtn).toBeVisible({ timeout: 10000 }).catch(() => {
763
+ throw new Error('No se encontró el botón de Filtros en la vista.');
764
+ });
765
+
766
+ await filterBtn.click();
767
+ console.log('✅ Clic en botón de Filtros realizado.');
768
+
769
+ // 3.2 Esperar que se abra el panel lateral (side panel)
770
+ await page.waitForTimeout(800);
771
+ const filterPanel = page
772
+ .locator('[role="dialog"], [data-slot="drawer"], aside, .drawer, .side-panel, .filter-panel, div[class*="drawer"], div[class*="panel"]')
773
+ .filter({ has: page.locator('input, select, button') })
774
+ .first();
775
+
776
+ await expect(filterPanel).toBeVisible({ timeout: 10000 }).catch(() => {
777
+ console.log('⚠️ No se detectó panel lateral explícito, continuando con la página completa...');
778
+ });
779
+
780
+ // 3.3 Extraer instrucciones del prompt usando CAMPOS CONOCIDOS
781
+ // En lugar de regex complejos, buscamos nombres de campos conocidos y extraemos valores cercanos
782
+
783
+ const knownFields = [
784
+ 'nombre completo', 'teléfono', 'telefono', 'correo electrónico', 'correo',
785
+ 'email', 'código de referido', 'codigo de referido', 'código', 'codigo',
786
+ 'fecha', 'nombre', 'descripción', 'descripcion', 'título', 'titulo'
787
+ ];
788
+
789
+ const fieldInstructions: Array<{ field: string; value: string }> = [];
790
+
791
+ for (const fieldName of knownFields) {
792
+ // Patrón 1: "campo : valor" o "campo = valor"
793
+ const colonPattern = new RegExp(`${fieldName}\\s*[:=]\\s*([\\w\\d@._-]+)`, 'i');
794
+ const colonMatch = prompt.match(colonPattern);
795
+ if (colonMatch && colonMatch[1]) {
796
+ const value = colonMatch[1].trim();
797
+ if (value && !fieldInstructions.some(f => f.field.toLowerCase() === fieldName.toLowerCase())) {
798
+ fieldInstructions.push({ field: fieldName, value });
799
+ }
800
+ continue;
801
+ }
802
+
803
+ // Patrón 2: "valor en el input de campo"
804
+ const inFieldPattern = new RegExp(`([\\w\\d\\s@._-]+?)\\s+en\\s+(?:el\\s+)?(?:input|campo)\\s+(?:de\\s+)?${fieldName}`, 'i');
805
+ const inFieldMatch = prompt.match(inFieldPattern);
806
+ if (inFieldMatch && inFieldMatch[1]) {
807
+ let value = inFieldMatch[1].trim();
808
+ // Limpiar prefijos de instrucción
809
+ value = value.replace(/^.*(?:escribe|poner|ingresa|coloca)\s+(?:el\s+)?(?:nombre\s+de\s+)?/i, '').trim();
810
+ if (value && value.length < 100 && !fieldInstructions.some(f => f.field.toLowerCase() === fieldName.toLowerCase())) {
811
+ fieldInstructions.push({ field: fieldName, value });
812
+ }
813
+ }
814
+ }
815
+
816
+ // Si no encontramos nada con campos conocidos, intentar patrón genérico simple
817
+ if (fieldInstructions.length === 0) {
818
+ const simplePattern = /escribe\s+(?:el\s+)?(?:nombre\s+de\s+)?(.+?)\s+en\s+(?:el\s+)?(?:input|campo)\s+(?:de\s+)?(.+?)(?:\.|$)/i;
819
+ const simpleMatch = prompt.match(simplePattern);
820
+ if (simpleMatch && simpleMatch[1] && simpleMatch[2]) {
821
+ fieldInstructions.push({
822
+ field: simpleMatch[2].trim(),
823
+ value: simpleMatch[1].trim()
824
+ });
825
+ }
826
+ }
827
+
828
+ console.log('📝 Instrucciones de llenado detectadas:', fieldInstructions);
829
+
830
+ // 3.4 Llenar los campos de filtro
831
+ // Detectamos el scope (panel de filtro o página completa)
832
+ const panelVisible = await filterPanel.isVisible().catch(() => false);
833
+ const scope = panelVisible ? filterPanel : page;
834
+ console.log(` 📍 Scope de búsqueda: ${panelVisible ? 'Panel de Filtros' : 'Página completa'}`);
835
+
836
+ for (const instr of fieldInstructions) {
837
+ console.log(` → Buscando campo "${instr.field}" para escribir "${instr.value}"...`);
838
+
839
+ // Estrategia multi-framework para encontrar inputs:
840
+ // 1. HeroUI/NextUI: [data-slot="base"] que contenga label con texto -> buscar input[data-slot="input"] dentro
841
+ // 2. Standard: label[for] + input con ese id
842
+ // 3. Fallback: getByLabel de Playwright
843
+
844
+ let fieldFound = false;
845
+
846
+ // Estrategia 1: HeroUI/NextUI pattern (CORREGIDO)
847
+ // Buscamos contenedor [data-slot="base"] que contenga el texto del campo (case-insensitive)
848
+ const heroContainer = scope.locator('[data-slot="base"]').filter({
849
+ hasText: new RegExp(instr.field, 'i')
850
+ }).first();
851
+
852
+ const heroContainerCount = await scope.locator('[data-slot="base"]').count();
853
+ console.log(` 📊 Contenedores [data-slot="base"] encontrados: ${heroContainerCount}`);
854
+
855
+ if (await heroContainer.isVisible({ timeout: 2000 }).catch(() => false)) {
856
+ console.log(` 🎯 Contenedor HeroUI encontrado para "${instr.field}"`);
857
+ const heroInput = heroContainer.locator('input[data-slot="input"], textarea[data-slot="input"], input, textarea').first();
858
+ if (await heroInput.isVisible({ timeout: 1000 }).catch(() => false)) {
859
+ await heroInput.fill(instr.value);
860
+ console.log(` ✅ [HeroUI] Campo "${instr.field}" llenado con "${instr.value}"`);
861
+ fieldFound = true;
862
+ } else {
863
+ console.log(` ⚠️ Input no encontrado dentro del contenedor`);
864
+ }
865
+ } else {
866
+ console.log(` ⚠️ No se encontró contenedor HeroUI con texto "${instr.field}"`);
867
+ }
868
+
869
+ // Estrategia 2: Buscar por role="dialog" (el panel tiene role="dialog")
870
+ if (!fieldFound) {
871
+ const dialogPanel = page.locator('[role="dialog"]').filter({ hasText: 'Filtros' }).first();
872
+ const dialogInput = dialogPanel.locator('[data-slot="base"]').filter({
873
+ hasText: new RegExp(instr.field, 'i')
874
+ }).locator('input, textarea').first();
875
+
876
+ if (await dialogInput.isVisible({ timeout: 1000 }).catch(() => false)) {
877
+ await dialogInput.fill(instr.value);
878
+ console.log(` ✅ [Dialog] Campo "${instr.field}" llenado con "${instr.value}"`);
879
+ fieldFound = true;
880
+ }
881
+ }
882
+
883
+ // Estrategia 3: Selector estándar (placeholder, aria-label, getByLabel)
884
+ if (!fieldFound) {
885
+ const standardLocators = [
886
+ page.locator(`input[placeholder*="${instr.field}" i]`),
887
+ page.locator(`input[aria-label*="${instr.field}" i]`),
888
+ page.locator(`textarea[placeholder*="${instr.field}" i]`),
889
+ page.getByLabel(new RegExp(instr.field, 'i')),
890
+ ];
891
+
892
+ for (const loc of standardLocators) {
893
+ if (await loc.first().isVisible({ timeout: 500 }).catch(() => false)) {
894
+ await loc.first().fill(instr.value);
895
+ console.log(` ✅ [Standard] Campo "${instr.field}" llenado con "${instr.value}"`);
896
+ fieldFound = true;
897
+ break;
898
+ }
899
+ }
900
+ }
901
+
902
+ if (!fieldFound) {
903
+ console.log(` ⚠️ No se encontró campo para "${instr.field}"`);
904
+ }
905
+ }
906
+
907
+ // 3.5 Buscar y hacer clic en botón Aplicar/Buscar (si existe)
908
+ await page.waitForTimeout(500);
909
+ const applyBtn = scope
910
+ .getByRole('button', { name: /aplicar|apply|buscar|search|filtrar|filter$/i })
911
+ .filter({ visible: true })
912
+ .first();
913
+
914
+ if (await applyBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
915
+ await applyBtn.click();
916
+ console.log('✅ Filtros aplicados.');
917
+ } else {
918
+ console.log('ℹ️ No se encontró botón de aplicar (posiblemente filtros automáticos).');
919
+ }
920
+
921
+ // 3.6 Esperar resultados
922
+ await page.waitForTimeout(1000);
923
+ console.log('🎉 Modo FILTRO completado.');
924
+ return;
925
+ }
926
+
927
+ let actionBtn;
928
+ if (isDeactivateMode) {
929
+ console.log('→ Buscando botón de DESACTIVACIÓN/BORRADO (Delete/Remove)...');
930
+ actionBtn = page
931
+ .getByRole('button', { name: /desactiv|elimin|borr|delete|remove|power/i })
932
+ .or(page.locator('button[aria-label*="delete" i], button[aria-label*="eliminar" i], button[aria-label*="borrar" i], button[aria-label*="power" i]'))
933
+ .or(page.locator('.lucide-trash, .lucide-trash-2, .lucide-ban, .lucide-power-off, [data-slot="delete-icon"]').locator('..'))
934
+ .filter({ visible: true })
935
+ .first();
936
+ } else if (/(^|\W)(descarg|export|bajar|get|obtener|download)($|\W)/i.test(prompt) && !/(\\|\/)downloads(\\|\/)/i.test(prompt)) {
937
+ console.log('→ Buscando botón de DESCARGA/EXPORTACIÓN...');
938
+ actionBtn = page
939
+ .getByRole('button', { name: /descarg|export|bajar|get|obtener|download|excel|pdf|csv/i })
940
+ .or(page.getByRole('link', { name: /descarg|export|bajar|get|obtener|download|excel|pdf|csv/i }))
941
+ .or(page.locator('button[aria-label*="download" i], button[aria-label*="export" i], button[aria-label*="descargar" i]'))
942
+ .or(page.locator('.lucide-download, .lucide-file-down, .lucide-share, [data-slot="download-icon"]').locator('..'))
943
+ .filter({ visible: true })
944
+ .first();
945
+
946
+ // Listen for download event BEFORE clicking
947
+ const downloadPromise = page.waitForEvent('download', { timeout: 15000 }).catch(() => null);
948
+
949
+ await expect(actionBtn).toBeVisible({ timeout: 10000 }).catch(() => {
950
+ throw new Error(`No se encontró botón de Descarga/Exportación.`);
951
+ });
952
+
953
+ await actionBtn.click();
954
+ console.log('✅ Clic en botón de Descarga realizado.');
955
+
956
+ const download = await downloadPromise;
957
+ if (download) {
958
+ console.log(`🎉 Descarga iniciada: ${download.suggestedFilename()}`);
959
+ await download.path(); // Wait for finish
960
+ console.log('✅ Archivo descargado correctamente.');
961
+ return;
962
+ } else {
963
+ console.warn('⚠️ No se detectó evento de descarga (tal vez abrió en nueva pestaña o no inició).');
964
+ // Si no hubo evento download, buscamos confirmación visual por si acaso
965
+ }
966
+ return; // Terminate for download tasks
967
+ } else if (isEditMode) {
968
+ console.log('→ Buscando botón de EDICIÓN (Edit/Update)...');
969
+ // Buscamos botones de edición, pero EXCLUIMOS los botones de "Crear/Nuevo"
970
+ actionBtn = page
971
+ .locator('button, a, [role="button"]')
972
+ .filter({ hasText: /edit|actualiz|modific|update/i })
973
+ .filter({ hasNotText: /nuevo|nueva|crear|new|add|agregar/i }) // IMPORTANTE: No confundir con "Nuevo"
974
+ .or(page.locator('button[aria-label*="edit" i], a[aria-label*="edit" i], [href*="edit" i]'))
975
+ .or(page.locator('.lucide-pencil, .lucide-edit, [data-slot="edit-icon"]').locator('..'))
976
+ .filter({ visible: true })
977
+ .first();
978
+ } else {
979
+ console.log('→ Buscando botón de CREACIÓN (New/Create)...');
980
+ // Para creación no suele haber "random" ni "target specifics" (salvo validaciones futuras)
981
+ actionBtn = page
982
+ .getByRole('button', { name: /nuevo|nueva|crear|new|add|agregar/i })
983
+ .or(page.getByRole('link', { name: /nuevo|nueva|crear|new|add|agregar/i }))
984
+ .filter({ visible: true })
985
+ .first();
986
+ }
987
+
988
+ // --- LOGICA DE SELECCIÓN (RANDOM / SPECIFIC) PARA EDIT/DELETE ---
989
+ const targetRecord = process.env.SMART_TARGET_RECORD || '';
990
+ const isRandom = process.env.SMART_IS_RANDOM === 'true';
991
+
992
+ // Si estamos en modo Edit/Delete verificamos si hay que filtrar o elegir aleatorio
993
+ if ((isEditMode || isDeactivateMode) && actionBtn) {
994
+ // Redefinimos actionBtn como una lista de candidatos si es necesario
995
+ // Nota: actionBtn arriba ya tenía .first(), así que necesitamos reconstruir el locator SIN .first()
996
+ // Copiamos la lógica de selectores de arriba pero sin .first()
997
+ let candidates;
998
+
999
+ if (isDeactivateMode) {
1000
+ candidates = page
1001
+ .getByRole('button', { name: /desactiv|elimin|borr|delete|remove|power/i })
1002
+ .or(page.locator('button[aria-label*="delete" i], button[aria-label*="eliminar" i], button[aria-label*="borrar" i], button[aria-label*="power" i]'))
1003
+ .or(page.locator('.lucide-trash, .lucide-trash-2, .lucide-ban, .lucide-power-off, [data-slot="delete-icon"]').locator('..'))
1004
+ .filter({ visible: true });
1005
+ } else {
1006
+ // Edit Mode
1007
+ candidates = page
1008
+ .locator('button, a, [role="button"]')
1009
+ .filter({ hasText: /edit|actualiz|modific|update/i })
1010
+ .filter({ hasNotText: /nuevo|nueva|crear|new|add|agregar/i })
1011
+ .or(page.locator('button[aria-label*="edit" i], a[aria-label*="edit" i], [href*="edit" i]'))
1012
+ .or(page.locator('.lucide-pencil, .lucide-edit, [data-slot="edit-icon"]').locator('..'))
1013
+ .filter({ visible: true });
1014
+ }
1015
+
1016
+ if (targetRecord) {
1017
+ console.log(`🎯 Filtrando por registro: "${targetRecord}"`);
1018
+ // Estrategia: Buscar contenedor (fila/card) que tenga el texto, luego buscar botón dentro
1019
+ const rowLocator = page.locator('tr, [role="row"], .card, li, div.border, div.shadow').filter({ hasText: targetRecord });
1020
+
1021
+ // Si encontramos filas, buscamos el botón dentro
1022
+ if (await rowLocator.count() > 0) {
1023
+ actionBtn = rowLocator.locator(candidates).first();
1024
+ } else {
1025
+ console.warn(`⚠️ No se encontraron filas con el texto "${targetRecord}". Se intentará buscar el botón globalmente.`);
1026
+ actionBtn = candidates.first();
1027
+ }
1028
+ } else if (isRandom) {
1029
+ console.log('🎲 Seleccionando registro ALEATORIO...');
1030
+ const count = await candidates.count();
1031
+ if (count > 0) {
1032
+ const randIndex = Math.floor(Math.random() * count);
1033
+ actionBtn = candidates.nth(randIndex);
1034
+ console.log(` ➡ Elegido índice ${randIndex} de ${count} candidatos.`);
1035
+ } else {
1036
+ actionBtn = candidates.first();
1037
+ }
1038
+ } else {
1039
+ // Default: primero
1040
+ actionBtn = candidates.first();
1041
+ }
1042
+ }
1043
+
1044
+
1045
+ await expect(actionBtn).toBeVisible({ timeout: 10000 }).catch(() => {
1046
+ throw new Error(`No se encontró el botón de ${isDeactivateMode ? 'Desactivación' : (isEditMode ? 'Edición' : 'Creación')} en la vista.`);
1047
+ });
1048
+
1049
+ await actionBtn.click();
1050
+ console.log(`✅ Clic en botón de ${isDeactivateMode ? 'Desactivación' : (isEditMode ? 'Edición' : 'Acción')} realizado.`);
1051
+
1052
+ // 3.1 Manejo especial para Desactivación/Borrado (Confirmación)
1053
+ if (isDeactivateMode) {
1054
+ console.log('→ Detectando posible modal de confirmación...');
1055
+ await page.waitForTimeout(1000);
1056
+ const confirmBtn = page.getByRole('button', { name: /aceptar|confirmar|si|yes|delete|eliminar|borrar/i })
1057
+ .filter({ visible: true })
1058
+ .first();
1059
+
1060
+ if (await confirmBtn.isVisible()) {
1061
+ console.log(' ➡ Modal de confirmación detectado. Haciendo clic en confirmar...');
1062
+ await confirmBtn.click();
1063
+ } else {
1064
+ console.log(' 🏁 No se detectó modal de confirmación explícito.');
1065
+ }
1066
+
1067
+ // Esperamos confirmación final y terminamos el test (no hay formulario que llenar)
1068
+ const confirmation = page
1069
+ .getByRole('alert')
1070
+ .or(page.getByRole('status'))
1071
+ .or(page.getByText(/exitosamente|success|borrado|eliminado|desactivado|éxito|correctamente/i));
1072
+
1073
+ await expect(confirmation.first()).toBeVisible({ timeout: 15000 });
1074
+ console.log('🎉 Registro procesado (Desactivado/Borrado) con éxito.');
1075
+ return;
1076
+ }
1077
+
1078
+ // 4. Scope del formulario (wizard)
1079
+ await page.waitForTimeout(1000);
1080
+ const formScope = page
1081
+ .locator('form, [role="dialog"], [role="main"], fieldset, .modal-content')
1082
+ .filter({ has: page.locator('input, textarea, select, [role="combobox"], button, [data-slot]') })
1083
+ .first();
1084
+
1085
+ await expect(formScope).toBeVisible({ timeout: 10000 });
1086
+
1087
+ // 5. Llenado inteligente (wizard/multistep)
1088
+ console.log('→ Iniciando llenado de formulario...');
1089
+
1090
+ let step = 1;
1091
+ let hasNext = true;
1092
+ const filledFieldsIds = new Set<string>();
1093
+
1094
+ while (hasNext && step < 10) {
1095
+ console.log(`\n📦 Pasando al paso #${step} del formulario...`);
1096
+ await ensureNoOverlay(page);
1097
+
1098
+ // 5.0 Componentes custom genéricos y Carga de archivos
1099
+ try {
1100
+ await fillCustomPickersIfPresent(page, formScope, filledFieldsIds);
1101
+ await handleFileUploads(page, formScope, filledFieldsIds);
1102
+ } catch (e) {
1103
+ console.log(` ⚠️ ERROR en componentes custom/archivos: ${e instanceof Error ? e.message : String(e)}`);
1104
+ }
1105
+ await ensureNoOverlay(page);
1106
+
1107
+ // Scan genérico de campos
1108
+ const fieldSelector = [
1109
+ 'input:not([type="hidden"])',
1110
+ 'textarea',
1111
+ 'select',
1112
+ '[role="combobox"]',
1113
+ 'button[aria-haspopup]',
1114
+ '[data-slot="trigger"]',
1115
+ 'button[type="button"]:not([disabled])', // Agregamos botones normales
1116
+ ].join(', ');
1117
+
1118
+ const allPotentialFields = await formScope.locator(fieldSelector).all();
1119
+
1120
+ for (const field of allPotentialFields) {
1121
+ await ensureNoOverlay(page);
1122
+
1123
+ if (!(await field.isVisible()) || (await field.isDisabled())) continue;
1124
+
1125
+ const id = await getStableFieldId(field);
1126
+ if (filledFieldsIds.has(id)) continue;
1127
+
1128
+ if (await isNavOrHeaderNoise(field)) continue;
1129
+ // OJO: isFlowButton filtra "Siguiente", "Guardar", etc.
1130
+ // Si el botón es "Configurar preguntas", NO debería ser filtrado por isFlowButton si está bien implementado.
1131
+ // Pero por seguridad, verificaremos el texto antes de descartar.
1132
+ const label = await getFieldLabel(field);
1133
+ const isNavigation = /siguiente|next|guardar|save|enviar|submit|cancelar|cancel/i.test(label);
1134
+
1135
+ if (await isFlowButton(field) || isNavigation) {
1136
+ // Doble chequeo: Si dice "Agregar" o "Configurar", NO es navegación de flujo principal
1137
+ if (!/agregar|add|configurar|config|gestionar|manage/i.test(label)) {
1138
+ continue;
1139
+ }
1140
+ }
1141
+
1142
+ const tag = await field.evaluate((el) => el.tagName.toLowerCase());
1143
+ const role = (await field.getAttribute('role')) || '';
1144
+ const type = (await field.getAttribute('type')) || '';
1145
+ const ariaAutocomplete = (await field.getAttribute('aria-autocomplete')) || '';
1146
+
1147
+ // label ya fue declarado arriba
1148
+ // const label = await getFieldLabel(field);
1149
+
1150
+ // Evitar Zona o botones de navegación desde el scan genérico
1151
+ if (/^zona$/i.test(label) || label.toLowerCase().includes('zonaselecciona')) {
1152
+ filledFieldsIds.add(id);
1153
+ continue;
1154
+ }
1155
+
1156
+ console.log(` - [${tag}/${role || '-'}] "${label}"`);
1157
+
1158
+ try {
1159
+ let handled = false;
1160
+
1161
+ // ✅ Autocomplete genérico
1162
+ const looksLikeAutocomplete = role === 'combobox' || ariaAutocomplete !== '';
1163
+ if (looksLikeAutocomplete) {
1164
+ console.log(` 🖱️ Intentando AUTOCOMPLETE para "${label}"...`);
1165
+ handled = await tryHandleAutocomplete(page, field, label);
1166
+ }
1167
+
1168
+ // ✅ HeroUI Select o Trigger de Modal
1169
+ const overrides = JSON.parse(process.env.SMART_FIELD_OVERRIDES || '{}');
1170
+ const overrideKey = Object.keys(overrides).find(k => label.toLowerCase().includes(k.toLowerCase()));
1171
+ const cleanPrompt = (process.env.SMART_PROMPT || '').toLowerCase();
1172
+
1173
+ if (!handled && (tag === 'button' || (await field.getAttribute('data-slot')) === 'trigger')) {
1174
+ const ariaHasPopup = await field.getAttribute('aria-haspopup');
1175
+
1176
+ if (ariaHasPopup === 'listbox') {
1177
+ console.log(` 🔽 Detectado HeroUI Select para "${label}"`);
1178
+ const val = overrideKey ? overrides[overrideKey] : valueFor(label, 'select');
1179
+ handled = await handleHeroUISelect(page, field, val);
1180
+ } else {
1181
+ // Detección proactiva de disparadores de modal
1182
+ const isModalTrigger = /configurar|gestionar|agregar|add|manage|config|preguntas|respuestas/i.test(label) ||
1183
+ cleanPrompt.includes(label.toLowerCase().replace(/\*/g, '').trim()) ||
1184
+ overrideKey;
1185
+
1186
+ if (isModalTrigger) {
1187
+ console.log(` 📦 [TRIGGER] Acción detectada: "${label}". Evaluando apertura...`);
1188
+ const instruction = overrideKey ? overrides[overrideKey] : cleanPrompt;
1189
+ handled = await handleModalSubform(page, field, instruction);
1190
+ }
1191
+ }
1192
+ }
1193
+
1194
+ // select nativo
1195
+ if (!handled && tag === 'select') {
1196
+ const opts = field.locator('option');
1197
+ if ((await opts.count()) > 1) {
1198
+ const val = await opts.nth(1).getAttribute('value');
1199
+ if (val) await field.selectOption(val);
1200
+ }
1201
+ handled = true;
1202
+ }
1203
+
1204
+ if (!handled && (tag === 'input' || tag === 'textarea')) {
1205
+ let val = valueFor(label, type);
1206
+
1207
+ if (overrideKey) {
1208
+ val = overrides[overrideKey];
1209
+ console.log(` ✨ Usando valor específico para "${label}": "${val}"`);
1210
+ }
1211
+
1212
+ await field.fill(val);
1213
+ handled = true;
1214
+ }
1215
+
1216
+ filledFieldsIds.add(id);
1217
+ } catch (e) {
1218
+ console.log(` ⚠️ ERROR en campo "${label}": ${e instanceof Error ? e.message : String(e)}`);
1219
+ filledFieldsIds.add(id);
1220
+ }
1221
+ }
1222
+
1223
+ // Avance wizard
1224
+ await ensureNoOverlay(page);
1225
+
1226
+ const nextBtn = page.getByRole('button', { name: /siguiente|next/i }).filter({ visible: true });
1227
+ if ((await nextBtn.count()) > 0 && (await nextBtn.first().isEnabled())) {
1228
+ console.log(' ➡ Botón "Siguiente" detectado. Avanzando...');
1229
+ await nextBtn.first().click();
1230
+ await page.waitForTimeout(1000);
1231
+ step++;
1232
+ } else {
1233
+ console.log(' 🏁 No hay más botones de "Siguiente". Procediendo a Guardar.');
1234
+ hasNext = false;
1235
+ }
1236
+ }
1237
+
1238
+ // --- FINALIZACIÓN: CLIC EN GUARDAR PRINCIPAL ---
1239
+ console.log('\n→ [FINAL] Intentando Guardar el registro principal...');
1240
+ await page.waitForTimeout(1000);
1241
+ await ensureNoOverlay(page);
1242
+
1243
+ const submitBtn = page
1244
+ .locator('button')
1245
+ .filter({ hasText: /guardar|save|crear|create|finalizar/i })
1246
+ .filter({ visible: true })
1247
+ .last(); // Usamos .last() por si hay botones fantasmas en el header
1248
+
1249
+ if (await submitBtn.isVisible({ timeout: 10000 })) {
1250
+ if (await submitBtn.isDisabled()) {
1251
+ console.warn(' ⚠️ Botón Guardar principal deshabilitado. Intentando scroll...');
1252
+ await submitBtn.scrollIntoViewIfNeeded();
1253
+ await page.waitForTimeout(500);
1254
+ }
1255
+ await submitBtn.click({ force: true });
1256
+ console.log('✅ [EXITO] Clic en botón "Guardar" principal realizado.');
1257
+ } else {
1258
+ console.error('❌ [ERROR] No se pudo encontrar el botón de Guardar final.');
1259
+ }
1260
+ });
1261
+
1262
+ async function handleHeroUISelect(page: any, trigger: any, value: string) {
1263
+ await trigger.click();
1264
+ await page.waitForTimeout(600);
1265
+ const listbox = page.locator('[role="listbox"], [role="menu"]').filter({ visible: true }).first();
1266
+
1267
+ // Si no vemos el listbox, puede que ya estuviera abierto o que el click fallara.
1268
+ // Re-intentamos levemente o buscamos opciones.
1269
+ const options = listbox.locator('[role="option"], [role="menuitem"]');
1270
+
1271
+ // Intentamos buscar por texto exacto o parcial
1272
+ let option = options.filter({ hasText: new RegExp(`^${value}$`, 'i') }).first();
1273
+ if (!(await option.isVisible())) {
1274
+ option = options.filter({ hasText: new RegExp(value, 'i') }).first();
1275
+ }
1276
+
1277
+ if (await option.isVisible({ timeout: 2000 }).catch(() => false)) {
1278
+ await option.click();
1279
+ console.log(` ✅ Opción "${value}" seleccionada.`);
1280
+ return true;
1281
+ }
1282
+
1283
+ // Fallback: Si no encontramos "Otros", intentamos con el primer elemento disponible
1284
+ // para no dejar el campo vacío y no cerrar el modal con Escape.
1285
+ const firstOpt = options.first();
1286
+ if (await firstOpt.isVisible()) {
1287
+ const text = await firstOpt.innerText();
1288
+ await firstOpt.click();
1289
+ console.log(` ✅ Opción "${value}" no encontrada. Usando fallback: "${text.trim()}"`);
1290
+ return true;
1291
+ }
1292
+
1293
+ console.log(` ⚠️ No se pudo seleccionar nada en el select para "${value}".`);
1294
+ // NO presionamos Escape aquí si no estamos seguros de que no cerrará el modal.
1295
+ // Es preferible dejar el listbox abierto que cerrar el modal.
1296
+ await page.mouse.click(10, 10).catch(() => { }); // Click fuera neutral
1297
+ return false;
1298
+ }
1299
+
1300
+ async function handleModalSubform(page: any, trigger: any, instructions: string) {
1301
+ console.log(` 🚀 [MODAL] Gestionando sub-formulario dinámico. Prompt: "${instructions}"`);
1302
+ await trigger.click();
1303
+ await page.waitForTimeout(2500);
1304
+
1305
+ const modal = page.locator('[role="dialog"], section[role="dialog"]').filter({ visible: true }).last();
1306
+ if (!(await modal.isVisible({ timeout: 6000 }).catch(() => false))) {
1307
+ console.warn(' ⚠️ No se detectó el modal abierto.');
1308
+ return false;
1309
+ }
1310
+
1311
+ const header = await modal.locator('header, h2, [role="banner"]').first().innerText().catch(() => 'Modal');
1312
+ console.log(` 📦 Trabajando en: "${header.split('\n')[0]}"`);
1313
+
1314
+ // --- 1. PREPARACIÓN ESTRUCTURAL ---
1315
+ const questMatch = instructions.match(/(\d+)\s+preguntas/i);
1316
+ let targetQuests = questMatch ? parseInt(questMatch[1]) : 1;
1317
+ const wantsNewQuest = /agrega(?:r)?\s+(?:una\s+)?nueva\s+pregunta|otra\s+pregunta/i.test(instructions);
1318
+
1319
+ const addQuestBtn = modal.locator('button').filter({ hasText: /agregar nueva pregunta|add new question/i }).first();
1320
+
1321
+ // Función para encontrar bloques de pregunta de forma dinámica
1322
+ const getBlocks = async () => {
1323
+ // Buscamos contenedores que tengan un botón de "agregar respuesta"
1324
+ const potential = modal.locator('.heroui-card, fieldset, .border-default-200');
1325
+ const count = await potential.count();
1326
+ const valid = [];
1327
+ for (let i = 0; i < count; i++) {
1328
+ const block = potential.nth(i);
1329
+ if (await block.locator('button').filter({ hasText: /agregar respuesta|add answer/i }).count() > 0) {
1330
+ valid.push(block);
1331
+ }
1332
+ }
1333
+ return valid;
1334
+ };
1335
+
1336
+ let blocks = await getBlocks();
1337
+ let currentBlocks = blocks.length || 1;
1338
+
1339
+ if (wantsNewQuest) {
1340
+ targetQuests = Math.max(targetQuests, currentBlocks + 1);
1341
+ }
1342
+
1343
+ while (currentBlocks < targetQuests && await addQuestBtn.isVisible()) {
1344
+ console.log(` ➕ Agregando bloque de pregunta #${currentBlocks + 1}...`);
1345
+ await addQuestBtn.click();
1346
+ await page.waitForTimeout(1000);
1347
+ blocks = await getBlocks();
1348
+ currentBlocks = blocks.length || (currentBlocks + 1);
1349
+ }
1350
+
1351
+ // Respuestas por cada bloque
1352
+ const respMatch = instructions.match(/(\d+)\s+respuestas/i);
1353
+ const targetResps = respMatch ? parseInt(respMatch[1]) : 0;
1354
+
1355
+ if (targetResps > 0) {
1356
+ blocks = await getBlocks();
1357
+ const addRespBtns = await modal.locator('button').filter({ hasText: /agregar respuesta|add answer/i }).all();
1358
+
1359
+ for (let i = 0; i < Math.max(blocks.length, addRespBtns.length); i++) {
1360
+ const btn = addRespBtns[i];
1361
+ const container = blocks[i];
1362
+
1363
+ if (btn && await btn.isVisible()) {
1364
+ let currentResps = 1;
1365
+ if (container) {
1366
+ currentResps = await container.locator('input[placeholder*="Respuesta" i], input[aria-label*="Respuesta" i]').count();
1367
+ } else {
1368
+ // Fallback: si no detectamos el contenedor, contamos globales divididos por número de botones
1369
+ const totalRespsInModal = await modal.locator('input[placeholder*="Respuesta" i]').count();
1370
+ currentResps = Math.ceil(totalRespsInModal / addRespBtns.length);
1371
+ }
1372
+
1373
+ if (currentResps === 0) currentResps = 1;
1374
+ while (currentResps < targetResps) {
1375
+ console.log(` [Bloque ${i + 1}] ➕ Añadiendo respuesta extra...`);
1376
+ await btn.click();
1377
+ await page.waitForTimeout(600);
1378
+ currentResps++;
1379
+ }
1380
+ }
1381
+ }
1382
+ }
1383
+
1384
+ // --- 2. LLENADO ---
1385
+ await page.waitForTimeout(800);
1386
+ const fields = await modal.locator('input:not([type="hidden"]), textarea, [data-slot="trigger"]').all();
1387
+ console.log(` 🔎 Llenando ${fields.length} campos detectados...`);
1388
+
1389
+ let qCount = 1;
1390
+ let rCount = 1;
1391
+
1392
+ for (let i = 0; i < fields.length; i++) {
1393
+ // VERIFICACIÓN CRÍTICA: ¿Sigue el modal visible?
1394
+ if (!(await modal.isVisible())) {
1395
+ console.error(' ❌ [ERROR] El modal se cerró inesperadamente durante el llenado.');
1396
+ return false;
1397
+ }
1398
+
1399
+ const field = fields[i];
1400
+ if (!(await field.isVisible())) continue;
1401
+
1402
+ const label = await getFieldLabel(field);
1403
+ const placeholder = await field.getAttribute('placeholder') || '';
1404
+ const ariaLabel = await field.getAttribute('aria-label') || '';
1405
+ const text = (label + ' ' + placeholder + ' ' + ariaLabel).toLowerCase();
1406
+
1407
+ let val = '';
1408
+ if (/tipo|type/i.test(text)) {
1409
+ val = 'Otros';
1410
+ } else if (/pregunta|question/i.test(text)) {
1411
+ val = `Pregunta #${qCount++}`;
1412
+ rCount = 1;
1413
+ } else if (/respuesta|answer/i.test(text)) {
1414
+ val = `Respuesta ${Math.max(1, qCount - 1)}.${rCount++}: Editada`;
1415
+ } else if (label.includes('*')) {
1416
+ val = 'Obligatorio';
1417
+ }
1418
+
1419
+ if (val) {
1420
+ console.log(` 🖋️ "${label || placeholder || 'Campo'}" -> "${val}"`);
1421
+ const isSelect = (await field.getAttribute('data-slot')) === 'trigger' || (await field.getAttribute('aria-haspopup')) === 'listbox';
1422
+ if (isSelect) {
1423
+ await handleHeroUISelect(page, field, val);
1424
+ await page.waitForTimeout(400);
1425
+ } else {
1426
+ await field.click({ force: true }).catch(() => { });
1427
+ await page.keyboard.press('Control+A');
1428
+ await page.keyboard.press('Backspace');
1429
+ await field.fill(val);
1430
+ await page.waitForTimeout(200);
1431
+ }
1432
+ }
1433
+ }
1434
+
1435
+ // --- 3. GUARDADO ---
1436
+ if (!(await modal.isVisible())) return false;
1437
+
1438
+ console.log(' 💾 Finalizando modal...');
1439
+ const saveBtn = modal.locator('button').filter({ hasText: /guardar|save|aceptar|ok|confirm/i }).last();
1440
+ if (await saveBtn.isVisible()) {
1441
+ if (await saveBtn.isDisabled()) {
1442
+ console.warn(' ⚠️ Botón deshabilitado. Forzando validación...');
1443
+ const lastField = modal.locator('input').last();
1444
+ if (await lastField.isVisible()) {
1445
+ await lastField.focus();
1446
+ await page.keyboard.type(' ');
1447
+ await page.keyboard.press('Backspace');
1448
+ await page.waitForTimeout(600);
1449
+ }
1450
+ }
1451
+ await saveBtn.click({ force: true });
1452
+ console.log(' ✅ Guardar pulsado.');
1453
+ await expect(modal).not.toBeVisible({ timeout: 10000 }).catch(() => page.keyboard.press('Escape'));
1454
+ await page.waitForTimeout(1000);
1455
+ return true;
1456
+ }
1457
+ return false;
1458
+ }