~/es/blog/playwright-para-verificar-agentes

Ya no solo reviso el código del agente: le pido pruebas

Seis flujos de Playwright que dejan captura, video y trace cuando fallan. En CI en cada PR y en cada deploy. Y cuatro veces que la evidencia me contradijo.

· 7 min de lectura · Read in English

Cuando un agente me dice "listo, ya funciona", no le creo. No porque mienta, sino porque "funciona" es una conclusión, y lo que yo necesito es la evidencia.

En el trabajo uso Playwright para eso, bastante. Le pido al agente que abra el navegador con el MCP de Playwright, haga lo que haría un usuario y me deje una captura, un video o un trace. Más de una vez, viendo el video, encontré lo que el agente no me había dicho.

Pero esa conversación se pierde. Lo que funcionó explorando tiene que quedar como un archivo en e2e/ que corre solo en cada deploy. De eso va este post: pasar de "muéstramelo una vez" a "muéstramelo siempre".

Lo armé sobre este blog: seis flujos de lector que corren en CI, en cada PR contra el build y después de cada deploy contra el sitio en vivo. Yo aprobé la lista de flujos. El código lo escribió un agente y lo revisó otro, el que revisa este sitio. El repo del sitio es privado, pero el sitio no. Todo lo que está aquí lo puedes correr tú contra davidquinta.tech.

Un flujo es lo que hace el lector, con lo que tiene que pasar#

Un flujo no es "prueba el blog". Es una historia corta con un resultado que se puede comprobar. Por ejemplo: estoy leyendo un post en inglés, cambio a español, tengo que llegar a la traducción de ese post, no al índice. Y si después vuelvo al portafolio, tiene que seguir en español.

e2e/flows.spec.ts
import { expect, test } from '@playwright/test'
 
const POST_EN = '/blog/i-made-my-dog-a-nintendogs/'
const POST_ES = '/es/blog/le-hice-un-nintendogs-a-mi-perro/'
 
test('switching to Spanish on a post opens its translation, and the portfolio follows', async ({ page }) => {
  await page.goto(POST_EN)
  await page.getByRole('group', { name: 'Language' }).getByRole('link', { name: 'ES' }).click()
 
  await expect(page).toHaveURL(POST_ES)
  await expect(page.locator('html')).toHaveAttribute('lang', 'es')
 
  await page.getByRole('link', { name: 'Portafolio', exact: true }).click()
  await expect(page.locator('html')).toHaveAttribute('lang', 'es')
})

Este flujo no salió de la nada. En este blog ya pasó: un /blog/ escrito a mano mandaba a los lectores en español al blog en inglés. Lo cuento en el post de los subagentes. Un buen flujo de regresión es un bug que ya tuviste, escrito para que no vuelva.

Los selectores van por rol y nombre (getByRole), no por clases de CSS. Si mañana cambio el markup, el test sigue. Y de paso valida que el link tenga un nombre accesible, porque si un lector de pantalla no lo encuentra, el test tampoco.

Ojo con el nombre: por defecto getByRole busca el texto como substring y sin importar mayúsculas. La primera vez que corrió, falló con strict mode violation: "Portafolio" encontraba el link del nav y también "VER PORTAFOLIO", que se ve como botón pero es un link. De ahí el exact: true.

Contra el build real#

Los flujos no corren contra next dev. Corren contra los mismos archivos que recibe el lector: en un PR, el export estático de out/; en el resto, producción.

playwright.config.ts
import { defineConfig } from '@playwright/test'
 
const local = process.env.LOCAL === '1'
 
export default defineConfig({
  testDir: 'e2e',
  forbidOnly: !!process.env.CI,
  retries: process.env.CI ? 1 : 0,
  reporter: [['list'], ['html', { open: 'never' }]],
  use: {
    baseURL: local ? 'http://127.0.0.1:4173' : (process.env.BASE_URL ?? 'https://www.davidquinta.tech'),
    screenshot: 'only-on-failure',
    video: 'retain-on-failure',
    trace: 'retain-on-failure',
  },
  webServer: local
    ? {
        command: 'python3 -m http.server 4173 --bind 127.0.0.1 --directory out',
        url: 'http://127.0.0.1:4173/',
        reuseExistingServer: !process.env.CI,
      }
    : undefined,
})

forbidOnly evita que un test.only olvidado deje pasar un CI que solo corrió un test. El retries: 1 en CI es para la red, no para tapar tests inestables: si un flujo necesita el reintento seguido, el reporte lo marca como flaky y hay que arreglarlo.

