siesa-agents 2.1.72-qa.21 → 2.1.72-qa.22

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.
@@ -1,297 +1,297 @@
1
- /**
2
- * React E2E Helpers — Módulo reutilizable para specs del stack corporativo SIESA
3
- * SIESA React (Vite + TanStack Router/Query + Zustand + siesa-ui-kit + Radix/shadcn)
4
- *
5
- * Equivalente React de `blazor-e2e-helpers.ts`. A diferencia de Blazor:
6
- * - NO hay SignalR ni pre-carga; se espera a que TanStack Query resuelva.
7
- * - Selectores SEMÁNTICOS (getByRole / getByLabel / getByTestId), NO CSS frágil.
8
- * - Validación con React Hook Form + Zod (errores como texto cerca del campo).
9
- *
10
- * ⚠️ SCAFFOLD: los selectores concretos de siesa-ui-kit NO están documentados en el repo.
11
- * Cada helper acepta overrides y/o `data-testid` para ajustarse a lo que confirme el
12
- * `architecture-brief-siesa-react.md` del módulo. Donde un selector sea una suposición
13
- * razonable, está marcado con `TODO[brief]`. Ajusta antes de promover specs a `tests/`.
14
- *
15
- * Uso:
16
- * import { createObsCollector, login, fillField, selectLookup, submitForm } from './react-e2e-helpers';
17
- */
18
-
19
- import { expect, type Page, type Locator } from '@playwright/test';
20
- import * as fs from 'fs';
21
- import * as path from 'path';
22
-
23
- // ─── Observability Collector (idéntico a Blazor — framework-agnóstico) ─────────
24
-
25
- export interface ObsEntry {
26
- timestamp: string;
27
- type: 'console-error' | 'console-warn' | 'js-exception' | 'http-error' | 'app-alert';
28
- message: string;
29
- url?: string;
30
- }
31
-
32
- export function createObsCollector() {
33
- const entries: ObsEntry[] = [];
34
- const now = () => new Date().toISOString().slice(11, 23);
35
-
36
- function attach(page: Page) {
37
- page.on('console', msg => {
38
- if (msg.type() === 'error') {
39
- entries.push({ timestamp: now(), type: 'console-error', message: msg.text().slice(0, 300) });
40
- } else if (msg.type() === 'warning') {
41
- entries.push({ timestamp: now(), type: 'console-warn', message: msg.text().slice(0, 300) });
42
- }
43
- });
44
-
45
- page.on('pageerror', err => {
46
- entries.push({ timestamp: now(), type: 'js-exception', message: String(err).slice(0, 300) });
47
- });
48
-
49
- page.on('response', resp => {
50
- if (resp.status() >= 400) {
51
- entries.push({
52
- timestamp: now(),
53
- type: 'http-error',
54
- message: `${resp.status()} ${resp.statusText()}`,
55
- url: resp.url().slice(0, 200),
56
- });
57
- }
58
- });
59
-
60
- page.on('dialog', async dialog => {
61
- entries.push({
62
- timestamp: now(),
63
- type: 'app-alert',
64
- message: `[${dialog.type()}] ${dialog.message().slice(0, 300)}`,
65
- });
66
- await dialog.accept().catch(() => {});
67
- });
68
- }
69
-
70
- function report(suiteName: string) {
71
- console.log(`\n╔══ OBSERVABILITY REPORT — ${suiteName} ══╗`);
72
- console.log(`║ Total entries: ${entries.length}`);
73
- const byType = entries.reduce((acc, e) => { acc[e.type] = (acc[e.type] || 0) + 1; return acc; }, {} as Record<string, number>);
74
- for (const [type, count] of Object.entries(byType)) {
75
- console.log(`║ ${type}: ${count}`);
76
- }
77
- if (entries.length > 0) {
78
- console.log('║ ─── Detalle (max 20) ───');
79
- for (const e of entries.slice(0, 20)) {
80
- console.log(`║ [${e.timestamp}] ${e.type}: ${e.message}${e.url ? ` (${e.url})` : ''}`);
81
- }
82
- if (entries.length > 20) console.log(`║ ... y ${entries.length - 20} mas`);
83
- }
84
- console.log(`╚${'═'.repeat(40)}╝\n`);
85
-
86
- const reportData = {
87
- suite: suiteName,
88
- timestamp: new Date().toISOString(),
89
- total: entries.length,
90
- byType,
91
- entries,
92
- };
93
- const outDir = path.join(process.cwd(), 'test-results');
94
- fs.mkdirSync(outDir, { recursive: true });
95
- const jsonPath = path.join(outDir, 'observability-report.json');
96
- fs.writeFileSync(jsonPath, JSON.stringify(reportData, null, 2));
97
- console.log(`Observability JSON → ${jsonPath}`);
98
-
99
- return reportData;
100
- }
101
-
102
- return { entries, attach, report };
103
- }
104
-
105
- // ─── Espera de carga (TanStack Query) ────────────────────────────────────────
106
-
107
- /**
108
- * Espera a que terminen los estados de carga de TanStack Query: spinners/skeletons ocultos
109
- * y la red en reposo. Sustituye al `.sdk-loader` de Blazor. Usar tras navegar o tras una
110
- * mutación (guardar) antes de aseverar.
111
- */
112
- export async function waitForQuery(page: Page, opts?: { loaderTestId?: string }) {
113
- await page.waitForLoadState('networkidle').catch(() => {});
114
- // Spinner accesible (role="progressbar") o el loader del kit por data-testid.
115
- const loader = opts?.loaderTestId
116
- ? page.getByTestId(opts.loaderTestId)
117
- : page.getByRole('progressbar'); // TODO[brief]: confirmar rol/testid del loader de siesa-ui-kit
118
- await loader.first().waitFor({ state: 'hidden', timeout: 15_000 }).catch(() => {});
119
- }
120
-
121
- // ─── Login ───────────────────────────────────────────────────────────────────
122
-
123
- /**
124
- * Login del stack React. Por defecto usa form con labels "Usuario"/"Contraseña"; si la app
125
- * usa SSO corporativo, sobreescribir vía `opts` o adaptar al brief del módulo.
126
- */
127
- export async function login(page: Page, user?: string, pass?: string, opts?: { loginPath?: string }) {
128
- const cred = {
129
- user: user || process.env.TEST_USER || '',
130
- password: pass || process.env.TEST_PASS || '',
131
- };
132
- if (!cred.user || !cred.password) {
133
- throw new Error(
134
- 'react-e2e-helpers.login: faltan credenciales. Define TEST_USER y TEST_PASS en tu .env ' +
135
- '(ver .env.example) o pásalas como argumentos a login(page, user, pass).'
136
- );
137
- }
138
- await page.goto(opts?.loginPath ?? '/login'); // TODO[brief]: ruta de login real (SSO vs form)
139
-
140
- // TODO[brief]: ajustar labels/roles a los del form real (i18n ES/EN).
141
- const userInput = page.getByLabel(/usuario|email|correo/i).first();
142
- await userInput.waitFor({ state: 'visible', timeout: 30_000 });
143
- await userInput.fill(cred.user);
144
-
145
- await page.getByLabel(/contrase|password/i).first().fill(cred.password);
146
-
147
- await page.getByRole('button', { name: /iniciar|ingresar|entrar|sign in/i }).click();
148
- await page.waitForURL(/.*(?!.*login).*/, { timeout: 30_000 });
149
- await waitForQuery(page);
150
- }
151
-
152
- // ─── Form Helpers ──────────────────────────────────────────────────────────────
153
-
154
- /**
155
- * Llena un campo de texto/número buscado por label accesible (React Hook Form).
156
- * Pasa `byTestId` si el campo no expone label asociable.
157
- */
158
- export async function fillField(
159
- page: Page,
160
- label: string | RegExp,
161
- value: string,
162
- opts?: { byTestId?: string }
163
- ) {
164
- const field = fieldLocator(page, label, opts?.byTestId, 'textbox');
165
- await field.click();
166
- await field.fill('');
167
- await field.fill(value);
168
- }
169
-
170
- /**
171
- * Selecciona una opción en un dropdown/Select (siesa-ui-kit / Radix Select).
172
- */
173
- export async function selectOption(
174
- page: Page,
175
- label: string | RegExp,
176
- optionName: string | RegExp,
177
- opts?: { byTestId?: string }
178
- ) {
179
- const trigger = opts?.byTestId
180
- ? page.getByTestId(opts.byTestId)
181
- : page.getByRole('combobox', { name: label });
182
- await trigger.click();
183
- await page.getByRole('option', { name: optionName }).click();
184
- }
185
-
186
- /**
187
- * Selecciona un registro maestro vía LookupField (patrón R-LF-001).
188
- * Escribe el término, espera la lista (TanStack Query) y elige la opción.
189
- */
190
- export async function selectLookup(
191
- page: Page,
192
- label: string | RegExp,
193
- searchText: string,
194
- opts?: { byTestId?: string; optionIndex?: number }
195
- ) {
196
- const input = opts?.byTestId
197
- ? page.getByTestId(opts.byTestId)
198
- : page.getByRole('combobox', { name: label }); // TODO[brief]: confirmar rol del LookupField
199
- await input.click();
200
- await input.fill(searchText);
201
- await waitForQuery(page); // espera el fetch de búsqueda
202
- const option = page.getByRole('option').nth(opts?.optionIndex ?? 0);
203
- await option.waitFor({ state: 'visible', timeout: 10_000 });
204
- await option.click();
205
- }
206
-
207
- /**
208
- * Marca/desmarca un checkbox o switch por label (ej: "Activo").
209
- */
210
- export async function setToggle(page: Page, label: string | RegExp, checked = true) {
211
- const toggle = page.getByRole('checkbox', { name: label })
212
- .or(page.getByRole('switch', { name: label }))
213
- .first();
214
- if ((await toggle.isChecked().catch(() => false)) !== checked) {
215
- await toggle.click();
216
- }
217
- }
218
-
219
- // ─── Acciones (submit, toolbar) ────────────────────────────────────────────────
220
-
221
- /**
222
- * Envía el formulario (botón Guardar/Submit) y espera que la mutación + invalidación
223
- * de query terminen. No asume toast; usar `expectToast` aparte si aplica.
224
- */
225
- export async function submitForm(page: Page, buttonName: string | RegExp = /guardar|save|submit/i) {
226
- await page.getByRole('button', { name: buttonName }).click();
227
- await waitForQuery(page);
228
- }
229
-
230
- /** Click en un botón de acción por nombre accesible (Nuevo, Editar, Eliminar, Cancelar). */
231
- export async function clickAction(page: Page, name: string | RegExp) {
232
- await page.getByRole('button', { name }).click();
233
- await waitForQuery(page);
234
- }
235
-
236
- // ─── Aserciones de feedback ────────────────────────────────────────────────────
237
-
238
- /**
239
- * Verifica un toast/notificación (role="status" o "alert"). El toast suele auto-ocultarse,
240
- * por eso se asevera apenas aparece.
241
- */
242
- export async function expectToast(page: Page, text: string | RegExp) {
243
- const toast = page.getByRole('status').or(page.getByRole('alert')).filter({ hasText: text }).first();
244
- await expect(toast).toBeVisible({ timeout: 10_000 });
245
- }
246
-
247
- /** Verifica el mensaje de error de validación Zod cercano a un campo. */
248
- export async function expectFieldError(page: Page, message: string | RegExp) {
249
- await expect(page.getByText(message).first()).toBeVisible({ timeout: 5_000 });
250
- }
251
-
252
- // ─── Navigation ────────────────────────────────────────────────────────────────
253
-
254
- /**
255
- * Navega a una ruta de la SPA y espera que la app React (Single-SPA) y sus queries monten.
256
- */
257
- export async function navigateTo(page: Page, url: string) {
258
- await page.goto(url);
259
- await waitForQuery(page);
260
- // La app vive bajo #single-spa-application; si no es MFE, basta con el <main>.
261
- const root = page.locator('#single-spa-application').or(page.getByRole('main')).first();
262
- await expect(root).toBeVisible({ timeout: 15_000 });
263
- }
264
-
265
- /** Espera a que una tabla/grid de datos sea visible (grid de siesa-ui-kit / role="grid"/"table"). */
266
- export async function waitForGrid(page: Page) {
267
- await page.getByRole('grid').or(page.getByRole('table')).first()
268
- .waitFor({ state: 'visible', timeout: 15_000 }).catch(() => {});
269
- }
270
-
271
- // ─── Utilidades (compartidas con el helper Blazor) ──────────────────────────────
272
-
273
- /** Resuelve un campo por testid (si se da) o por label accesible con rol esperado. */
274
- function fieldLocator(page: Page, label: string | RegExp, byTestId?: string, role: 'textbox' | 'spinbutton' = 'textbox'): Locator {
275
- if (byTestId) return page.getByTestId(byTestId);
276
- // getByLabel cubre la mayoría; fallback a role+name para inputs sin <label> asociado.
277
- return page.getByLabel(label).or(page.getByRole(role, { name: label })).first();
278
- }
279
-
280
- export function getFechas(diasAdelante = 30): { inicio: string; fin: string } {
281
- const hoy = new Date();
282
- const fin = new Date(hoy);
283
- fin.setDate(fin.getDate() + diasAdelante);
284
-
285
- const fmt = (d: Date) =>
286
- `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
287
-
288
- return { inicio: fmt(hoy), fin: fmt(fin) };
289
- }
290
-
291
- export function step(n: number, desc: string) {
292
- console.log(` [#${n}] ${desc}`);
293
- }
294
-
295
- export function generateRunId() {
296
- return Date.now().toString().slice(-4);
297
- }
1
+ /**
2
+ * React E2E Helpers — Módulo reutilizable para specs del stack corporativo SIESA
3
+ * SIESA React (Vite + TanStack Router/Query + Zustand + siesa-ui-kit + Radix/shadcn)
4
+ *
5
+ * Equivalente React de `blazor-e2e-helpers.ts`. A diferencia de Blazor:
6
+ * - NO hay SignalR ni pre-carga; se espera a que TanStack Query resuelva.
7
+ * - Selectores SEMÁNTICOS (getByRole / getByLabel / getByTestId), NO CSS frágil.
8
+ * - Validación con React Hook Form + Zod (errores como texto cerca del campo).
9
+ *
10
+ * ⚠️ SCAFFOLD: los selectores concretos de siesa-ui-kit NO están documentados en el repo.
11
+ * Cada helper acepta overrides y/o `data-testid` para ajustarse a lo que confirme el
12
+ * `architecture-brief-siesa-react.md` del módulo. Donde un selector sea una suposición
13
+ * razonable, está marcado con `TODO[brief]`. Ajusta antes de promover specs a `tests/`.
14
+ *
15
+ * Uso:
16
+ * import { createObsCollector, login, fillField, selectLookup, submitForm } from './react-e2e-helpers';
17
+ */
18
+
19
+ import { expect, type Page, type Locator } from '@playwright/test';
20
+ import * as fs from 'fs';
21
+ import * as path from 'path';
22
+
23
+ // ─── Observability Collector (idéntico a Blazor — framework-agnóstico) ─────────
24
+
25
+ export interface ObsEntry {
26
+ timestamp: string;
27
+ type: 'console-error' | 'console-warn' | 'js-exception' | 'http-error' | 'app-alert';
28
+ message: string;
29
+ url?: string;
30
+ }
31
+
32
+ export function createObsCollector() {
33
+ const entries: ObsEntry[] = [];
34
+ const now = () => new Date().toISOString().slice(11, 23);
35
+
36
+ function attach(page: Page) {
37
+ page.on('console', msg => {
38
+ if (msg.type() === 'error') {
39
+ entries.push({ timestamp: now(), type: 'console-error', message: msg.text().slice(0, 300) });
40
+ } else if (msg.type() === 'warning') {
41
+ entries.push({ timestamp: now(), type: 'console-warn', message: msg.text().slice(0, 300) });
42
+ }
43
+ });
44
+
45
+ page.on('pageerror', err => {
46
+ entries.push({ timestamp: now(), type: 'js-exception', message: String(err).slice(0, 300) });
47
+ });
48
+
49
+ page.on('response', resp => {
50
+ if (resp.status() >= 400) {
51
+ entries.push({
52
+ timestamp: now(),
53
+ type: 'http-error',
54
+ message: `${resp.status()} ${resp.statusText()}`,
55
+ url: resp.url().slice(0, 200),
56
+ });
57
+ }
58
+ });
59
+
60
+ page.on('dialog', async dialog => {
61
+ entries.push({
62
+ timestamp: now(),
63
+ type: 'app-alert',
64
+ message: `[${dialog.type()}] ${dialog.message().slice(0, 300)}`,
65
+ });
66
+ await dialog.accept().catch(() => {});
67
+ });
68
+ }
69
+
70
+ function report(suiteName: string) {
71
+ console.log(`\n╔══ OBSERVABILITY REPORT — ${suiteName} ══╗`);
72
+ console.log(`║ Total entries: ${entries.length}`);
73
+ const byType = entries.reduce((acc, e) => { acc[e.type] = (acc[e.type] || 0) + 1; return acc; }, {} as Record<string, number>);
74
+ for (const [type, count] of Object.entries(byType)) {
75
+ console.log(`║ ${type}: ${count}`);
76
+ }
77
+ if (entries.length > 0) {
78
+ console.log('║ ─── Detalle (max 20) ───');
79
+ for (const e of entries.slice(0, 20)) {
80
+ console.log(`║ [${e.timestamp}] ${e.type}: ${e.message}${e.url ? ` (${e.url})` : ''}`);
81
+ }
82
+ if (entries.length > 20) console.log(`║ ... y ${entries.length - 20} mas`);
83
+ }
84
+ console.log(`╚${'═'.repeat(40)}╝\n`);
85
+
86
+ const reportData = {
87
+ suite: suiteName,
88
+ timestamp: new Date().toISOString(),
89
+ total: entries.length,
90
+ byType,
91
+ entries,
92
+ };
93
+ const outDir = path.join(process.cwd(), 'test-results');
94
+ fs.mkdirSync(outDir, { recursive: true });
95
+ const jsonPath = path.join(outDir, 'observability-report.json');
96
+ fs.writeFileSync(jsonPath, JSON.stringify(reportData, null, 2));
97
+ console.log(`Observability JSON → ${jsonPath}`);
98
+
99
+ return reportData;
100
+ }
101
+
102
+ return { entries, attach, report };
103
+ }
104
+
105
+ // ─── Espera de carga (TanStack Query) ────────────────────────────────────────
106
+
107
+ /**
108
+ * Espera a que terminen los estados de carga de TanStack Query: spinners/skeletons ocultos
109
+ * y la red en reposo. Sustituye al `.sdk-loader` de Blazor. Usar tras navegar o tras una
110
+ * mutación (guardar) antes de aseverar.
111
+ */
112
+ export async function waitForQuery(page: Page, opts?: { loaderTestId?: string }) {
113
+ await page.waitForLoadState('networkidle').catch(() => {});
114
+ // Spinner accesible (role="progressbar") o el loader del kit por data-testid.
115
+ const loader = opts?.loaderTestId
116
+ ? page.getByTestId(opts.loaderTestId)
117
+ : page.getByRole('progressbar'); // TODO[brief]: confirmar rol/testid del loader de siesa-ui-kit
118
+ await loader.first().waitFor({ state: 'hidden', timeout: 15_000 }).catch(() => {});
119
+ }
120
+
121
+ // ─── Login ───────────────────────────────────────────────────────────────────
122
+
123
+ /**
124
+ * Login del stack React. Por defecto usa form con labels "Usuario"/"Contraseña"; si la app
125
+ * usa SSO corporativo, sobreescribir vía `opts` o adaptar al brief del módulo.
126
+ */
127
+ export async function login(page: Page, user?: string, pass?: string, opts?: { loginPath?: string }) {
128
+ const cred = {
129
+ user: user || process.env.TEST_USER || '',
130
+ password: pass || process.env.TEST_PASS || '',
131
+ };
132
+ if (!cred.user || !cred.password) {
133
+ throw new Error(
134
+ 'react-e2e-helpers.login: faltan credenciales. Define TEST_USER y TEST_PASS en tu .env ' +
135
+ '(ver .env.example) o pásalas como argumentos a login(page, user, pass).'
136
+ );
137
+ }
138
+ await page.goto(opts?.loginPath ?? '/login'); // TODO[brief]: ruta de login real (SSO vs form)
139
+
140
+ // TODO[brief]: ajustar labels/roles a los del form real (i18n ES/EN).
141
+ const userInput = page.getByLabel(/usuario|email|correo/i).first();
142
+ await userInput.waitFor({ state: 'visible', timeout: 30_000 });
143
+ await userInput.fill(cred.user);
144
+
145
+ await page.getByLabel(/contrase|password/i).first().fill(cred.password);
146
+
147
+ await page.getByRole('button', { name: /iniciar|ingresar|entrar|sign in/i }).click();
148
+ await page.waitForURL(/.*(?!.*login).*/, { timeout: 30_000 });
149
+ await waitForQuery(page);
150
+ }
151
+
152
+ // ─── Form Helpers ──────────────────────────────────────────────────────────────
153
+
154
+ /**
155
+ * Llena un campo de texto/número buscado por label accesible (React Hook Form).
156
+ * Pasa `byTestId` si el campo no expone label asociable.
157
+ */
158
+ export async function fillField(
159
+ page: Page,
160
+ label: string | RegExp,
161
+ value: string,
162
+ opts?: { byTestId?: string }
163
+ ) {
164
+ const field = fieldLocator(page, label, opts?.byTestId, 'textbox');
165
+ await field.click();
166
+ await field.fill('');
167
+ await field.fill(value);
168
+ }
169
+
170
+ /**
171
+ * Selecciona una opción en un dropdown/Select (siesa-ui-kit / Radix Select).
172
+ */
173
+ export async function selectOption(
174
+ page: Page,
175
+ label: string | RegExp,
176
+ optionName: string | RegExp,
177
+ opts?: { byTestId?: string }
178
+ ) {
179
+ const trigger = opts?.byTestId
180
+ ? page.getByTestId(opts.byTestId)
181
+ : page.getByRole('combobox', { name: label });
182
+ await trigger.click();
183
+ await page.getByRole('option', { name: optionName }).click();
184
+ }
185
+
186
+ /**
187
+ * Selecciona un registro maestro vía LookupField (patrón R-LF-001).
188
+ * Escribe el término, espera la lista (TanStack Query) y elige la opción.
189
+ */
190
+ export async function selectLookup(
191
+ page: Page,
192
+ label: string | RegExp,
193
+ searchText: string,
194
+ opts?: { byTestId?: string; optionIndex?: number }
195
+ ) {
196
+ const input = opts?.byTestId
197
+ ? page.getByTestId(opts.byTestId)
198
+ : page.getByRole('combobox', { name: label }); // TODO[brief]: confirmar rol del LookupField
199
+ await input.click();
200
+ await input.fill(searchText);
201
+ await waitForQuery(page); // espera el fetch de búsqueda
202
+ const option = page.getByRole('option').nth(opts?.optionIndex ?? 0);
203
+ await option.waitFor({ state: 'visible', timeout: 10_000 });
204
+ await option.click();
205
+ }
206
+
207
+ /**
208
+ * Marca/desmarca un checkbox o switch por label (ej: "Activo").
209
+ */
210
+ export async function setToggle(page: Page, label: string | RegExp, checked = true) {
211
+ const toggle = page.getByRole('checkbox', { name: label })
212
+ .or(page.getByRole('switch', { name: label }))
213
+ .first();
214
+ if ((await toggle.isChecked().catch(() => false)) !== checked) {
215
+ await toggle.click();
216
+ }
217
+ }
218
+
219
+ // ─── Acciones (submit, toolbar) ────────────────────────────────────────────────
220
+
221
+ /**
222
+ * Envía el formulario (botón Guardar/Submit) y espera que la mutación + invalidación
223
+ * de query terminen. No asume toast; usar `expectToast` aparte si aplica.
224
+ */
225
+ export async function submitForm(page: Page, buttonName: string | RegExp = /guardar|save|submit/i) {
226
+ await page.getByRole('button', { name: buttonName }).click();
227
+ await waitForQuery(page);
228
+ }
229
+
230
+ /** Click en un botón de acción por nombre accesible (Nuevo, Editar, Eliminar, Cancelar). */
231
+ export async function clickAction(page: Page, name: string | RegExp) {
232
+ await page.getByRole('button', { name }).click();
233
+ await waitForQuery(page);
234
+ }
235
+
236
+ // ─── Aserciones de feedback ────────────────────────────────────────────────────
237
+
238
+ /**
239
+ * Verifica un toast/notificación (role="status" o "alert"). El toast suele auto-ocultarse,
240
+ * por eso se asevera apenas aparece.
241
+ */
242
+ export async function expectToast(page: Page, text: string | RegExp) {
243
+ const toast = page.getByRole('status').or(page.getByRole('alert')).filter({ hasText: text }).first();
244
+ await expect(toast).toBeVisible({ timeout: 10_000 });
245
+ }
246
+
247
+ /** Verifica el mensaje de error de validación Zod cercano a un campo. */
248
+ export async function expectFieldError(page: Page, message: string | RegExp) {
249
+ await expect(page.getByText(message).first()).toBeVisible({ timeout: 5_000 });
250
+ }
251
+
252
+ // ─── Navigation ────────────────────────────────────────────────────────────────
253
+
254
+ /**
255
+ * Navega a una ruta de la SPA y espera que la app React (Single-SPA) y sus queries monten.
256
+ */
257
+ export async function navigateTo(page: Page, url: string) {
258
+ await page.goto(url);
259
+ await waitForQuery(page);
260
+ // La app vive bajo #single-spa-application; si no es MFE, basta con el <main>.
261
+ const root = page.locator('#single-spa-application').or(page.getByRole('main')).first();
262
+ await expect(root).toBeVisible({ timeout: 15_000 });
263
+ }
264
+
265
+ /** Espera a que una tabla/grid de datos sea visible (grid de siesa-ui-kit / role="grid"/"table"). */
266
+ export async function waitForGrid(page: Page) {
267
+ await page.getByRole('grid').or(page.getByRole('table')).first()
268
+ .waitFor({ state: 'visible', timeout: 15_000 }).catch(() => {});
269
+ }
270
+
271
+ // ─── Utilidades (compartidas con el helper Blazor) ──────────────────────────────
272
+
273
+ /** Resuelve un campo por testid (si se da) o por label accesible con rol esperado. */
274
+ function fieldLocator(page: Page, label: string | RegExp, byTestId?: string, role: 'textbox' | 'spinbutton' = 'textbox'): Locator {
275
+ if (byTestId) return page.getByTestId(byTestId);
276
+ // getByLabel cubre la mayoría; fallback a role+name para inputs sin <label> asociado.
277
+ return page.getByLabel(label).or(page.getByRole(role, { name: label })).first();
278
+ }
279
+
280
+ export function getFechas(diasAdelante = 30): { inicio: string; fin: string } {
281
+ const hoy = new Date();
282
+ const fin = new Date(hoy);
283
+ fin.setDate(fin.getDate() + diasAdelante);
284
+
285
+ const fmt = (d: Date) =>
286
+ `${String(d.getMonth() + 1).padStart(2, '0')}/${String(d.getDate()).padStart(2, '0')}/${d.getFullYear()}`;
287
+
288
+ return { inicio: fmt(hoy), fin: fmt(fin) };
289
+ }
290
+
291
+ export function step(n: number, desc: string) {
292
+ console.log(` [#${n}] ${desc}`);
293
+ }
294
+
295
+ export function generateRunId() {
296
+ return Date.now().toString().slice(-4);
297
+ }