chore: baseline plantilla-proyectos como base del CRM
This commit is contained in:
188
frontend/e2e/0-setup-catalogs.spec.ts
Normal file
188
frontend/e2e/0-setup-catalogs.spec.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import * as fs from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import * as path from 'path'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const SHARED_FILE = path.join(__dirname, '.e2e-catalog.json')
|
||||
|
||||
function saveCatalog(data: Record<string, string>) {
|
||||
fs.writeFileSync(SHARED_FILE, JSON.stringify(data))
|
||||
}
|
||||
|
||||
// Clase y parte con fraccion valida del catalogo SITAR
|
||||
// 8471.30.01 es una fraccion comun para equipos de computo — existe en SITAR
|
||||
const CLASS_CODE = 'E2E01'
|
||||
const CLASS_FRACTION = '12787'
|
||||
// HTS-style code resolvable via SITAR fracciones-usa (8 or 10 digit patterns used in UI)
|
||||
const CLASS_US_FRACTION = '8471300100'
|
||||
const CLASS_UM = 'KGS'
|
||||
const CLASS_MATERIAL_KEY = 'MP'
|
||||
const CLASS_DESC_ES = 'Clase E2E Test'
|
||||
const CLASS_DESC_EN = 'E2E Test Class'
|
||||
|
||||
const PART_NUMBER = 'E2E-PART-001'
|
||||
const PART_DESC_ES = 'Parte E2E Test'
|
||||
|
||||
async function fillInput(page: Page, selector: string, value: string) {
|
||||
await page.locator(selector).click()
|
||||
await page.evaluate(({ sel, val }: { sel: string; val: string }) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement
|
||||
if (!el) return
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
setter?.call(el, val)
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}, { sel: selector, val: value })
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
test.describe('Setup — Catalogos para pruebas E2E', () => {
|
||||
|
||||
test('1. crear clase valida', async ({ page }) => {
|
||||
await page.goto('/dashboard/goods/fixed-asset-classes')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Abrir dialog de nueva clase
|
||||
await page.getByRole('button', { name: /Insertar|Nueva Clase|Nuevo/ }).first().click()
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
await expect(page.getByRole('dialog')).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Llenar campos
|
||||
await page.locator('#class_code').scrollIntoViewIfNeeded()
|
||||
await fillInput(page, '#class_code', CLASS_CODE)
|
||||
|
||||
await page.locator('#material_key').scrollIntoViewIfNeeded()
|
||||
await fillInput(page, '#material_key', CLASS_MATERIAL_KEY)
|
||||
|
||||
await page.locator('#description_es').scrollIntoViewIfNeeded()
|
||||
await fillInput(page, '#description_es', CLASS_DESC_ES)
|
||||
|
||||
await page.locator('#description_en').scrollIntoViewIfNeeded()
|
||||
await fillInput(page, '#description_en', CLASS_DESC_EN)
|
||||
|
||||
await page.locator('#unit_of_measure').scrollIntoViewIfNeeded()
|
||||
await fillInput(page, '#unit_of_measure', CLASS_UM)
|
||||
|
||||
await page.locator('#fraction').scrollIntoViewIfNeeded()
|
||||
await fillInput(page, '#fraction', CLASS_FRACTION)
|
||||
// El input #fraction abre TariffFractionSelector (SITAR) como dialog anidado.
|
||||
// El onclick está en el <tr> — hacer click forzado en la primera fila lo cierra.
|
||||
await page.waitForTimeout(1500)
|
||||
const sitarDialog = page.getByRole('dialog', { name: /SITAR/i })
|
||||
if (await sitarDialog.isVisible().catch(() => false)) {
|
||||
await sitarDialog.locator('tbody tr').first()
|
||||
.click({ force: true, timeout: 5000 })
|
||||
.catch(async () => {
|
||||
// Fallback: cerrar con Close si no podemos clickear fila
|
||||
await sitarDialog.getByRole('button', { name: /Close|Cerrar/ })
|
||||
.click({ force: true }).catch(() => {})
|
||||
})
|
||||
await sitarDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
|
||||
}
|
||||
|
||||
await page.locator('#us_fraction').scrollIntoViewIfNeeded()
|
||||
await fillInput(page, '#us_fraction', CLASS_US_FRACTION)
|
||||
await page.waitForTimeout(1500)
|
||||
// Mismo patrón para catálogo de fracciones americanas si abre.
|
||||
const usDialog = page.getByRole('dialog', { name: /AMERICANA|US/i })
|
||||
if (await usDialog.isVisible().catch(() => false)) {
|
||||
await usDialog.locator('tbody tr').first()
|
||||
.click({ force: true, timeout: 5000 })
|
||||
.catch(async () => {
|
||||
await usDialog.getByRole('button', { name: /Close|Cerrar/ })
|
||||
.click({ force: true }).catch(() => {})
|
||||
})
|
||||
await usDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
|
||||
}
|
||||
|
||||
// Guardar
|
||||
await page.getByRole('button', { name: /^Guardar$/ }).scrollIntoViewIfNeeded()
|
||||
await page.getByRole('button', { name: /^Guardar$/ }).click()
|
||||
await page.waitForTimeout(3000)
|
||||
|
||||
// Si aparece un error visible, mostrarlo para diagnóstico (no toleramos silencioso).
|
||||
const errorEl = page.locator('.text-destructive, [role="alert"]').first()
|
||||
if (await errorEl.isVisible({ timeout: 500 }).catch(() => false)) {
|
||||
const txt = await errorEl.textContent()
|
||||
console.log('[DEBUG][crear clase] Error visible:', txt)
|
||||
}
|
||||
|
||||
saveCatalog({ CLASS_CODE, PART_NUMBER })
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
})
|
||||
|
||||
test('2. verificar clase en tabla', async ({ page }) => {
|
||||
await page.goto('/dashboard/goods/fixed-asset-classes')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Buscar la clase creada en la tabla
|
||||
const classRow = page.locator('tbody').getByText(CLASS_CODE)
|
||||
if (await classRow.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await expect(classRow.first()).toBeVisible()
|
||||
} else {
|
||||
// La clase puede no aparecer si ya existia — OK
|
||||
console.log('Clase no encontrada en tabla — puede ya existir con otro nombre')
|
||||
}
|
||||
})
|
||||
|
||||
test('3. crear parte valida', async ({ page }) => {
|
||||
// Las partes se crean en /dashboard/goods/parts/edit/new
|
||||
await page.goto('/dashboard/goods/parts/edit/new')
|
||||
await page.waitForLoadState('networkidle')
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// PrerequisitesModal: AlertDialog "Aviso" que abre cuando la DB no tiene
|
||||
// Agentes aduanales o Clientes registrados (caso típico en E2E con DB limpia).
|
||||
// Bloquea pointer events con un overlay sobre todo el form. Cancelar te saca
|
||||
// del editor; Aceptar lo cierra y permite continuar.
|
||||
// Ver routes/dashboard/goods/parts/edit/[[id]]/+page.svelte:29-38.
|
||||
const prerequisitesDialog = page.getByRole('alertdialog', { name: /Aviso/i })
|
||||
if (await prerequisitesDialog.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await prerequisitesDialog.getByRole('button', { name: /^Aceptar$/ }).click()
|
||||
await prerequisitesDialog.waitFor({ state: 'hidden', timeout: 5000 }).catch(() => {})
|
||||
}
|
||||
|
||||
// Llenar campos del formulario de nueva parte
|
||||
// Numero de parte
|
||||
const partInput = page.locator('#part_number, input[placeholder*="parte"], input[placeholder*="número"], input[name="part_number"]').first()
|
||||
if (await partInput.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await partInput.scrollIntoViewIfNeeded()
|
||||
await fillInput(page, '#part_number', PART_NUMBER).catch(async () => {
|
||||
await partInput.fill(PART_NUMBER)
|
||||
})
|
||||
}
|
||||
|
||||
// Descripcion en español
|
||||
const descInput = page.locator('#description_es, textarea[placeholder*="español"], input[placeholder*="escripción"]').first()
|
||||
if (await descInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await descInput.scrollIntoViewIfNeeded()
|
||||
await descInput.fill(PART_DESC_ES).catch(() => {})
|
||||
}
|
||||
|
||||
// Clase — puede ser un input o select
|
||||
const claseInput = page.locator('#class_code, #clase, input[placeholder*="Clase"], input[placeholder*="clase"]').first()
|
||||
if (await claseInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await claseInput.scrollIntoViewIfNeeded()
|
||||
await claseInput.fill(CLASS_CODE).catch(() => {})
|
||||
await page.waitForTimeout(500)
|
||||
const option = page.getByRole('option').first()
|
||||
if (await option.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await option.click()
|
||||
}
|
||||
}
|
||||
|
||||
// Guardar
|
||||
const saveBtn = page.getByRole('button', { name: /Guardar|Crear|Insertar/ }).first()
|
||||
if (await saveBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await saveBtn.scrollIntoViewIfNeeded()
|
||||
await saveBtn.click()
|
||||
await page.waitForTimeout(1000)
|
||||
}
|
||||
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
})
|
||||
|
||||
})
|
||||
248
frontend/e2e/FlujoCompleto.MD
Normal file
248
frontend/e2e/FlujoCompleto.MD
Normal file
@@ -0,0 +1,248 @@
|
||||
# Reporte de Pruebas E2E — Flujo de Factura
|
||||
|
||||
**Proyecto:** Anexo 76 — Sistema de Control de Operaciones Aduaneras
|
||||
**Herramienta:** Playwright
|
||||
**Archivo:** `frontend/e2e/invoice-flow.spec.ts`
|
||||
**Fecha:** Abril 2026
|
||||
**Estado:** 12/12 pruebas pasando ✅
|
||||
**Tiempo de ejecución:** ~4.4 minutos
|
||||
|
||||
---
|
||||
|
||||
## Resumen
|
||||
|
||||
| Categoría | Pruebas | Estado |
|
||||
|-----------|---------|--------|
|
||||
| Prerrequisitos (proveedor, cliente, agente, TC) | 4 | ✅ |
|
||||
| Pedimento | 1 | ✅ |
|
||||
| Factura TEM | 3 | ✅ |
|
||||
| Partidas | 2 | ✅ |
|
||||
| Actualización final | 1 | ✅ |
|
||||
| **Total** | **11** | **✅** |
|
||||
|
||||
> Nota: el test de setup de autenticación (`auth.setup.ts`) suma 1 prueba adicional, totalizando 12 en el runner.
|
||||
|
||||
---
|
||||
|
||||
## Flujo completo
|
||||
|
||||
### 1. Crear proveedor
|
||||
|
||||
Navega a `/dashboard/clients_and_providers`, abre el formulario de nuevo socio, llena RFC y nombre, selecciona tipo "Proveedor" y guarda. Verifica redirección a la lista y que el nombre aparece en la tabla.
|
||||
|
||||
### 2. Crear cliente
|
||||
|
||||
Mismo flujo que el proveedor pero con tipo "Cliente".
|
||||
|
||||
### 3. Crear agente aduanal
|
||||
|
||||
Navega a `/dashboard/customs_brokers`, abre el formulario, llena clave, patente y nombre. Verifica toast de éxito y redirección.
|
||||
|
||||
### 4. Crear tipo de cambio
|
||||
|
||||
Navega a `/dashboard/general_catalogs/exchange-rate`, abre el modal de nuevo tipo de cambio, llena fecha de hoy y valor `17.5`, confirma. Verifica toast de éxito.
|
||||
|
||||
### 5. Crear pedimento
|
||||
|
||||
Navega a `/dashboard/pedimentos/edit/new`, llena año (`26`), selecciona Aduana, Patente, Clave, Tipo de Operación y Régimen via bits-ui Select. Llena número de pedimento. Guarda y verifica redirección a `/dashboard/pedimentos`.
|
||||
|
||||
### 6. Crear factura de importación TEM
|
||||
|
||||
Navega a `/dashboard/invoices/edit/new?operation_type=imp&invoice_type=TEM`. Llena número de factura con `pressSequentially`, fecha, y en la pestaña General selecciona proveedor, sold-to, shipped-to, agente aduanal, aduana y tipo de documento. Guarda y verifica toast de éxito. Al finalizar guarda el número de factura en `.e2e-shared.json` para los tests posteriores.
|
||||
|
||||
### 7. Factura aparece en la lista
|
||||
|
||||
Filtra por número de factura en la lista de importación y verifica que la fila es visible.
|
||||
|
||||
### 8. Agregar partida a la factura
|
||||
|
||||
Abre la factura desde la lista, navega a la pestaña Partidas, abre el sheet de nueva partida. Selecciona Clase, U.M. y País de Origen (cada uno abre un dialog con tabla). Llena cantidad (`10`), costo unitario (`100`), peso neto (`5`), peso bruto (`6`) y descripción en español. Hace click en el botón "Crear" del sheet. Guarda la factura completa.
|
||||
|
||||
### 9. Editar factura existente
|
||||
|
||||
Lee el número de factura desde `.e2e-shared.json`, la busca en la lista, la abre en modo edición. En la pestaña General vuelve a seleccionar agente aduanal, aduana y tipo de documento. Guarda y verifica toast de éxito.
|
||||
|
||||
### 10. Editar partida existente
|
||||
|
||||
Lee el número desde shared, abre la factura, va a pestaña Partidas. Hace click en el ícono Pencil de la primera fila para abrir el sheet de edición. Modifica cantidad (`20`) y costo unitario (`200`). Hace click en "Actualizar" del sheet. Guarda la factura.
|
||||
|
||||
### 11. Actualizar factura — verificación final
|
||||
|
||||
Lee el número desde shared, busca la factura en la lista, selecciona la fila, hace click en el botón "Actualizar" del footer (ícono RefreshCw, clase `h-8`). Verifica el resultado con toast de éxito.
|
||||
|
||||
---
|
||||
|
||||
## Patrones técnicos establecidos
|
||||
|
||||
### fillInput — inputs reactivos de Svelte 5
|
||||
|
||||
Los inputs de Svelte 5 no responden a `page.fill()` ni `pressSequentially` de forma confiable. La solución es usar el native setter del prototipo:
|
||||
|
||||
```typescript
|
||||
async function fillInput(page: Page, selector: string, value: string) {
|
||||
await page.locator(selector).click()
|
||||
await page.evaluate(({ sel, val }) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement
|
||||
const setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLInputElement.prototype, 'value'
|
||||
)?.set
|
||||
setter?.call(el, val)
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}, { sel: selector, val: value })
|
||||
await page.waitForTimeout(2000)
|
||||
}
|
||||
```
|
||||
|
||||
La excepción es `#invoice_number`, que sí responde a `pressSequentially` con delay:
|
||||
|
||||
```typescript
|
||||
await page.locator('#invoice_number').click()
|
||||
await page.keyboard.press('Control+A')
|
||||
await page.locator('#invoice_number').pressSequentially(INVOICE_NUMBER, { delay: 1000 })
|
||||
```
|
||||
|
||||
### bits-ui Select — selects con IDs dinámicos
|
||||
|
||||
Los selects de bits-ui generan IDs como `bits-s65` que cambian en cada render. La estrategia es seleccionarlos por el atributo `data-select-trigger` y posición:
|
||||
|
||||
```typescript
|
||||
const triggers = page.locator('[data-select-trigger]')
|
||||
await triggers.nth(0).click() // Aduana
|
||||
await page.getByRole('option').first().click()
|
||||
```
|
||||
|
||||
Para selects con IDs estables (facturas) se usa directamente:
|
||||
|
||||
```typescript
|
||||
await page.locator('#provider_id').click()
|
||||
await page.getByRole('option').first().click()
|
||||
```
|
||||
|
||||
### Dialogs anidados — clase, U.M., país de origen
|
||||
|
||||
Los campos Clase, U.M. y País de Origen abren un dialog de búsqueda encima del sheet. Para evitar que el sheet intercepte los clicks, se scopea al último dialog abierto:
|
||||
|
||||
```typescript
|
||||
await page.locator('#clase').click()
|
||||
await page.waitForTimeout(3000)
|
||||
const claseDialog = page.locator('[data-dialog-content]').last()
|
||||
await claseDialog.locator('tbody tr').first().click()
|
||||
```
|
||||
|
||||
### Botones dentro del sheet
|
||||
|
||||
El botón de guardar partida está dentro del sheet y puede ser interceptado. Se scopea explícitamente:
|
||||
|
||||
```typescript
|
||||
const sheet = page.locator('[data-slot="sheet-content"]')
|
||||
await sheet.getByRole('button', { name: /Crear/ }).click() // nueva partida
|
||||
await sheet.getByRole('button', { name: /Actualizar/ }).click() // editar partida
|
||||
```
|
||||
|
||||
### Distinguir botones ambiguos por clase CSS
|
||||
|
||||
Cuando hay múltiples botones con el mismo texto o ícono, se distinguen por clases CSS únicas:
|
||||
|
||||
```typescript
|
||||
// Botón Actualizar del footer (tiene h-8, border, RefreshCw)
|
||||
await page.locator('button.h-8:has([class*="lucide-refresh"])').click()
|
||||
```
|
||||
|
||||
### Compartir estado entre tests
|
||||
|
||||
Playwright corre cada test en un worker separado, por lo que `Date.now()` se reevalúa. Para compartir el número de factura entre tests se usa un archivo JSON:
|
||||
|
||||
```typescript
|
||||
// Al final del test 6
|
||||
saveShared({ INVOICE_NUMBER })
|
||||
|
||||
// En tests 7-11
|
||||
const shared = loadShared()
|
||||
const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER
|
||||
```
|
||||
|
||||
El archivo se guarda en `frontend/e2e/.e2e-shared.json`.
|
||||
|
||||
---
|
||||
|
||||
## Selectores de referencia
|
||||
|
||||
| Campo | Selector | Tipo |
|
||||
|-------|----------|------|
|
||||
| RFC | `#rfc` | input normal |
|
||||
| Nombre | `#name` | input normal |
|
||||
| Tipo de socio | `#type` | bits-ui Select |
|
||||
| Año pedimento | `#year` | input normal |
|
||||
| Número pedimento | `#pedimento_number` | input normal |
|
||||
| Aduana pedimento | `[data-select-trigger]` nth(0) | bits-ui Select |
|
||||
| Patente pedimento | `[data-select-trigger]` nth(1) | bits-ui Select |
|
||||
| Clave pedimento | `[data-select-trigger]` nth(2) | bits-ui Select |
|
||||
| Número factura | `#invoice_number` | input (pressSequentially) |
|
||||
| Fecha factura | `#invoice_date` | date input |
|
||||
| Proveedor | `#provider_id` | bits-ui Select |
|
||||
| Sold-to | `#sold_to_id` | bits-ui Select |
|
||||
| Shipped-to | `#shipped_to_id` | bits-ui Select |
|
||||
| Agente aduanal | `#customs_broker_id` | bits-ui Select |
|
||||
| Aduana factura | `#aduana` | bits-ui Select |
|
||||
| Tipo documento | `#document_type` | bits-ui Select |
|
||||
| Clase partida | `#clase` | input readonly → dialog |
|
||||
| U.M. | `#um` | input readonly → dialog |
|
||||
| País origen | `#pais_origen` | input readonly → dialog |
|
||||
| Cantidad | `#cantidad` | input number |
|
||||
| Costo unitario | `#costo_unitario` | input number |
|
||||
| Peso neto | `#peso_neto` | input number |
|
||||
| Peso bruto | `#peso_bruto` | input number |
|
||||
| Descripción ES | `#desc_espanol` | textarea |
|
||||
| Filtro número | `#filter-invoice-number` | input normal |
|
||||
|
||||
---
|
||||
|
||||
## Comandos
|
||||
|
||||
```bash
|
||||
# Flujo completo
|
||||
pnpm test:e2e --grep "Flujo completo"
|
||||
|
||||
# Test individual
|
||||
pnpm test:e2e --grep "5. crear pedimento"
|
||||
pnpm test:e2e --grep "8. agregar partida"
|
||||
|
||||
# Modo visual para debug
|
||||
pnpm test:e2e --grep "Flujo completo" --headed --timeout 120000
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Estructura de archivos
|
||||
|
||||
```
|
||||
frontend/e2e/
|
||||
├── .auth/
|
||||
│ └── user.json sesion de autenticacion
|
||||
├── .e2e-shared.json estado compartido entre tests (generado)
|
||||
├── auth.setup.ts 1 test — login y guardado de sesion
|
||||
├── invoice-flow.spec.ts 11 tests — flujo completo de factura
|
||||
├── full-flow.spec.ts 4 tests
|
||||
├── login.spec.ts 3 tests
|
||||
├── navigation.spec.ts 10 tests
|
||||
└── modules.spec.ts 10 tests
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Conteo total actualizado
|
||||
|
||||
| Suite | Pruebas |
|
||||
|-------|---------|
|
||||
| auth.setup.ts | 1 |
|
||||
| invoice-flow.spec.ts | 11 |
|
||||
| full-flow.spec.ts | 4 |
|
||||
| login.spec.ts | 3 |
|
||||
| navigation.spec.ts | 10 |
|
||||
| modules.spec.ts | 10 |
|
||||
| **Total Playwright** | **39** |
|
||||
|
||||
---
|
||||
|
||||
*Anexo 76 — Reporte de Pruebas E2E v4.0 — invoice-flow — Abril 2026*
|
||||
93
frontend/e2e/auth.setup.ts
Normal file
93
frontend/e2e/auth.setup.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Setup de autenticación para tests E2E de Playwright.
|
||||
*
|
||||
* El flujo de login de Anexo76 pasa por Workspace (workspace.aduanasoft.com):
|
||||
* 1. Navegar a /login → 303 redirect al formulario de Workspace
|
||||
* 2. Llenar el form de Workspace con el usuario de prueba (creado en globalSetup)
|
||||
* 3. Workspace/Keycloak redirige de vuelta a /auth/callback → /dashboard
|
||||
* 4. Guardar storageState para todos los tests dependientes
|
||||
*
|
||||
* El usuario de prueba es creado y eliminado por globalSetup / globalTeardown.
|
||||
* Patrón idéntico al de aduanasoft-hub.
|
||||
*/
|
||||
import { test as setup, expect } from '@playwright/test'
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const authFile = path.join(__dirname, '.auth/user.json')
|
||||
|
||||
setup('autenticacion', async ({ page }) => {
|
||||
const username = process.env.E2E_TEST_USER ?? ''
|
||||
const password = process.env.E2E_TEST_PASSWORD ?? ''
|
||||
|
||||
if (!username) throw new Error('E2E_TEST_USER no está configurado')
|
||||
if (!password) throw new Error('E2E_TEST_PASSWORD no está configurado')
|
||||
|
||||
// ── 1. Navegar a /login — Workspace intercepta y muestra su formulario ──
|
||||
await page.goto('/login')
|
||||
|
||||
// ── 2. Llenar el formulario de Workspace (mismos selectores que hub/login.spec.ts) ──
|
||||
await page.getByRole('textbox', { name: /usuario o email/i }).fill(username)
|
||||
await page.getByLabel(/contraseña/i).fill(password)
|
||||
await page.getByRole('button', { name: /continuar/i }).click()
|
||||
|
||||
// ── 3. Si Workspace muestra el app-launcher (usuario con múltiples apps),
|
||||
// elegir anexo76-dev — la app del launcher cuya URL apunta al frontend bajo prueba.
|
||||
// Cuando hay match directo de dominio, Workspace salta el launcher y este paso no ejecuta.
|
||||
try {
|
||||
await page.waitForURL(/\/(app-launcher|dashboard|auth\/callback)/, { timeout: 30_000 })
|
||||
} catch (e) {
|
||||
// Si seguimos en /login de Workspace, las credenciales fueron rechazadas o el form no aceptó.
|
||||
// Imprimir diagnóstico antes de fallar: URL actual + posibles mensajes de error visibles.
|
||||
const currentUrl = page.url()
|
||||
const errorMessages = await page.locator('.text-destructive, [role="alert"], .error, .invalid-feedback')
|
||||
.allTextContents()
|
||||
.catch(() => [] as string[])
|
||||
const visibleText = await page.locator('body').innerText().catch(() => '')
|
||||
console.log(`[auth.setup][DIAG] URL: ${currentUrl}`)
|
||||
console.log(`[auth.setup][DIAG] Mensajes de error: ${JSON.stringify(errorMessages)}`)
|
||||
console.log(`[auth.setup][DIAG] Body (primeros 800 chars): ${visibleText.slice(0, 800)}`)
|
||||
throw e
|
||||
}
|
||||
if (/\/app-launcher/.test(page.url())) {
|
||||
// Nuevo botón de Fixed Assets en el launcher de Workspace.
|
||||
// Intentar primero el botón de Fixed Asset y, si no existe (compatibilidad),
|
||||
// caer al botón legacy de anexo76-dev.
|
||||
const fixedAssetButton = page.getByRole('button', {
|
||||
name: /fixed asset|activo fijo/i
|
||||
})
|
||||
if (await fixedAssetButton.isVisible().catch(() => false)) {
|
||||
await fixedAssetButton.click()
|
||||
} else {
|
||||
await page.getByRole('button', { name: /^anexo76-dev/i }).click()
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Esperar redirect SSO con active_system=fixed_asset y luego /dashboard ──
|
||||
await page.waitForURL(/\/auth\/sso\?.*active_system=fixed_asset/, { timeout: 30_000 })
|
||||
await page.waitForURL(/\/dashboard/, { timeout: 30_000 })
|
||||
|
||||
// ── 5. ExchangeRateGuard abre un dialog modal en cada carga del dashboard
|
||||
// si no existe un tipo de cambio para hoy. El guard espera a que
|
||||
// companyStore.activeCompany?.id esté disponible (API async) antes de
|
||||
// consultar la DB y abrir el dialog. Esperar networkidle para que ese
|
||||
// flujo se complete antes de buscar el dialog.
|
||||
await page.waitForLoadState('networkidle', { timeout: 30_000 }).catch(() => {})
|
||||
const tcDialog = page.getByRole('dialog', { name: /Nuevo Tipo de Cambio/i })
|
||||
if (await tcDialog.isVisible({ timeout: 10_000 }).catch(() => false)) {
|
||||
const valueInput = tcDialog.getByRole('spinbutton', { name: /Tipo de Cambio/i })
|
||||
await tcDialog.getByRole('button', { name: /Consultar DOF/i }).click()
|
||||
// El backend consulta el DOF y llena el campo. Esperar a que tenga un valor.
|
||||
await expect(valueInput).not.toHaveValue('', { timeout: 15_000 })
|
||||
// "Ok" abre un AlertDialog de confirmación; el TC se crea hasta "Confirmar".
|
||||
await tcDialog.getByRole('button', { name: /^Ok$/ }).click()
|
||||
await page.getByRole('button', { name: /^Confirmar$/ }).click()
|
||||
await tcDialog.waitFor({ state: 'hidden', timeout: 10_000 })
|
||||
}
|
||||
|
||||
// ── 6. Guardar estado para los tests dependientes ─────────────────────────
|
||||
mkdirSync(path.dirname(authFile), { recursive: true })
|
||||
await page.context().storageState({ path: authFile })
|
||||
})
|
||||
6
frontend/e2e/demo.test.ts
Normal file
6
frontend/e2e/demo.test.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
|
||||
test('home page has expected h1', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page.locator('h1')).toBeVisible();
|
||||
});
|
||||
529
frontend/e2e/export-flow.spec.ts
Normal file
529
frontend/e2e/export-flow.spec.ts
Normal file
@@ -0,0 +1,529 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import * as fs from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import * as path from 'path'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const SHARED_FILE = path.join(__dirname, '.e2e-shared-exp.json')
|
||||
|
||||
function saveShared(data: Record<string, string>) {
|
||||
fs.writeFileSync(SHARED_FILE, JSON.stringify(data))
|
||||
}
|
||||
|
||||
function loadShared(): Record<string, string> {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(SHARED_FILE, 'utf-8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const SUFFIX = 'X' + Date.now().toString().slice(-5) // prefijo X para exportacion
|
||||
const TODAY = new Date().toISOString().split('T')[0]
|
||||
|
||||
const HOMOCLAVE = SUFFIX.slice(-3).toUpperCase()
|
||||
|
||||
const PROVEEDOR_RFC = `XAXX010101${HOMOCLAVE}`
|
||||
const PROVEEDOR_NOMBRE = `Proveedor E2E ${SUFFIX}`
|
||||
|
||||
const CLIENTE_RFC = `XBXX010101${HOMOCLAVE}`
|
||||
const CLIENTE_NOMBRE = `Cliente E2E ${SUFFIX}`
|
||||
|
||||
const BROKER_KEY = `T${SUFFIX.slice(-4)}`
|
||||
const BROKER_LICENSE = SUFFIX.slice(-4).replace(/^0/, '1')
|
||||
const INVOICE_NUMBER = `E2E-${SUFFIX}`
|
||||
|
||||
const PEDIMENTO_YEAR = '26'
|
||||
const PEDIMENTO_OFFICE = '240'
|
||||
const PEDIMENTO_LICENSE = '3101'
|
||||
const PEDIMENTO_NUMBER = SUFFIX.slice(-5) // 5 digitos para pedimento
|
||||
|
||||
async function fillInput(page: Page, selector: string, value: string) {
|
||||
await page.locator(selector).click()
|
||||
await page.evaluate(({ sel, val }: { sel: string; val: string }) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement
|
||||
if (!el) return
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
setter?.call(el, val)
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}, { sel: selector, val: value })
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
async function fillTextarea(page: Page, selector: string, value: string) {
|
||||
await page.locator(selector).click()
|
||||
await page.evaluate(({ sel, val }: { sel: string; val: string }) => {
|
||||
const el = document.querySelector(sel) as HTMLTextAreaElement
|
||||
if (!el) return
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
|
||||
setter?.call(el, val)
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}, { sel: selector, val: value })
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
test.describe('Flujo completo — creacion y actualizacion de factura EXPORTACION', () => {
|
||||
|
||||
test('1. crear proveedor', async ({ page }) => {
|
||||
await page.goto('/dashboard/clients_and_providers')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await page.getByRole('link', { name: /Nuevo/ }).click()
|
||||
await expect(page).toHaveURL(/edit/, { timeout: 10000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await fillInput(page, '#rfc', PROVEEDOR_RFC)
|
||||
await fillInput(page, '#name', PROVEEDOR_NOMBRE)
|
||||
|
||||
await page.locator('#type').click()
|
||||
await page.getByRole('option', { name: 'Proveedor' }).click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 })
|
||||
await expect(page.getByText(PROVEEDOR_NOMBRE)).toBeVisible()
|
||||
})
|
||||
|
||||
test('2. crear cliente', async ({ page }) => {
|
||||
await page.goto('/dashboard/clients_and_providers')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await page.getByRole('link', { name: /Nuevo/ }).click()
|
||||
await expect(page).toHaveURL(/edit/, { timeout: 10000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await fillInput(page, '#rfc', CLIENTE_RFC)
|
||||
await fillInput(page, '#name', CLIENTE_NOMBRE)
|
||||
|
||||
await page.locator('#type').click()
|
||||
await page.getByRole('option', { name: 'Cliente' }).click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 })
|
||||
await expect(page.getByText(CLIENTE_NOMBRE)).toBeVisible()
|
||||
})
|
||||
|
||||
test('3. crear agente aduanal', async ({ page }) => {
|
||||
await page.goto('/dashboard/customs_brokers')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await page.getByRole('link', { name: /Nuevo/ }).click()
|
||||
await expect(page).toHaveURL(/edit/, { timeout: 20000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await fillInput(page, 'input[placeholder="Ej. 550"]', BROKER_KEY)
|
||||
await fillInput(page, 'input[placeholder="Ej. 3421"]', BROKER_LICENSE)
|
||||
await fillInput(page, 'input[placeholder="Nombre oficial"]', `Agente E2E ${SUFFIX}`)
|
||||
|
||||
await page.getByRole('button', { name: /Guardar Agente|Actualizar Agente/ }).click()
|
||||
|
||||
await expect(page.getByText(/Agente creado|Agente actualizado/i)).toBeVisible({ timeout: 20000 })
|
||||
await expect(page).toHaveURL(/customs_brokers$/, { timeout: 25000 })
|
||||
})
|
||||
|
||||
test('4. crear tipo de cambio', async ({ page }) => {
|
||||
await page.goto('/dashboard/general_catalogs/exchange-rate')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Si ya existe TC del día (creado por auth.setup.ts), no intentar duplicar.
|
||||
const todayCell = page.locator('tbody').getByText(new Date().toLocaleDateString('es-MX'))
|
||||
if (await todayCell.first().isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
return
|
||||
}
|
||||
|
||||
// El botón se llama "Nuevo Registro"; antes era "Nuevo Tipo de Cambio".
|
||||
await page.getByRole('button', { name: /Nuevo Registro|Nuevo Tipo de Cambio/ }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Nuevo Tipo de Cambio' })).toBeVisible()
|
||||
|
||||
await page.locator('#date').fill(TODAY)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await fillInput(page, '#value', '17.5')
|
||||
|
||||
await page.getByRole('button', { name: /^Ok$/ }).click()
|
||||
await page.getByRole('button', { name: 'Confirmar' }).click()
|
||||
|
||||
await expect(page.getByText(/tipo de cambio creado|tipo de cambio actualizado/i)).toBeVisible({ timeout: 20000 })
|
||||
})
|
||||
|
||||
test('5. crear pedimento', async ({ page }) => {
|
||||
await page.goto('/dashboard/pedimentos/edit/new')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expect(page.getByText('Nuevo Pedimento')).toBeVisible({ timeout: 20000 })
|
||||
|
||||
const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ })
|
||||
if (await prereqModal.isVisible()) await prereqModal.click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Año — input con id estable
|
||||
await fillInput(page, '#year', PEDIMENTO_YEAR)
|
||||
|
||||
// Aduana — bits-ui Select, 1er trigger de la fila superior
|
||||
const triggers = page.locator('[data-select-trigger]')
|
||||
await triggers.nth(0).click()
|
||||
await page.waitForTimeout(800)
|
||||
await page.getByRole('option').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Patente — bits-ui Select, 2do trigger
|
||||
await triggers.nth(1).click()
|
||||
await page.waitForTimeout(800)
|
||||
await page.getByRole('option').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Número de pedimento — input con id estable
|
||||
await fillInput(page, '#pedimento_number', PEDIMENTO_NUMBER)
|
||||
|
||||
// Clave — bits-ui Select, 3er trigger
|
||||
await triggers.nth(2).click()
|
||||
await page.waitForTimeout(800)
|
||||
await page.getByRole('option').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Tipo de Operación — bits-ui Select, 4to trigger
|
||||
await triggers.nth(3).click()
|
||||
await page.waitForTimeout(800)
|
||||
await page.getByRole('option').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Régimen — bits-ui Select, 5to trigger
|
||||
await triggers.nth(4).click()
|
||||
await page.waitForTimeout(800)
|
||||
await page.getByRole('option').first().click()
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/pedimentos$/, { timeout: 20000 })
|
||||
})
|
||||
|
||||
test('6. crear factura de exportacion', async ({ page }) => {
|
||||
await page.goto('/dashboard/invoices/edit/new?operation_type=exp')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expect(page.getByText('Nueva Factura')).toBeVisible({ timeout: 20000 })
|
||||
|
||||
const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ })
|
||||
if (await prereqModal.isVisible()) await prereqModal.click()
|
||||
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
await page.locator('#invoice_number').click()
|
||||
await page.keyboard.press('Control+A')
|
||||
await page.locator('#invoice_number').pressSequentially(INVOICE_NUMBER, { delay: 50 })
|
||||
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
await page.locator('#invoice_date').fill(TODAY)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Helper: click en un botón-selector por su placeholder y elige la 1ra opción.
|
||||
const selectFirstByName = async (btnName: RegExp) => {
|
||||
await page.getByRole('button', { name: btnName }).first().click()
|
||||
await page.waitForTimeout(800)
|
||||
await page.getByRole('option').first().click({ force: true })
|
||||
await page.waitForTimeout(600)
|
||||
}
|
||||
|
||||
// Tipo de factura — sin ID, identificar por placeholder del botón
|
||||
await selectFirstByName(/^Tipo de factura$/)
|
||||
|
||||
await page.getByRole('tab', { name: /General/ }).click()
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
// Selectores con IDs estables (siguen existiendo)
|
||||
await page.locator('#provider_id').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.locator('#sold_to_id').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.locator('#shipped_to_id').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
// Estos ya no tienen ID — usar el placeholder del botón
|
||||
await selectFirstByName(/Agente Aduanal Mex/)
|
||||
await selectFirstByName(/Aduana y Secci[oó]n de Despacho/)
|
||||
await selectFirstByName(/Clave de R[eé]gimen Aduanero/)
|
||||
|
||||
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
|
||||
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
|
||||
// Guardar numero de factura para tests posteriores
|
||||
saveShared({ INVOICE_NUMBER })
|
||||
})
|
||||
|
||||
test('7. factura aparece en la lista de exportacion', async ({ page }) => {
|
||||
const shared = loadShared()
|
||||
const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER
|
||||
|
||||
await page.goto('/dashboard/invoices?operation_type=exp')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
|
||||
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber)
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
// TODO(AS-export-partida): El sheet "Nueva Partida" en exportación abre y se cierra
|
||||
// automáticamente antes de que el test pueda interactuar. Comportamiento estable en
|
||||
// importación. Probable causa: $effect que cierra el sheet cuando la factura de
|
||||
// exportación no tiene vinculación a una factura impo previa.
|
||||
// Requiere fix en item-sheet-fa.svelte; mientras tanto, 8, 10 y 11 quedan en fixme.
|
||||
test.fixme('8. agregar partida a la factura', async ({ page }) => {
|
||||
const shared = loadShared()
|
||||
const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER
|
||||
|
||||
await page.goto('/dashboard/invoices?operation_type=exp')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber)
|
||||
await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 20000 })
|
||||
|
||||
await page.locator('tbody').getByText(invoiceNumber).first().click()
|
||||
await page.getByRole('button', { name: /Editar/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Ir a pestaña Partidas. La página de export necesita tiempo extra para
|
||||
// hidratar todas las relaciones (factura, items existentes) antes de Agregar.
|
||||
await page.getByRole('tab', { name: /Partidas/ }).click()
|
||||
await page.waitForLoadState('networkidle', { timeout: 15000 }).catch(() => {})
|
||||
await page.waitForTimeout(5000)
|
||||
|
||||
// Abrir sheet de nueva partida. En exportación click+focus a veces no dispara;
|
||||
// usar Enter key tras focus. Reintentar si el sheet no aparece.
|
||||
const addBtn = page.getByRole('button', { name: /^Agregar Partidas$/ }).first()
|
||||
const sheetHeading = page.getByRole('heading', { name: /Nueva Partida/i })
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await addBtn.scrollIntoViewIfNeeded()
|
||||
await addBtn.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
if (await sheetHeading.isVisible({ timeout: 4000 }).catch(() => false)) break
|
||||
await page.waitForTimeout(1500)
|
||||
}
|
||||
await sheetHeading.waitFor({ state: 'visible', timeout: 15000 })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Helper para seleccionar en dialog y esperar cierre
|
||||
async function selectFromDialog(selector: string) {
|
||||
await page.locator(selector).click()
|
||||
await page.waitForTimeout(800)
|
||||
const dialogsBefore = await page.locator('[data-dialog-content]').count()
|
||||
await page.locator('[data-dialog-content]').last().locator('tbody tr').first().click()
|
||||
await page.waitForFunction(
|
||||
(count) => document.querySelectorAll('[data-dialog-content]').length < count,
|
||||
dialogsBefore,
|
||||
{ timeout: 10000 }
|
||||
).catch(() => {})
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
// Exportacion requiere vincular a una factura de importacion
|
||||
// El bloque "Factura Impo" aparece en el sheet — click en el input readonly
|
||||
const sheet = page.locator('[data-slot="sheet-content"]')
|
||||
const facturaImpoInput = sheet.locator('input[placeholder="Seleccionar factura..."]').first()
|
||||
if (await facturaImpoInput.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
await facturaImpoInput.click()
|
||||
await page.waitForTimeout(500)
|
||||
// InvoiceSelectorModal abre con campo de busqueda
|
||||
const modalDialog = page.locator('[data-dialog-content]').last()
|
||||
if (await modalDialog.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
// Buscar la factura de importacion por numero para encontrarla
|
||||
const searchInput = modalDialog.locator('input[placeholder*="número"], input[placeholder*="numero"], input[type="search"], input[type="text"]').first()
|
||||
if (await searchInput.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
// Leer numero desde shared file del flujo de importacion
|
||||
const sharedImp = loadShared()
|
||||
await searchInput.fill(sharedImp.INVOICE_NUMBER || '')
|
||||
// Click en buscar si hay boton
|
||||
const buscarBtn = modalDialog.getByRole('button', { name: /Buscar/i })
|
||||
if (await buscarBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
|
||||
await buscarBtn.click()
|
||||
}
|
||||
await page.waitForTimeout(800)
|
||||
}
|
||||
// Seleccionar primera fila visible
|
||||
const firstRow = modalDialog.locator('tbody tr').first()
|
||||
if (await firstRow.isVisible({ timeout: 5000 }).catch(() => false)) {
|
||||
await firstRow.click()
|
||||
await page.waitForTimeout(800)
|
||||
} else {
|
||||
// No hay facturas procesadas — cerrar modal y continuar sin vincular
|
||||
await page.keyboard.press('Escape')
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
// Seleccionar linea si el input esta habilitado
|
||||
await page.waitForTimeout(500)
|
||||
const lineaInput = sheet.locator('#fa_search_line')
|
||||
if (await lineaInput.isEnabled({ timeout: 3000 }).catch(() => false)) {
|
||||
await lineaInput.click()
|
||||
await page.waitForTimeout(800)
|
||||
const lineDialog = page.locator('[data-dialog-content]').last()
|
||||
if (await lineDialog.isVisible({ timeout: 3000 }).catch(() => false)) {
|
||||
const lineBtn = lineDialog.getByRole('button').first()
|
||||
if (await lineBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
await lineBtn.click()
|
||||
await page.waitForTimeout(500)
|
||||
} else {
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await selectFromDialog('#clase')
|
||||
await selectFromDialog('#um')
|
||||
await selectFromDialog('#pais_origen')
|
||||
|
||||
await page.keyboard.press('Escape')
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Cantidad
|
||||
await fillInput(page, '#cantidad', '10')
|
||||
|
||||
// Costo unitario
|
||||
await fillInput(page, '#costo_unitario', '100')
|
||||
|
||||
// Peso neto y bruto — requeridos por el backend
|
||||
await fillInput(page, '#peso_neto', '5')
|
||||
await fillInput(page, '#peso_bruto', '6')
|
||||
|
||||
// Descripción en español (textarea)
|
||||
await fillTextarea(page, '#desc_espanol', `Partida E2E ${SUFFIX}`)
|
||||
|
||||
// Guardar partida — botón "Crear" dentro del sheet
|
||||
await sheet.getByRole('button', { name: /^(Crear|Guardar)$/i }).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Verificar que la partida aparece en la tabla
|
||||
await expect(page.locator('tbody').first()).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Guardar factura completa
|
||||
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
|
||||
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('9. editar factura existente', async ({ page }) => {
|
||||
await page.goto('/dashboard/invoices?operation_type=exp')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const shared9 = loadShared()
|
||||
const invoiceNumber9 = shared9.INVOICE_NUMBER || INVOICE_NUMBER
|
||||
|
||||
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber9)
|
||||
await expect(page.locator('tbody').getByText(invoiceNumber9).first()).toBeVisible({ timeout: 20000 })
|
||||
|
||||
await page.locator('tbody').getByText(invoiceNumber9).first().click()
|
||||
await page.getByRole('button', { name: /Editar/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expect(page.getByText(/Factura #/)).toBeVisible()
|
||||
|
||||
await page.getByRole('tab', { name: /General/ }).click()
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
await page.locator('#customs_broker_id').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.locator('#aduana').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.locator('#document_type').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
|
||||
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test.fixme('10. editar partida existente', async ({ page }) => {
|
||||
// Depende de test 8 (sheet inestable en exportación) — ver TODO arriba.
|
||||
const shared10 = loadShared()
|
||||
const invoiceNumber10 = shared10.INVOICE_NUMBER || INVOICE_NUMBER
|
||||
|
||||
await page.goto('/dashboard/invoices?operation_type=exp')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber10)
|
||||
await expect(page.locator('tbody').getByText(invoiceNumber10).first()).toBeVisible({ timeout: 20000 })
|
||||
|
||||
await page.locator('tbody').getByText(invoiceNumber10).first().click()
|
||||
await page.getByRole('button', { name: /Editar/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Ir a pestaña Partidas
|
||||
await page.getByRole('tab', { name: /Partidas/ }).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Abrir sheet de edición — la fila de la partida contiene "E2E01"; el primer botón
|
||||
// de la cell de acciones es editar.
|
||||
const partidaRow = page.locator('tbody tr').filter({ hasText: 'E2E01' }).first()
|
||||
await partidaRow.locator('button').first().click({ force: true, timeout: 10000 })
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Modificar cantidad
|
||||
await fillInput(page, '#cantidad', '20')
|
||||
|
||||
// Modificar costo unitario
|
||||
await fillInput(page, '#costo_unitario', '200')
|
||||
|
||||
// Guardar partida editada — botón "Actualizar" dentro del sheet
|
||||
const sheetEdit = page.locator('[data-slot="sheet-content"]')
|
||||
await sheetEdit.getByRole('button', { name: /^(Actualizar|Guardar)$/i }).click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Guardar factura completa
|
||||
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
|
||||
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test.fixme('11. actualizar factura — verificacion final', async ({ page }) => {
|
||||
// Depende de tests 8 y 10 (sheet inestable en exportación) — ver TODO arriba.
|
||||
const shared11 = loadShared()
|
||||
const invoiceNumber11 = shared11.INVOICE_NUMBER || INVOICE_NUMBER
|
||||
|
||||
await page.goto('/dashboard/invoices?operation_type=exp')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber11)
|
||||
await expect(page.locator('tbody').getByText(invoiceNumber11).first()).toBeVisible({ timeout: 20000 })
|
||||
|
||||
// Seleccionar la fila
|
||||
await page.locator('tbody tr').first().click()
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// Click en boton Actualizar del footer — tiene h-8, gap-1.5 y border
|
||||
await page.locator('button.h-8:has([class*="lucide-refresh"])').click()
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
// Verificar que el proceso se ejecuto — puede ser exito o error de validacion de datos
|
||||
// El test verifica que el flujo llega hasta el procesamiento, no que los datos sean correctos
|
||||
await expect(
|
||||
page.getByText(/actualiz|procesad|exito|validaci|error/i).first()
|
||||
).toBeVisible({ timeout: 20000 })
|
||||
})
|
||||
|
||||
})
|
||||
453
frontend/e2e/invoice-flow.spec.ts
Normal file
453
frontend/e2e/invoice-flow.spec.ts
Normal file
@@ -0,0 +1,453 @@
|
||||
import { test, expect, type Page } from '@playwright/test'
|
||||
import * as fs from 'fs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import * as path from 'path'
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url)
|
||||
const __dirname = path.dirname(__filename)
|
||||
const SHARED_FILE = path.join(__dirname, '.e2e-shared.json')
|
||||
|
||||
function saveShared(data: Record<string, string>) {
|
||||
fs.writeFileSync(SHARED_FILE, JSON.stringify(data))
|
||||
}
|
||||
|
||||
function loadShared(): Record<string, string> {
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(SHARED_FILE, 'utf-8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
const SUFFIX = Date.now().toString().slice(-6)
|
||||
const TODAY = new Date().toISOString().split('T')[0]
|
||||
|
||||
const HOMOCLAVE = SUFFIX.slice(-3).toUpperCase()
|
||||
|
||||
const PROVEEDOR_RFC = `XAXX010101${HOMOCLAVE}`
|
||||
const PROVEEDOR_NOMBRE = `Proveedor E2E ${SUFFIX}`
|
||||
|
||||
const CLIENTE_RFC = `XBXX010101${HOMOCLAVE}`
|
||||
const CLIENTE_NOMBRE = `Cliente E2E ${SUFFIX}`
|
||||
|
||||
const BROKER_KEY = `T${SUFFIX.slice(-4)}`
|
||||
const BROKER_LICENSE = SUFFIX.slice(-4).replace(/^0/, '1')
|
||||
const INVOICE_NUMBER = `E2E-${SUFFIX}`
|
||||
|
||||
const PEDIMENTO_YEAR = '26'
|
||||
const PEDIMENTO_OFFICE = '240'
|
||||
const PEDIMENTO_LICENSE = '3101'
|
||||
const PEDIMENTO_NUMBER = `${SUFFIX}`
|
||||
|
||||
async function fillInput(page: Page, selector: string, value: string) {
|
||||
await page.locator(selector).click()
|
||||
await page.evaluate(({ sel, val }: { sel: string; val: string }) => {
|
||||
const el = document.querySelector(sel) as HTMLInputElement
|
||||
if (!el) return
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLInputElement.prototype, 'value')?.set
|
||||
setter?.call(el, val)
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}, { sel: selector, val: value })
|
||||
await page.waitForTimeout(300)
|
||||
}
|
||||
|
||||
async function fillTextarea(page: Page, selector: string, value: string) {
|
||||
await page.locator(selector).click()
|
||||
await page.evaluate(({ sel, val }: { sel: string; val: string }) => {
|
||||
const el = document.querySelector(sel) as HTMLTextAreaElement
|
||||
if (!el) return
|
||||
const setter = Object.getOwnPropertyDescriptor(window.HTMLTextAreaElement.prototype, 'value')?.set
|
||||
setter?.call(el, val)
|
||||
el.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
el.dispatchEvent(new Event('change', { bubbles: true }))
|
||||
}, { sel: selector, val: value })
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
|
||||
test.describe('Flujo completo — creacion y actualizacion de factura', () => {
|
||||
|
||||
test('1. crear proveedor', async ({ page }) => {
|
||||
await page.goto('/dashboard/clients_and_providers')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await page.getByRole('link', { name: /Nuevo/ }).click()
|
||||
await expect(page).toHaveURL(/edit/, { timeout: 10000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await fillInput(page, '#rfc', PROVEEDOR_RFC)
|
||||
await fillInput(page, '#name', PROVEEDOR_NOMBRE)
|
||||
|
||||
await page.locator('#type').click()
|
||||
await page.getByRole('option', { name: 'Proveedor' }).click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 })
|
||||
await expect(page.getByText(PROVEEDOR_NOMBRE)).toBeVisible()
|
||||
})
|
||||
|
||||
test('2. crear cliente', async ({ page }) => {
|
||||
await page.goto('/dashboard/clients_and_providers')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await page.getByRole('link', { name: /Nuevo/ }).click()
|
||||
await expect(page).toHaveURL(/edit/, { timeout: 10000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await fillInput(page, '#rfc', CLIENTE_RFC)
|
||||
await fillInput(page, '#name', CLIENTE_NOMBRE)
|
||||
|
||||
await page.locator('#type').click()
|
||||
await page.getByRole('option', { name: 'Cliente' }).click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await page.getByRole('button', { name: /Guardar Socio|Actualizar Socio/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/clients_and_providers$/, { timeout: 15000 })
|
||||
await expect(page.getByText(CLIENTE_NOMBRE)).toBeVisible()
|
||||
})
|
||||
|
||||
test('3. crear agente aduanal', async ({ page }) => {
|
||||
await page.goto('/dashboard/customs_brokers')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await page.getByRole('link', { name: /Nuevo/ }).click()
|
||||
await expect(page).toHaveURL(/edit/, { timeout: 20000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await fillInput(page, 'input[placeholder="Ej. 550"]', BROKER_KEY)
|
||||
await fillInput(page, 'input[placeholder="Ej. 3421"]', BROKER_LICENSE)
|
||||
await fillInput(page, 'input[placeholder="Nombre oficial"]', `Agente E2E ${SUFFIX}`)
|
||||
|
||||
await page.getByRole('button', { name: /Guardar Agente|Actualizar Agente/ }).click()
|
||||
|
||||
await expect(page.getByText(/Agente creado|Agente actualizado/i)).toBeVisible({ timeout: 20000 })
|
||||
await expect(page).toHaveURL(/customs_brokers$/, { timeout: 25000 })
|
||||
})
|
||||
|
||||
test('4. crear tipo de cambio', async ({ page }) => {
|
||||
await page.goto('/dashboard/general_catalogs/exchange-rate')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Si ya existe TC del día (creado por auth.setup.ts), no intentar duplicar.
|
||||
const todayCell = page.locator('tbody').getByText(new Date().toLocaleDateString('es-MX'))
|
||||
if (await todayCell.first().isVisible({ timeout: 2000 }).catch(() => false)) {
|
||||
return
|
||||
}
|
||||
|
||||
// El botón se llama "Nuevo Registro"; antes era "Nuevo Tipo de Cambio".
|
||||
await page.getByRole('button', { name: /Nuevo Registro|Nuevo Tipo de Cambio/ }).click()
|
||||
await expect(page.getByRole('heading', { name: 'Nuevo Tipo de Cambio' })).toBeVisible()
|
||||
|
||||
await page.locator('#date').fill(TODAY)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await fillInput(page, '#value', '17.5')
|
||||
|
||||
await page.getByRole('button', { name: /^Ok$/ }).click()
|
||||
await page.getByRole('button', { name: 'Confirmar' }).click()
|
||||
|
||||
await expect(page.getByText(/tipo de cambio creado|tipo de cambio actualizado/i)).toBeVisible({ timeout: 20000 })
|
||||
})
|
||||
|
||||
test('5. crear pedimento', async ({ page }) => {
|
||||
await page.goto('/dashboard/pedimentos/edit/new')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expect(page.getByText('Nuevo Pedimento')).toBeVisible({ timeout: 20000 })
|
||||
|
||||
const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ })
|
||||
if (await prereqModal.isVisible()) await prereqModal.click()
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Año — input con id estable
|
||||
await fillInput(page, '#year', PEDIMENTO_YEAR)
|
||||
|
||||
// Aduana — bits-ui Select, 1er trigger de la fila superior
|
||||
const triggers = page.locator('[data-select-trigger]')
|
||||
await triggers.nth(0).click()
|
||||
await page.waitForTimeout(800)
|
||||
await page.getByRole('option').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Patente — bits-ui Select, 2do trigger
|
||||
await triggers.nth(1).click()
|
||||
await page.waitForTimeout(800)
|
||||
await page.getByRole('option').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Número de pedimento — input con id estable
|
||||
await fillInput(page, '#pedimento_number', PEDIMENTO_NUMBER)
|
||||
|
||||
// Clave — bits-ui Select, 3er trigger
|
||||
await triggers.nth(2).click()
|
||||
await page.waitForTimeout(800)
|
||||
await page.getByRole('option').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Tipo de Operación — bits-ui Select, 4to trigger
|
||||
await triggers.nth(3).click()
|
||||
await page.waitForTimeout(800)
|
||||
await page.getByRole('option').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Régimen — bits-ui Select, 5to trigger
|
||||
await triggers.nth(4).click()
|
||||
await page.waitForTimeout(800)
|
||||
await page.getByRole('option').first().click()
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/pedimentos$/, { timeout: 20000 })
|
||||
await expect(page.getByText(/Pedimento creado/i)).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('6. crear factura de importacion TEM', async ({ page }) => {
|
||||
await page.goto('/dashboard/invoices/edit/new?operation_type=imp&invoice_type=TEM')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expect(page.getByText('Nueva Factura')).toBeVisible({ timeout: 20000 })
|
||||
|
||||
const prereqModal = page.getByRole('button', { name: /Continuar|Aceptar/ })
|
||||
if (await prereqModal.isVisible()) await prereqModal.click()
|
||||
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
await page.locator('#invoice_number').click()
|
||||
await page.keyboard.press('Control+A')
|
||||
await page.locator('#invoice_number').pressSequentially(INVOICE_NUMBER, { delay: 50 })
|
||||
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
await page.locator('#invoice_date').fill(TODAY)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
await page.getByRole('tab', { name: /General/ }).click()
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
await page.locator('#provider_id').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.locator('#sold_to_id').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.locator('#shipped_to_id').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.locator('#customs_broker_id').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.locator('#aduana').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.locator('#document_type').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
|
||||
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
|
||||
// Guardar numero de factura para tests posteriores
|
||||
saveShared({ INVOICE_NUMBER })
|
||||
})
|
||||
|
||||
test('7. factura aparece en la lista de importacion', async ({ page }) => {
|
||||
const shared = loadShared()
|
||||
const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER
|
||||
|
||||
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
|
||||
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber)
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('8. agregar partida a la factura', async ({ page }) => {
|
||||
const shared = loadShared()
|
||||
const invoiceNumber = shared.INVOICE_NUMBER || INVOICE_NUMBER
|
||||
|
||||
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber)
|
||||
await expect(page.locator('tbody').getByText(invoiceNumber).first()).toBeVisible({ timeout: 20000 })
|
||||
|
||||
await page.locator('tbody').getByText(invoiceNumber).first().click()
|
||||
await page.getByRole('button', { name: /Editar/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Ir a pestaña Partidas
|
||||
await page.getByRole('tab', { name: /Partidas/ }).click()
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// Abrir sheet de nueva partida
|
||||
await page.getByRole('button', { name: /Agregar Partidas/ }).click()
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Clase — abre un dialog de búsqueda con tabla, scopear al dialog activo
|
||||
await page.locator('#clase').click()
|
||||
await page.waitForTimeout(800)
|
||||
// El dialog de clase tiene data-nested y está encima del sheet
|
||||
// Scopear al último dialog abierto para evitar que el sheet intercepte
|
||||
const claseDialog = page.locator('[data-dialog-content]').last()
|
||||
await claseDialog.locator('tbody tr').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Unidad de medida — mismo patron
|
||||
await page.locator('#um').click()
|
||||
await page.waitForTimeout(800)
|
||||
const umDialog = page.locator('[data-dialog-content]').last()
|
||||
await umDialog.locator('tbody tr').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// País de origen — abre dialog con tabla igual que clase y UM
|
||||
await page.locator('#pais_origen').click()
|
||||
await page.waitForTimeout(800)
|
||||
const paisDialog = page.locator('[data-dialog-content]').last()
|
||||
await paisDialog.locator('tbody tr').first().click()
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Cantidad
|
||||
await fillInput(page, '#cantidad', '10')
|
||||
|
||||
// Costo unitario
|
||||
await fillInput(page, '#costo_unitario', '100')
|
||||
|
||||
// Peso neto y bruto — requeridos por el backend
|
||||
await fillInput(page, '#peso_neto', '5')
|
||||
await fillInput(page, '#peso_bruto', '6')
|
||||
|
||||
// Descripción en español (textarea)
|
||||
await fillTextarea(page, '#desc_espanol', `Partida E2E ${SUFFIX}`)
|
||||
|
||||
// Guardar partida — botón "Crear" dentro del sheet
|
||||
const sheet = page.locator('[data-slot="sheet-content"]')
|
||||
await sheet.getByRole('button', { name: /^(Crear|Guardar)$/i }).click()
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// Verificar que la partida aparece en la tabla
|
||||
await expect(page.locator('tbody').first()).toBeVisible({ timeout: 10000 })
|
||||
|
||||
// Guardar factura completa
|
||||
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
|
||||
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('9. editar factura existente', async ({ page }) => {
|
||||
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
const shared9 = loadShared()
|
||||
const invoiceNumber9 = shared9.INVOICE_NUMBER || INVOICE_NUMBER
|
||||
|
||||
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber9)
|
||||
await expect(page.locator('tbody').getByText(invoiceNumber9).first()).toBeVisible({ timeout: 20000 })
|
||||
|
||||
await page.locator('tbody').getByText(invoiceNumber9).first().click()
|
||||
await page.getByRole('button', { name: /Editar/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await expect(page.getByText(/Factura #/)).toBeVisible()
|
||||
|
||||
await page.getByRole('tab', { name: /General/ }).click()
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
await page.locator('#customs_broker_id').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.locator('#aduana').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.locator('#document_type').click()
|
||||
await page.waitForTimeout(500)
|
||||
await page.getByRole('option').first().click()
|
||||
|
||||
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
|
||||
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('10. editar partida existente', async ({ page }) => {
|
||||
const shared10 = loadShared()
|
||||
const invoiceNumber10 = shared10.INVOICE_NUMBER || INVOICE_NUMBER
|
||||
|
||||
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber10)
|
||||
await expect(page.locator('tbody').getByText(invoiceNumber10).first()).toBeVisible({ timeout: 20000 })
|
||||
|
||||
await page.locator('tbody').getByText(invoiceNumber10).first().click()
|
||||
await page.getByRole('button', { name: /Editar/ }).click()
|
||||
|
||||
await expect(page).toHaveURL(/invoices\/edit\/\d+/, { timeout: 10000 })
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
// Ir a pestaña Partidas
|
||||
await page.getByRole('tab', { name: /Partidas/ }).click()
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// Abrir sheet de edición — la fila de la partida contiene "E2E01"; el primer botón
|
||||
// de la cell de acciones es editar.
|
||||
const partidaRow = page.locator('tbody tr').filter({ hasText: 'E2E01' }).first()
|
||||
await partidaRow.locator('button').first().click({ force: true, timeout: 10000 })
|
||||
await page.waitForTimeout(1000)
|
||||
|
||||
// Modificar cantidad
|
||||
await fillInput(page, '#cantidad', '20')
|
||||
|
||||
// Modificar costo unitario
|
||||
await fillInput(page, '#costo_unitario', '200')
|
||||
|
||||
// Guardar partida editada — botón "Actualizar" dentro del sheet
|
||||
const sheetEdit = page.locator('[data-slot="sheet-content"]')
|
||||
await sheetEdit.getByRole('button', { name: /^(Actualizar|Guardar)$/i }).click()
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// Guardar factura completa
|
||||
await page.getByRole('button', { name: /Guardar Todos los Cambios/ }).click()
|
||||
await expect(page.getByText('Todos los cambios se guardaron correctamente')).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
test('11. actualizar factura — verificacion final', async ({ page }) => {
|
||||
const shared11 = loadShared()
|
||||
const invoiceNumber11 = shared11.INVOICE_NUMBER || INVOICE_NUMBER
|
||||
|
||||
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM')
|
||||
await page.waitForLoadState('networkidle')
|
||||
|
||||
await fillInput(page, '#filter-invoice-number, input[placeholder="No. Factura"]', invoiceNumber11)
|
||||
await expect(page.locator('tbody').getByText(invoiceNumber11).first()).toBeVisible({ timeout: 20000 })
|
||||
|
||||
// Seleccionar la fila
|
||||
await page.locator('tbody tr').first().click()
|
||||
await page.waitForTimeout(800)
|
||||
|
||||
// Click en boton Actualizar del footer — tiene h-8, gap-1.5 y border
|
||||
await page.locator('button.h-8:has([class*="lucide-refresh"])').click()
|
||||
await page.waitForTimeout(1500)
|
||||
|
||||
// Verificar resultado
|
||||
await expect(page.getByText(/actualiz|procesad|exito/i).first()).toBeVisible({ timeout: 20000 })
|
||||
})
|
||||
|
||||
})
|
||||
17
frontend/e2e/login.spec.ts
Normal file
17
frontend/e2e/login.spec.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
// El login local fue reemplazado por SSO de Workspace; auth.setup.ts cubre el flujo de auth.
|
||||
// Este spec mantiene solo verificaciones del dashboard tras el setup.
|
||||
test.describe('Login', () => {
|
||||
|
||||
test('sesión válida lleva al dashboard', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(page).toHaveURL(/dashboard/, { timeout: 15000 })
|
||||
})
|
||||
|
||||
test('dashboard muestra encabezado', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.locator('h1')).toBeVisible({ timeout: 15000 })
|
||||
})
|
||||
|
||||
})
|
||||
108
frontend/e2e/modules.spec.ts
Normal file
108
frontend/e2e/modules.spec.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Modulos', () => {
|
||||
|
||||
test.describe('Import Invoices', () => {
|
||||
|
||||
test('factura TEM carga sin error', async ({ page }) => {
|
||||
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=TEM')
|
||||
await expect(page).toHaveURL(/invoices/)
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
})
|
||||
|
||||
test('factura DEF carga sin error', async ({ page }) => {
|
||||
await page.goto('/dashboard/invoices?operation_type=imp&invoice_type=DEF')
|
||||
await expect(page).toHaveURL(/invoices/)
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
test.describe('Export Invoices', () => {
|
||||
|
||||
test('exportacion carga sin error', async ({ page }) => {
|
||||
await page.goto('/dashboard/invoices?operation_type=exp')
|
||||
await expect(page).toHaveURL(/invoices/)
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
test.describe('Fixed Catalogs', () => {
|
||||
|
||||
test('carga sin error', async ({ page }) => {
|
||||
await page.goto('/dashboard/reference_data/code_pedimento_regimens')
|
||||
await expect(page).toHaveURL(/reference_data/)
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
test.describe('General Catalogs', () => {
|
||||
|
||||
test('company information carga sin error', async ({ page }) => {
|
||||
await page.goto('/dashboard/general_catalogs/company_information')
|
||||
await expect(page).toHaveURL(/company_information/)
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
test.describe('Transportes', () => {
|
||||
|
||||
test('transporters carga sin error', async ({ page }) => {
|
||||
await page.goto('/dashboard/general_catalogs/transporters')
|
||||
await expect(page).toHaveURL(/transporters/)
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
test.describe('Goods', () => {
|
||||
|
||||
test('fixed asset classes carga sin error', async ({ page }) => {
|
||||
await page.goto('/dashboard/goods/fixed-asset-classes')
|
||||
await expect(page).toHaveURL(/goods/)
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
test.describe('Settings', () => {
|
||||
|
||||
test('general carga sin error', async ({ page }) => {
|
||||
await page.goto('/dashboard/settings/general')
|
||||
await expect(page).toHaveURL(/settings/)
|
||||
await expect(page.locator('body')).toBeVisible()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
test.describe('Reportes', () => {
|
||||
|
||||
test('invoices carga sin error', async ({ page }) => {
|
||||
await page.goto('/dashboard/reports/invoices')
|
||||
await expect(page).toHaveURL(/reports/)
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
test.describe('Logout', () => {
|
||||
|
||||
test('cerrar sesion redirige a login', async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
await page.waitForLoadState('networkidle')
|
||||
// Abrir el dropdown del usuario; el trigger es un Sidebar.MenuButton que
|
||||
// puede no responder al click central → forzar con teclado.
|
||||
const userBtn = page.getByRole('button').filter({ hasText: '@' }).first()
|
||||
await userBtn.focus()
|
||||
await page.keyboard.press('Enter')
|
||||
// El menú está parcialmente en inglés ("Log out").
|
||||
await page.getByRole('menuitem', { name: /Log out|Cerrar sesión/i }).click({ timeout: 10000 })
|
||||
await expect(page).toHaveURL(/(login|workspace\.aduanasoft\.com)/, { timeout: 15000 })
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
79
frontend/e2e/navigation.spec.ts
Normal file
79
frontend/e2e/navigation.spec.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { test, expect } from '@playwright/test'
|
||||
|
||||
test.describe('Navegacion', () => {
|
||||
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.goto('/dashboard')
|
||||
})
|
||||
|
||||
test('dashboard carga con saludo', async ({ page }) => {
|
||||
await expect(page.locator('h1')).toBeVisible()
|
||||
})
|
||||
|
||||
test('header muestra nombre de la empresa', async ({ page }) => {
|
||||
await page.waitForLoadState('networkidle')
|
||||
// El nombre exacto depende de la empresa activa; aceptar cualquier nombre no vacío en el botón del sidebar
|
||||
await expect(page.locator('[data-sidebar]').getByRole('button').first())
|
||||
.toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test.describe('Menu lateral', () => {
|
||||
// El sidebar tiene links directos y grupos plegables que cargan según permisos.
|
||||
// Verificamos que aparezcan los textos en alguna parte del menú (botón o link).
|
||||
|
||||
test('tiene Bitácora en el menu', async ({ page }) => {
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page.getByRole('link', { name: 'Bitácora' }).or(
|
||||
page.getByRole('button', { name: 'Bitácora' })
|
||||
).first()).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('tiene Agentes Aduanales en el menu', async ({ page }) => {
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page.getByRole('link', { name: 'Agentes Aduanales' })).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('Fracciones aparece en el menu', async ({ page }) => {
|
||||
await page.waitForLoadState('networkidle')
|
||||
await expect(page.getByText('Fracciones').first()).toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
test('Pedimentos aparece en el menu', async ({ page }) => {
|
||||
await page.waitForLoadState('networkidle')
|
||||
// "Pedimentos" aparece como botón top-level del sidebar (no como sub-items plegados).
|
||||
await expect(page.getByRole('button', { name: /^Pedimentos$/ }).first())
|
||||
.toBeVisible({ timeout: 10000 })
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
test.describe('Modulos accesibles', () => {
|
||||
// Navegamos directo por URL — el menú lateral es dinámico y no garantiza link visible.
|
||||
|
||||
test('Bitácora carga sin error', async ({ page }) => {
|
||||
await page.goto('/dashboard/audit_logs')
|
||||
await expect(page).toHaveURL(/audit/)
|
||||
await expect(page.locator('h1')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Agentes Aduanales carga sin error', async ({ page }) => {
|
||||
await page.goto('/dashboard/customs_brokers')
|
||||
await expect(page).toHaveURL(/customs/)
|
||||
await expect(page.locator('h1')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Fraction Sitar carga sin error', async ({ page }) => {
|
||||
await page.goto('/dashboard/general_catalogs/tariff-fractions/sitar')
|
||||
await expect(page).toHaveURL(/tariff-fractions/)
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
})
|
||||
|
||||
test('Pedimentos carga sin error', async ({ page }) => {
|
||||
await page.goto('/dashboard/reference_data/code_pedimento_regimens')
|
||||
await expect(page).toHaveURL(/reference_data/)
|
||||
await expect(page.locator('main')).toBeVisible()
|
||||
})
|
||||
|
||||
})
|
||||
|
||||
})
|
||||
Reference in New Issue
Block a user