La evidencia la deja el test, no el agente#

Las tres líneas de screenshot, video y trace son el "muéstramelo siempre". Cuando algo falla, no tengo que preguntarle al agente qué pasó. Tengo la captura, el video y el trace, que es la grabación de cada paso con el DOM, la red y la consola.

Para comprobar que esto sirve, rompimos el sitio a propósito: hice que el botón de idioma de los posts mandara siempre al índice del blog. El flujo falló así:

Error: expect(page).toHaveURL(expected) failed
Expected: "http://127.0.0.1:4173/es/blog/le-hice-un-nintendogs-a-mi-perro/"
Received: "http://127.0.0.1:4173/es/blog/"
El trace viewer de Playwright con el test fallido: la lista de acciones marca en rojo el expect toHaveURL, el navegador muestra el índice del blog en español en vez del post, y abajo se ve la URL que se esperaba.Ver la imagen en tamaño completo

El trace del flujo roto. El test esperaba el post y terminó en el índice.

Y ahí está la diferencia con un "listo" del agente: ese trace lo puede abrir cualquiera con npx playwright show-trace, y se ve exactamente dónde se rompió. En CI, el trace queda dentro del reporte que se sube como artifact del run.

Lo que no se ve con un clic#

Algunos flujos no son de clics. El video del post de Dante tiene que esperar a estar en pantalla para arrancar, y si el lector lo pausa, tiene que seguir pausado aunque haga scroll.

e2e/flows.spec.ts
test('the clip waits until it is on screen, and a reader can pause it', async ({ page }) => {
  await page.goto(POST_ES)
  const video = page.locator('video.post-video')
  const paused = () => video.evaluate((v: HTMLVideoElement) => v.paused)
 
  // Below the fold, and still not playing a second later.
  await expect(video).not.toBeInViewport()
  await page.waitForTimeout(1000)
  expect(await paused()).toBe(true)
 
  await video.scrollIntoViewIfNeeded()
  await expect.poll(paused).toBe(false)
 
  // The reader pauses it, scrolls away and comes back.
  await video.evaluate((v: HTMLVideoElement) => v.pause())
  await page.evaluate(() => window.scrollTo({ top: 0, behavior: 'instant' }))
  await expect(video).not.toBeInViewport()
  await video.scrollIntoViewIfNeeded()
  await expect(video).toBeInViewport()
  await page.waitForTimeout(1000)
  expect(await paused()).toBe(true)
})

expect.poll reintenta hasta que el video arranca, sin un sleep fijo. Los waitForTimeout sí son fijos, a propósito: son tres en todo el archivo, y los tres comprueban que algo no pasa, que es imposible sin darle tiempo de pasar. Todo lo demás espera con polling. Y en este test, antes de cada espera, toBeInViewport confirma dónde está el video, porque si está fuera de pantalla, "sigue pausado" no prueba nada.

Ese video ya había fallado una vez. Arrancaba solo y no había forma de pausarlo, y eso incumple WCAG 2.2.2. axe, que corre en el audit del sitio, no lo detectó: sus reglas de autoplay miran el audio, y este video no tiene sonido. Lo encontró el agente que revisa el sitio, en el PR del video. Ahora hay un test para eso.

Los otros flujos hacen lo mismo con cosas que un clic no muestra: que al cerrar una imagen ampliada con Escape el foco vuelva a la imagen, que un post se pueda leer con JavaScript apagado (javaScriptEnabled: false), y que un link a una sección no deje el título escondido debajo de la barra de navegación, en escritorio y en celular:

e2e/flows.spec.ts
for (const viewport of [
  { width: 1280, height: 800 },
  { width: 390, height: 844 },
]) {
  test(`a link to a section lands just below the sticky nav at ${viewport.width}px`, async ({ page }) => {
    await page.setViewportSize(viewport)
    // The site scrolls smoothly, so a fresh load is still on its way to the
    // anchor. Under reduced motion it jumps, and the position can be read.
    await page.emulateMedia({ reducedMotion: 'reduce' })
    await page.goto(`${POST_ES}#la-cara-no-salio`)
    const navBottom = await page.locator('nav').first().evaluate((n) => n.getBoundingClientRect().bottom)
    const top = () => page.locator('#la-cara-no-salio').evaluate((h) => h.getBoundingClientRect().top)
 
    await expect.poll(top).toBeGreaterThanOrEqual(navBottom)
    expect(await top()).toBeLessThanOrEqual(navBottom + 24)
  })
}

