)
}
diff --git a/e2e/.report/data/2a784762ff1e69c9953accdefd61d0bb07e17116.webm b/e2e/.report/data/2a784762ff1e69c9953accdefd61d0bb07e17116.webm
new file mode 100644
index 0000000..9190705
Binary files /dev/null and b/e2e/.report/data/2a784762ff1e69c9953accdefd61d0bb07e17116.webm differ
diff --git a/e2e/.report/data/568d7c9f3284eb982276228f043baedcf2510977.webm b/e2e/.report/data/568d7c9f3284eb982276228f043baedcf2510977.webm
new file mode 100644
index 0000000..77eb06b
Binary files /dev/null and b/e2e/.report/data/568d7c9f3284eb982276228f043baedcf2510977.webm differ
diff --git a/e2e/.report/data/baa068ae1f41d0863370f8743945ca0e007a2c93.webm b/e2e/.report/data/baa068ae1f41d0863370f8743945ca0e007a2c93.webm
new file mode 100644
index 0000000..0eb3440
Binary files /dev/null and b/e2e/.report/data/baa068ae1f41d0863370f8743945ca0e007a2c93.webm differ
diff --git a/e2e/.report/data/f8a69bd1b67164d8285c53ab8666533689839727.webm b/e2e/.report/data/f8a69bd1b67164d8285c53ab8666533689839727.webm
new file mode 100644
index 0000000..2984a1f
Binary files /dev/null and b/e2e/.report/data/f8a69bd1b67164d8285c53ab8666533689839727.webm differ
diff --git a/e2e/.report/index.html b/e2e/.report/index.html
new file mode 100644
index 0000000..e3b3019
--- /dev/null
+++ b/e2e/.report/index.html
@@ -0,0 +1,90 @@
+
+
+
+
+
+
+
+
+ Playwright Test Report
+
+
+
+
+
+
+
+data:application/zip;base64,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
\ No newline at end of file
diff --git a/e2e/.results/.last-run.json b/e2e/.results/.last-run.json
new file mode 100644
index 0000000..cbcc1fb
--- /dev/null
+++ b/e2e/.results/.last-run.json
@@ -0,0 +1,4 @@
+{
+ "status": "passed",
+ "failedTests": []
+}
\ No newline at end of file
diff --git "a/e2e/.results/article-search-B\303\272squeda-de-32e93-t\303\255culos-al-limpiar-el-input-chromium/video.webm" "b/e2e/.results/article-search-B\303\272squeda-de-32e93-t\303\255culos-al-limpiar-el-input-chromium/video.webm"
new file mode 100644
index 0000000..2984a1f
Binary files /dev/null and "b/e2e/.results/article-search-B\303\272squeda-de-32e93-t\303\255culos-al-limpiar-el-input-chromium/video.webm" differ
diff --git "a/e2e/.results/article-search-B\303\272squeda-de-8f284-je-cuando-no-hay-resultados-chromium/video.webm" "b/e2e/.results/article-search-B\303\272squeda-de-8f284-je-cuando-no-hay-resultados-chromium/video.webm"
new file mode 100644
index 0000000..9190705
Binary files /dev/null and "b/e2e/.results/article-search-B\303\272squeda-de-8f284-je-cuando-no-hay-resultados-chromium/video.webm" differ
diff --git "a/e2e/.results/article-search-B\303\272squeda-de-cf1fc-ulos-por-t\303\255tulo-al-escribir-chromium/video.webm" "b/e2e/.results/article-search-B\303\272squeda-de-cf1fc-ulos-por-t\303\255tulo-al-escribir-chromium/video.webm"
new file mode 100644
index 0000000..0eb3440
Binary files /dev/null and "b/e2e/.results/article-search-B\303\272squeda-de-cf1fc-ulos-por-t\303\255tulo-al-escribir-chromium/video.webm" differ
diff --git "a/e2e/.results/article-search-B\303\272squeda-de-da2b8-nsitive-y-normaliza-acentos-chromium/video.webm" "b/e2e/.results/article-search-B\303\272squeda-de-da2b8-nsitive-y-normaliza-acentos-chromium/video.webm"
new file mode 100644
index 0000000..77eb06b
Binary files /dev/null and "b/e2e/.results/article-search-B\303\272squeda-de-da2b8-nsitive-y-normaliza-acentos-chromium/video.webm" differ
diff --git a/e2e/article-search.spec.ts b/e2e/article-search.spec.ts
new file mode 100644
index 0000000..b6a019a
--- /dev/null
+++ b/e2e/article-search.spec.ts
@@ -0,0 +1,71 @@
+import { test, expect } from "@playwright/test"
+
+test.describe("Búsqueda de artículos en tiempo real", () => {
+ test.beforeEach(async ({ page }) => {
+ await page.goto("/")
+ await page.waitForSelector('[aria-label="Buscar artículos"]', { state: "visible" })
+ })
+
+ test("muestra el campo de búsqueda en la página principal", async ({ page }) => {
+ const input = page.getByRole("searchbox", { name: "Buscar artículos" })
+ await expect(input).toBeVisible()
+ await expect(input).toHaveAttribute("placeholder", "Buscar artículos…")
+ })
+
+ test("golden path — filtra artículos al escribir en el campo de búsqueda", async ({ page }) => {
+ const input = page.getByRole("searchbox", { name: "Buscar artículos" })
+
+ const initialCards = page.locator("article")
+ const initialCount = await initialCards.count()
+ expect(initialCount).toBeGreaterThan(0)
+
+ await input.fill("react")
+ await expect(input).toHaveValue("react")
+
+ const filteredCards = page.locator("article")
+ await expect(filteredCards.first()).toBeVisible()
+ const filteredCount = await filteredCards.count()
+ expect(filteredCount).toBeGreaterThan(0)
+ expect(filteredCount).toBeLessThanOrEqual(initialCount)
+ })
+
+ test("restaura todos los artículos al borrar el texto", async ({ page }) => {
+ const input = page.getByRole("searchbox", { name: "Buscar artículos" })
+
+ const initialCards = page.locator("article")
+ const initialCount = await initialCards.count()
+
+ await input.fill("react")
+ await expect(input).toHaveValue("react")
+
+ await input.clear()
+ await expect(input).toHaveValue("")
+
+ const restoredCards = page.locator("article")
+ await expect(restoredCards.first()).toBeVisible()
+ await expect(restoredCards).toHaveCount(initialCount)
+ })
+
+ test("muestra mensaje cuando no hay resultados", async ({ page }) => {
+ const input = page.getByRole("searchbox", { name: "Buscar artículos" })
+
+ await input.fill("xxxxxxxxxx")
+ await expect(input).toHaveValue("xxxxxxxxxx")
+
+ await expect(page.getByText("No se encontraron artículos")).toBeVisible()
+ await expect(page.locator("article")).toHaveCount(0)
+ })
+
+ test("el filtrado es case-insensitive y sin distinción de acentos", async ({ page }) => {
+ const input = page.getByRole("searchbox", { name: "Buscar artículos" })
+
+ await input.fill("REACT")
+ await expect(input).toHaveValue("REACT")
+ const cards = page.locator("article")
+ await expect(cards.first()).toBeVisible()
+
+ await input.fill("supabase")
+ await expect(input).toHaveValue("supabase")
+ await expect(cards.first()).toBeVisible()
+ })
+})
diff --git a/playwright.config.ts b/playwright.config.ts
index a33c9da..cf61467 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -18,5 +18,13 @@ export default defineConfig({
command: 'npm run dev',
url: 'http://localhost:3000',
reuseExistingServer: !process.env.CI,
+ env: {
+ NEXT_PUBLIC_SUPABASE_URL:
+ process.env.NEXT_PUBLIC_SUPABASE_URL || 'https://placeholder.supabase.co',
+ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY:
+ process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY ||
+ process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ||
+ 'placeholder-anon-key',
+ },
},
})
diff --git a/specs/article-search/plan.md b/specs/article-search/plan.md
new file mode 100644
index 0000000..0e9470a
--- /dev/null
+++ b/specs/article-search/plan.md
@@ -0,0 +1,51 @@
+# Technical Plan — Búsqueda de Artículos en Tiempo Real
+
+> Derivado de `spec.md` confirmado. Feature slug: `article-search`.
+
+---
+
+## 1. Stack final
+
+- **Frontend:** Next.js 15 App Router + React 19 + TypeScript strict — ya en el proyecto.
+- **Filtrado:** cliente-side con `useState` + Array.filter — sin nueva API route ni React Query.
+- **Normalización:** función `normalize(str)` con `str.normalize("NFD").replace(/\p{Diacritic}/gu, "").toLowerCase()`.
+- **UI:** Tailwind CSS v4 + primitivas de `app/components/ui/` si aplica.
+
+---
+
+## 2. Componentes
+
+### `app/_components/ArticleSearch.tsx` (nuevo, Client Component)
+
+Responsabilidades:
+1. Recibe `posts: Post[]` como prop.
+2. Mantiene estado `query: string` con `useState("")`.
+3. Filtra los posts comparando `normalize(query)` contra `normalize(post.title)` y `normalize(post.excerpt)`.
+4. Renderiza el `` con `aria-label="Buscar artículos"` y placeholder "Buscar artículos…".
+5. Renderiza los `` filtrados.
+6. Muestra "No se encontraron artículos" si el array filtrado está vacío y `query !== ""`.
+
+### `app/page.tsx` (modificado)
+
+- Sigue siendo async Server Component (obtiene posts en servidor).
+- Pasa los posts al componente ``.
+
+---
+
+## 3. Orden de construcción
+
+1. Crear `app/_components/ArticleSearch.tsx`.
+2. Modificar `app/page.tsx` para usar ``.
+3. Añadir test E2E en `e2e/article-search.spec.ts`.
+
+---
+
+## 4. Definition of done
+
+- [ ] El input aparece en `/` con `aria-label="Buscar artículos"`.
+- [ ] Escribir en el input filtra la lista en tiempo real.
+- [ ] El filtrado ignora mayúsculas y acentos.
+- [ ] Mensaje "No se encontraron artículos" cuando no hay coincidencias.
+- [ ] Al borrar, vuelven todos los artículos.
+- [ ] Test E2E (golden path) en verde.
+- [ ] `npm run lint` y `npm test` en verde.
diff --git a/specs/article-search/spec.md b/specs/article-search/spec.md
new file mode 100644
index 0000000..065e80c
--- /dev/null
+++ b/specs/article-search/spec.md
@@ -0,0 +1,80 @@
+# Spec — Búsqueda de Artículos en Tiempo Real
+
+> Dominicode Spec-First template (Bezael Pérez).
+> Feature slug: `article-search`
+
+---
+
+## Visión
+
+Permitir a cualquier visitante (autenticado o no) filtrar los artículos de la página
+principal mientras escribe, sin recargar la página. Reduce el tiempo necesario para
+encontrar un artículo concreto cuando la lista crece.
+
+---
+
+## Usuarios
+
+**Visitante (anónimo o autenticado):** quiere encontrar un artículo por título o
+descripción sin tener que leer toda la lista.
+
+---
+
+## Funcionalidades
+
+**Must:**
+- Campo `` con placeholder "Buscar artículos…" y `aria-label="Buscar artículos"` visible sobre la lista de artículos.
+- Filtrado instantáneo (sin petición de red) mientras el usuario escribe.
+- Coincidencia en título **y** excerpt del artículo.
+- Filtrado case-insensitive y sin distinción de acentos (ej: "react" encuentra "React").
+- Mensaje "No se encontraron artículos" cuando no hay coincidencias.
+- Al borrar el texto, se restaura la lista completa.
+
+**Should:**
+- El input está limpio al entrar a la página (sin texto previo).
+
+**Could:**
+- Debounce en el filtrado (no necesario dado que el filtrado es cliente-side y los posts son pocos).
+
+---
+
+## Flujos
+
+**Happy path:**
+1. El usuario visita `/`.
+2. Ve el campo de búsqueda encima de la lista de artículos.
+3. Escribe "react" en el campo.
+4. La lista se actualiza instantáneamente mostrando solo artículos cuyo título o excerpt contiene "react" (insensible a mayúsculas y acentos).
+5. El usuario borra el texto — vuelven a aparecer todos los artículos.
+
+**Sin resultados:**
+1. El usuario escribe un término sin coincidencias (ej: "xxxxxxxxxx").
+2. La lista queda vacía y se muestra el mensaje "No se encontraron artículos".
+
+---
+
+## Arquitectura
+
+- **`app/page.tsx`** — actualmente Server Component. Se convierte en un Client Component
+ o se extrae la lógica de búsqueda a un componente cliente hijo que recibe `posts` como prop.
+- **`app/_components/ArticleSearch.tsx`** — nuevo Client Component (`"use client"`). Contiene
+ el ``, el estado `query` con `useState`, la lógica de filtrado (normalización de texto)
+ y la lista filtrada de ``.
+- No se necesita API route ni React Query: el filtrado opera sobre los posts ya cargados.
+- Sin dependencias nuevas.
+
+**Archivos afectados:**
+| Archivo | Cambio |
+|---------|--------|
+| `app/page.tsx` | Pasa `posts` al nuevo componente `ArticleSearch` |
+| `app/_components/ArticleSearch.tsx` | Nuevo — input + lógica de filtrado + lista |
+
+---
+
+## NFRs
+
+- **Rendimiento:** filtrado síncrono en cliente sobre array pequeño (< 100 posts); sin latencia perceptible.
+- **Accesibilidad:** `aria-label="Buscar artículos"` en el input para localización semántica por Playwright y lectores de pantalla.
+- **Seguridad:** sin nuevas superficies de ataque — solo lógica cliente sobre datos estáticos.
+- **Idioma:** placeholder y mensaje de estado vacío en español.
+- **Dependencias:** cero librerías nuevas.
diff --git a/specs/article-search/tasks.md b/specs/article-search/tasks.md
new file mode 100644
index 0000000..7a48456
--- /dev/null
+++ b/specs/article-search/tasks.md
@@ -0,0 +1,35 @@
+# Tasks — Búsqueda de Artículos en Tiempo Real
+
+> Orden TDD: tests primero, luego implementación mínima que los pase.
+
+---
+
+- [ ] **T1 — Crear componente `ArticleSearch`**
+ - Crea `app/_components/ArticleSearch.tsx` con `"use client"`.
+ - Acepta prop `posts: Post[]`.
+ - Renderiza ``.
+ - Renderiza todos los `` cuando `query` está vacío.
+ - **Criterio:** el input existe en el DOM con el aria-label correcto.
+
+- [ ] **T2 — Lógica de filtrado case-insensitive sin acentos**
+ - Implementa función `normalize(str)` que elimina diacríticos y convierte a minúsculas.
+ - Filtra posts donde `normalize(post.title)` o `normalize(post.excerpt)` contiene `normalize(query)`.
+ - **Criterio:** "react" filtra artículos con "React" en el título; "supabase" filtra "Supabase".
+
+- [ ] **T3 — Mensaje de estado vacío**
+ - Cuando el array filtrado está vacío y `query !== ""`, muestra el texto "No se encontraron artículos".
+ - Cuando `query === ""`, nunca muestra ese mensaje.
+ - **Criterio:** texto visible en pantalla cuando no hay resultados.
+
+- [ ] **T4 — Modificar `app/page.tsx`**
+ - Importar y usar `` en lugar de la sección de artículos actual.
+ - Mantener la sección de hero (h1 + párrafo).
+ - **Criterio:** la página `/` pasa los posts al componente de búsqueda.
+
+- [ ] **T5 — Test E2E golden path**
+ - Crea `e2e/article-search.spec.ts`.
+ - Navega a `/`, verifica que el input existe.
+ - Escribe "react" → verifica que aparece al menos un resultado.
+ - Borra el texto → verifica que vuelven todos los artículos.
+ - Escribe "xxxxxxxxxx" → verifica el mensaje "No se encontraron artículos".
+ - **Criterio:** `npm run test:e2e` en verde.
diff --git a/specs/real-time-article-search/plan.md b/specs/real-time-article-search/plan.md
new file mode 100644
index 0000000..84e100c
--- /dev/null
+++ b/specs/real-time-article-search/plan.md
@@ -0,0 +1,63 @@
+# Technical Plan — Búsqueda de artículos en tiempo real
+
+> Derivado de `spec.md` confirmado. Feature slug: `real-time-article-search`.
+
+---
+
+## 1. Stack final
+
+- **Frontend:** Next.js 15 App Router + React 19 + TypeScript strict — ya en el proyecto.
+- **Estado:** `useState` de React para el valor del input; `useMemo` para la lista filtrada.
+- **Iconos:** `lucide-react` — ya instalada; se usa el icono `Search`.
+- **Test runner (unit):** Vitest 2.1.1 — ya disponible. Comando: `npm test`.
+- **Test runner (E2E):** Playwright 1.60.0 — ya disponible. Comando: `npm run test:e2e`.
+
+### Stacks descartados
+
+- **React Query:** innecesario — los datos ya están en memoria (no hay fetch del lado del cliente).
+- **URL search params:** descartado — el spec no requiere persistencia de búsqueda en URL.
+- **Debounce:** descartado — la lista es estática y pequeña; no hay ganancia de rendimiento.
+
+---
+
+## 2. Pasos de implementación
+
+### Paso 1 — Crear `app/_components/ArticleSearch.tsx`
+
+Componente cliente que recibe `Post[]` como prop:
+- `useState("")` para el valor del input.
+- `useMemo` para calcular `filteredPosts` filtrando por `title` y `excerpt` usando normalización
+ de diacríticos.
+- Renderiza el input con `aria-label="Buscar artículos"` y el icono `Search` de lucide-react.
+- Renderiza el grid de `PostCard` con `filteredPosts`, o el mensaje "No se encontraron artículos"
+ si `filteredPosts.length === 0` y hay texto en el input.
+
+### Paso 2 — Modificar `app/page.tsx`
+
+- Cambiar el componente para que pase `posts` completo (no solo `featured`) a ``.
+- Mantener `page.tsx` como Server Component.
+
+### Paso 3 — Añadir test E2E `e2e/article-search.spec.ts`
+
+- Test 1 (golden path): navegar a `/`, escribir en el input, verificar que la lista se filtra.
+- Test 2 (sin resultados): escribir texto sin coincidencias, verificar mensaje.
+- Test 3 (limpiar): filtrar y luego borrar, verificar que vuelven todos los artículos.
+
+---
+
+## 3. Normalización de acentos
+
+```ts
+function normalize(str: string): string {
+ return str
+ .normalize("NFD")
+ .replace(/[̀-ͯ]/g, "")
+ .toLowerCase()
+}
+```
+
+---
+
+## 4. Riesgos
+
+- Ninguno significativo: el feature es puramente client-side sobre datos estáticos.
diff --git a/specs/real-time-article-search/spec.md b/specs/real-time-article-search/spec.md
new file mode 100644
index 0000000..a75330b
--- /dev/null
+++ b/specs/real-time-article-search/spec.md
@@ -0,0 +1,97 @@
+# Spec — Búsqueda de artículos en tiempo real
+
+> Dominicode Spec-First template (Bezael Pérez).
+> Feature slug: `real-time-article-search`
+
+---
+
+## Visión
+
+Los usuarios que visitan la página principal (`/`) pueden encontrar artículos relevantes sin
+necesidad de navegar por toda la lista. Un campo de búsqueda filtra los artículos visibles
+instantáneamente mientras el usuario escribe, reduciendo la fricción para descubrir contenido.
+
+---
+
+## Usuarios
+
+**Visitante anónimo:** busca artículos sobre un tema concreto desde la página principal sin
+necesidad de autenticarse.
+
+**Usuario autenticado:** igual que el visitante anónimo; la búsqueda no depende de la sesión.
+
+---
+
+## Funcionalidades
+
+**Must have**
+- Un `` con `placeholder="Buscar artículos…"` y `aria-label="Buscar artículos"` aparece
+ encima de la lista de artículos en la página principal.
+- Al escribir, la lista se filtra instantáneamente (sin recarga de página) mostrando solo los
+ artículos cuyo título o excerpt contenga el texto introducido.
+- La comparación es case-insensitive y normaliza acentos (ej: "react" encuentra "React", "codigo"
+ encuentra "código").
+- Cuando el filtro no produce resultados, se muestra el mensaje "No se encontraron artículos".
+- Al borrar el texto del input, vuelven a mostrarse todos los artículos.
+
+**Should have**
+- El input tiene un icono de lupa (Search) a la izquierda para indicar su propósito visualmente.
+- El estado de búsqueda no se persiste en la URL (filtrado puramente en memoria).
+
+**Could have**
+- Debounce del input para entornos con listas muy largas (no necesario con datos estáticos).
+
+---
+
+## Flujos
+
+**Happy path — encontrar un artículo:**
+1. El usuario visita `/`.
+2. Ve el campo de búsqueda sobre la lista de artículos.
+3. Escribe "react" en el campo.
+4. La lista se actualiza instantáneamente mostrando solo los artículos que contienen "react"
+ en título o excerpt.
+5. El usuario hace clic en un artículo y navega al detalle.
+
+**Flujo alternativo — sin resultados:**
+1. El usuario escribe un texto que no coincide con ningún artículo (ej: "xyz123").
+2. La lista muestra el mensaje "No se encontraron artículos" en lugar de cards.
+
+**Flujo alternativo — limpiar búsqueda:**
+1. El usuario ha filtrado la lista.
+2. Borra el texto del input.
+3. Todos los artículos vuelven a ser visibles.
+
+---
+
+## Arquitectura
+
+**Archivos afectados / creados:**
+
+```
+app/
+├── page.tsx # MODIFICADO: pasa posts como prop al componente cliente
+└── _components/
+ └── ArticleSearch.tsx # NUEVO: componente cliente con input + lista filtrada
+```
+
+**Patrón:**
+- `app/page.tsx` permanece Server Component; obtiene los posts con `getPosts()` y los pasa como
+ prop a ``.
+- `ArticleSearch` es un Client Component (`"use client"`); gestiona el estado del input con
+ `useState` y calcula los posts filtrados con `useMemo`. No necesita React Query porque los datos
+ ya están en memoria.
+- La normalización de acentos usa `String.prototype.normalize('NFD')` con regex para eliminar
+ diacríticos.
+
+**Dependencias:** ninguna nueva; usa `lucide-react` (ya instalada) para el icono de lupa.
+
+---
+
+## NFRs
+
+- **Rendimiento:** el filtrado se ejecuta en `useMemo` para evitar recálculos innecesarios.
+- **Accesibilidad:** el input tiene `aria-label="Buscar artículos"` para lectores de pantalla y
+ localización semántica en tests Playwright.
+- **Seguridad:** el filtrado es puramente client-side sobre datos estáticos; no hay entrada de
+ usuario enviada al servidor.
diff --git a/specs/real-time-article-search/tasks.md b/specs/real-time-article-search/tasks.md
new file mode 100644
index 0000000..7ef620b
--- /dev/null
+++ b/specs/real-time-article-search/tasks.md
@@ -0,0 +1,47 @@
+# Tasks — Búsqueda de artículos en tiempo real
+
+> Derivado de `spec.md` + `plan.md` confirmados.
+> Ejecutar en orden estricto. Un task a la vez.
+
+---
+
+## Convenciones
+
+- `[ ]` pendiente · `[x]` hecho
+- ⚙️ Setup · 🔴 Red (test falla) · 🟢 Green (implementación mínima) · 🔵 Refactor · 🔗 Integración
+
+---
+
+## Phase 0 — Verificación del runner
+
+- [x] ⚙️ **Runner verificado:** Vitest 2.1.1 y Playwright 1.60.0 detectados en `package.json`.
+
+---
+
+## Phase 1 — Componente `ArticleSearch`
+
+- [ ] 🟢 **Crear `app/_components/ArticleSearch.tsx`**
+ - Recibe `posts: Post[]` como prop.
+ - Estado: `useState("")` para el valor del input.
+ - Filtrado: `useMemo` que normaliza diacríticos y busca en `title` + `excerpt`.
+ - Renderiza: input con `aria-label="Buscar artículos"`, grid de `PostCard`, o mensaje vacío.
+ - Criterio: el componente renderiza correctamente en TypeScript strict sin errores.
+
+---
+
+## Phase 2 — Integración en `page.tsx`
+
+- [ ] 🔗 **Modificar `app/page.tsx`**
+ - Pasar `posts` completo a ``.
+ - Mantener `page.tsx` como Server Component.
+ - Criterio: la página principal muestra el input y los artículos filtrados.
+
+---
+
+## Phase 3 — Tests E2E
+
+- [ ] 🔴 **Crear `e2e/article-search.spec.ts`**
+ - Test golden path: navegar a `/`, escribir en el input, verificar lista filtrada.
+ - Test sin resultados: texto sin coincidencias → mensaje "No se encontraron artículos".
+ - Test limpiar: filtrar y borrar → todos los artículos visibles.
+ - Criterio: `npm run test:e2e` pasa en verde.
diff --git a/vitest.config.ts b/vitest.config.ts
index c6dc4cf..904734e 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -7,6 +7,7 @@ export default defineConfig({
test: {
environment: "jsdom",
passWithNoTests: true,
+ exclude: ["**/node_modules/**", "**/e2e/**"],
},
resolve: {
alias: {