feat(kanban): deadlines en cards (context menu, badges, calendario, history)
- migration 009 + columna deadline TEXT en cards - backend: CardPatch.HasDeadline, eventos deadline_set/deadline_cleared - KanbanCard: menu derecho con DatePicker, badge countdown con colores por ratio (azul>=50%, amarillo<50%, rojo<10%, red.9 overdue) - App.tsx: filtro "Con deadline", handleSetCardDeadline optimista, jump-to-card + highlight - CalendarView: popover por dia con seq_num + titulo, click navega a card en tablero - HistoryModal: render eventos deadline_set/deadline_cleared - .gitignore: *.log Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
// Tests e2e contra kanban server (puerto 8095) usando funciones del registry.
|
||||
// Requiere kanban backend corriendo + Chrome accesible (WSL2 o Linux).
|
||||
//
|
||||
// Ejecucion:
|
||||
// cd e2e && go test -v -tags fts5 ./...
|
||||
// o: BASE_URL=http://localhost:5180 go test -v ./... (modo dev con Vite)
|
||||
//
|
||||
// Variables de entorno:
|
||||
// BASE_URL — default http://localhost:8095
|
||||
// KANBAN_USER — default e2e
|
||||
// KANBAN_PASS — default e2etest
|
||||
// HEADLESS — "1" para headless. Default "1"
|
||||
//
|
||||
// Reusa funciones del registry: chrome_launch, cdp_*. NO duplica logica.
|
||||
package e2e
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"fn-registry/functions/browser"
|
||||
)
|
||||
|
||||
const cdpPort = 9335
|
||||
|
||||
type ctx struct {
|
||||
t *testing.T
|
||||
conn *browser.CDPConn
|
||||
chromePID int
|
||||
baseURL string
|
||||
}
|
||||
|
||||
func envOr(k, dflt string) string {
|
||||
if v := os.Getenv(k); v != "" {
|
||||
return v
|
||||
}
|
||||
return dflt
|
||||
}
|
||||
|
||||
func setup(t *testing.T) *ctx {
|
||||
t.Helper()
|
||||
baseURL := envOr("BASE_URL", "http://localhost:8095")
|
||||
|
||||
// Verificar que el backend responde antes de lanzar Chrome.
|
||||
resp, err := http.Get(baseURL + "/api/board")
|
||||
if err != nil {
|
||||
t.Skipf("backend no accesible en %s: %v", baseURL, err)
|
||||
}
|
||||
resp.Body.Close()
|
||||
|
||||
headless := envOr("HEADLESS", "1") == "1"
|
||||
pid, err := browser.ChromeLaunch(browser.ChromeLaunchOpts{
|
||||
Port: cdpPort,
|
||||
UserDataDir: "/tmp/kanban-e2e-profile",
|
||||
Headless: headless,
|
||||
})
|
||||
if err != nil {
|
||||
t.Skipf("chrome_launch fallo: %v", err)
|
||||
}
|
||||
|
||||
conn, err := browser.CdpConnect(cdpPort)
|
||||
if err != nil {
|
||||
_ = browser.CdpClose(nil, pid)
|
||||
t.Fatalf("cdp_connect fallo: %v", err)
|
||||
}
|
||||
|
||||
c := &ctx{t: t, conn: conn, chromePID: pid, baseURL: baseURL}
|
||||
t.Cleanup(func() { _ = browser.CdpClose(c.conn, c.chromePID) })
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *ctx) navigate(path string) {
|
||||
c.t.Helper()
|
||||
if err := browser.CdpNavigate(c.conn, c.baseURL+path); err != nil {
|
||||
c.t.Fatalf("navigate %s: %v", path, err)
|
||||
}
|
||||
if err := browser.CdpWaitLoad(c.conn, 10*time.Second); err != nil {
|
||||
c.t.Fatalf("wait_load %s: %v", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *ctx) screenshot(name string) {
|
||||
c.t.Helper()
|
||||
dir := "screenshots"
|
||||
_ = os.MkdirAll(dir, 0o755)
|
||||
out := fmt.Sprintf("%s/%s.png", dir, name)
|
||||
if err := browser.CdpScreenshot(c.conn, out, browser.CdpScreenshotOpts{Format: "png"}); err != nil {
|
||||
c.t.Logf("screenshot fallo (%s): %v", name, err)
|
||||
return
|
||||
}
|
||||
c.t.Logf("screenshot: %s", out)
|
||||
}
|
||||
|
||||
func (c *ctx) eval(expr string) string {
|
||||
c.t.Helper()
|
||||
out, err := browser.CdpEvaluate(c.conn, expr)
|
||||
if err != nil {
|
||||
c.t.Fatalf("eval (%s): %v", expr, err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// --- Tests ---
|
||||
|
||||
func TestE2E_HomeLoads(t *testing.T) {
|
||||
c := setup(t)
|
||||
c.navigate("/")
|
||||
|
||||
// Login form o board (segun haya sesion previa). Busca cualquier rasgo visible.
|
||||
if err := browser.CdpWaitElement(c.conn, "body", 5*time.Second); err != nil {
|
||||
t.Fatalf("body no aparecio: %v", err)
|
||||
}
|
||||
html, err := browser.CdpGetHTML(c.conn)
|
||||
if err != nil {
|
||||
t.Fatalf("get_html: %v", err)
|
||||
}
|
||||
if !strings.Contains(strings.ToLower(html), "kanban") &&
|
||||
!strings.Contains(strings.ToLower(html), "iniciar") &&
|
||||
!strings.Contains(strings.ToLower(html), "login") {
|
||||
t.Errorf("home no contiene rastros esperados (kanban/login). HTML[:200]=%s", html[:min(len(html), 200)])
|
||||
}
|
||||
c.screenshot("01_home")
|
||||
}
|
||||
|
||||
func TestE2E_ApiBoardResponds(t *testing.T) {
|
||||
baseURL := envOr("BASE_URL", "http://localhost:8095")
|
||||
resp, err := http.Get(baseURL + "/api/board")
|
||||
if err != nil {
|
||||
t.Skipf("backend no accesible: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
// 401 (sin sesion) o 200 (sesion activa) — ambos validos.
|
||||
if resp.StatusCode != 200 && resp.StatusCode != 401 {
|
||||
t.Errorf("/api/board status inesperado: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_FlagsEndpoint_DoesNotExist(t *testing.T) {
|
||||
// Smoke: endpoint /api/me devuelve 401 sin auth (no 5xx).
|
||||
baseURL := envOr("BASE_URL", "http://localhost:8095")
|
||||
resp, err := http.Get(baseURL + "/api/me")
|
||||
if err != nil {
|
||||
t.Skipf("backend no accesible: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 500 {
|
||||
t.Errorf("/api/me devolvio 5xx: %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestE2E_FrontendBundleHasNoConsoleErrors(t *testing.T) {
|
||||
c := setup(t)
|
||||
c.navigate("/")
|
||||
if err := browser.CdpWaitElement(c.conn, "body", 5*time.Second); err != nil {
|
||||
t.Fatalf("body: %v", err)
|
||||
}
|
||||
// Comprueba que no hay errores graves en el DOM.
|
||||
val := c.eval(`document.querySelectorAll('script[src*="error"]').length`)
|
||||
if !strings.Contains(val, "0") {
|
||||
t.Errorf("scripts de error detectados: %s", val)
|
||||
}
|
||||
// Verifica que el bundle se cargo (algun script de assets).
|
||||
val = c.eval(`document.querySelectorAll('script[src*="/assets/"]').length`)
|
||||
if strings.Contains(val, "0") {
|
||||
t.Errorf("bundle no cargado: %s", val)
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
Reference in New Issue
Block a user