El rango importa tanto como el mínimo: si el título queda 300 px más abajo, tampoco está bien.

Cuatro veces que la evidencia me contradijo#

Las cuatro las encontró una prueba o una revisión, no un lector. Para eso están.

El test que corrió sin la opción. Para probar el video con reducir movimiento, el agente escribió test.use({ reducedMotion: 'reduce' }), una opción que en la versión de Playwright del repo no existe. El runner la ignoró en silencio, el test falló y mi primera sospecha fue el sitio. TypeScript sí lo detecta:

error TS2353: Object literal may only specify known properties,
and 'reducedMotion' does not exist in type 'Fixtures<...>'.

Pero Playwright corre los tests en TypeScript sin revisar los tipos. Por eso el CI corre tsc antes que los flujos: es el único paso del CI que ve una opción que no existe.

El reloj. El juego de Dante tiene en su repo un test de Playwright que le tira la pelota y espera a que la traiga. En mi máquina pasaba; en GitHub Actions falló las tres veces. El runner no tiene GPU, el juego corría a menos de un cuadro por segundo y, como cada cuadro avanza máximo 0.05 s de juego, en los 120 segundos del test solo pasaban unos 3 segundos de juego. El arreglo: en los tests, un cuadro lento se reparte en varios pasos de 0.05 s. En el navegador el juego no cambia.

El códec que sí estaba. El agente escribió en la config que el Chromium de Playwright en Linux no reproduce H.264 y cambió los tests a Chrome. Eso valía para el Chromium de antes, pero desde la 1.57 Playwright corre sobre builds de Chrome for Testing. El agente no lo comprobó y yo no lo cuestioné. Una prueba en CI con los dos navegadores reprodujo el video en los dos, y quitamos el cambio. Si le exijo evidencia al agente, también me la tengo que exigir a mí.

Los dos tests que no podían fallar. Antes de publicar esto, el agente que revisa el sitio puso a prueba los flujos: le quitó al sitio el margen del ancla y midió dónde quedaba el video en cada paso. Dos de los seis pasaban igual. El del ancla medía antes de tiempo: el sitio hace scroll suave, así que justo después de cargar el título todavía venía en camino, muy abajo, y "está debajo de la barra" se cumplía siempre. El del video usaba mouse.wheel, que no espera a que termine el scroll, y el video terminaba fuera de pantalla justo cuando el test revisaba que siguiera pausado. Fuera de pantalla nunca arranca, así que el test pasaba aunque el sitio olvidara la pausa del lector. El agente los arregló, rompió el sitio a propósito y los dos fallaron como debían. Un test que nunca viste fallar no te dice nada.

En CI: en cada PR y en cada deploy#

.github/workflows/e2e.yml
on:
  pull_request:
  deployment_status:
  workflow_dispatch:
 
jobs:
  flows:
    if: github.event_name != 'deployment_status' || (github.event.deployment_status.state == 'success' && github.event.deployment.environment == 'Production')
    runs-on: ubuntu-latest
    timeout-minutes: 15
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      # Playwright runs TypeScript without type-checking it, so a wrong option
      # name in a test is only caught here.
      - run: npm run typecheck
      - run: npx playwright install --with-deps chromium
      - if: github.event_name == 'pull_request'
        run: npm run build
      - name: Flows
        run: npx playwright test
        env:
          LOCAL: ${{ github.event_name == 'pull_request' && '1' || '' }}
      - uses: actions/upload-artifact@v4
        if: failure()
        with:
          name: playwright-report
          path: playwright-report/
          retention-days: 14

En un PR, construye el sitio y corre los flujos contra ese build, así una regresión aparece antes del merge. Después de cada deploy a producción, los corre contra el sitio en vivo, por si algo se coló o el deploy cambió algo que el build no muestra. No los corro todos los días: el sitio es estático, y entre un deploy y otro no cambia nada que probar. El aviso del deploy llega solo: Vercel le reporta cada deploy a GitHub, y el if deja pasar únicamente el de producción ya terminado. Si falla, el reporte con las capturas, los videos y los traces queda como artifact del run.

Los seis flujos tardan unos 15 segundos en CI. El job completo, con la instalación del navegador y el build, unos dos minutos.

Pruébalo#

Los snippets de este post corren tal cual contra davidquinta.tech: copia el primer test en e2e/ y la config, instala @playwright/test, baja el navegador con npx playwright install chromium y corre npx playwright test. Si encuentras algo roto en el sitio, cuéntame. Y si quieres el próximo post, está el feed RSS.