import type { Browser, BrowserContext, Page } from 'playwright'; const PUBLIC_URL = 'https://www.sat.gob.mx/portal/public/tramites/constancia-de-situacion-fiscal'; export interface CsfLoginSession { context: BrowserContext; appPage: Page; } /** * Navigates from the public CSF page → "SERVICIO" popup → FIEL login → * returns the post-login app page (popup that became the SPA). * Ver referencia_sat_portal_csf: el botón "Generar" vive en un iframe JSF * dentro de esta appPage, por eso la retornamos tal cual. */ export async function loginSatCsf( browser: Browser, cerPath: string, keyPath: string, password: string, ): Promise { const context = await browser.newContext({ acceptDownloads: true }); const publicPage = await context.newPage(); publicPage.setDefaultTimeout(60_000); await publicPage.goto(PUBLIC_URL, { waitUntil: 'networkidle' }); await publicPage.waitForTimeout(2000); // Click acordeón "Obtén tu constancia" / "Obtener constancia" const obtenerLocator = publicPage.locator( 'text=/Obt[eé]n\\s+la\\s+constancia|Obt[eé]n\\s+tu\\s+constancia|Obtener\\s+constancia|Obtener\\s+la\\s+constancia/i', ).first(); await obtenerLocator.waitFor({ state: 'visible', timeout: 60_000 }); await obtenerLocator.scrollIntoViewIfNeeded(); await obtenerLocator.click(); await publicPage.waitForTimeout(1500); // Click "SERVICIO" → popup const popupPromise = context.waitForEvent('page', { timeout: 60_000 }); await publicPage.locator('text=/^\\s*SERVICIO\\s*$/i').first().click(); const loginPage = await popupPromise; await loginPage.waitForLoadState('domcontentloaded'); loginPage.setDefaultTimeout(60_000); // Click "e.firma" (NO "e.firma portable"). El SAT a veces aterriza en la // pestaña de contraseña: el botón cambia a la vista FIEL. El click sintético // de Playwright a veces no dispara el handler — afirmamos el efecto (aparece // el file input) y reintentamos con dispatchEvent si hace falta. const efirmaBtn = loginPage .locator('button:has-text("e.firma"):not(:has-text("portable")), input[type="button"][value="e.firma" i], input[type="submit"][value="e.firma" i]') .first(); await efirmaBtn.waitFor({ state: 'visible', timeout: 30_000 }); await efirmaBtn.scrollIntoViewIfNeeded(); await efirmaBtn.click(); const fileInputs = loginPage.locator('input[type="file"]'); try { await fileInputs.first().waitFor({ state: 'attached', timeout: 10_000 }); } catch { // Retry: el click sintético no disparó el handler — forzamos dispatchEvent await efirmaBtn.dispatchEvent('click'); await fileInputs.first().waitFor({ state: 'attached', timeout: 30_000 }); } // Upload .cer (primer input) y .key (segundo) await fileInputs.nth(0).setInputFiles(cerPath); await fileInputs.nth(1).setInputFiles(keyPath); // Password + Enviar await loginPage.locator('input[type="password"]').first().fill(password); await loginPage.locator('button:has-text("Enviar"), input[value="Enviar"]').first().click(); // Esperar a que salga del dominio de login await loginPage.waitForURL(url => !url.toString().includes('loginda.siat.sat.gob.mx'), { timeout: 60_000 }); await loginPage.waitForLoadState('networkidle').catch(() => undefined); await loginPage.waitForTimeout(2000); const bodyText = await loginPage.locator('body').innerText().catch(() => ''); if (/contrase[nñ]a\s+incorrecta|usuario.*no.*v[aá]lido|firma\s+inv[aá]lida/i.test(bodyText)) { throw new Error('FIEL inválida o contraseña incorrecta'); } return { context, appPage: loginPage }; }