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:
2026-05-09 03:45:36 +02:00
parent 5ba0254e57
commit 7ce227ddea
54 changed files with 3066 additions and 496 deletions
+575
View File
@@ -0,0 +1,575 @@
// Tests del color picker (Modal personalizado dentro de Menu/Popover de Mantine).
// Reproduce el bug: click en el circulo "Color personalizado" abre Modal pero
// se cierra inmediatamente. Comprueba que el Modal permanezca visible >300ms.
package e2e
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"time"
"fn-registry/functions/browser"
)
// loginAndGetCookie registra (idempotente) y hace login. Retorna valor de la cookie kanban_session.
func loginAndGetCookie(t *testing.T, baseURL, user, pass string) string {
t.Helper()
body := fmt.Sprintf(`{"username":%q,"password":%q,"display_name":%q}`, user, pass, user)
// Registro: 200 OK la primera vez, error si ya existe (ignorable).
_, _ = http.Post(baseURL+"/api/auth/register", "application/json", strings.NewReader(body))
loginBody := fmt.Sprintf(`{"username":%q,"password":%q}`, user, pass)
resp, err := http.Post(baseURL+"/api/auth/login", "application/json", strings.NewReader(loginBody))
if err != nil {
t.Fatalf("login http: %v", err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
buf := new(bytes.Buffer)
buf.ReadFrom(resp.Body)
t.Fatalf("login status %d: %s", resp.StatusCode, buf.String())
}
for _, ck := range resp.Cookies() {
if ck.Name == "kanban_session" {
return ck.Value
}
}
t.Fatalf("login no devolvio cookie kanban_session")
return ""
}
// ensureBoardSeed crea una columna y card si la BD esta vacia. Usa la cookie autenticada.
func ensureBoardSeed(t *testing.T, baseURL, cookie string) {
t.Helper()
client := &http.Client{}
mk := func(method, url string, body string) *http.Response {
req, _ := http.NewRequest(method, baseURL+url, strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: "kanban_session", Value: cookie})
resp, err := client.Do(req)
if err != nil {
t.Fatalf("%s %s: %v", method, url, err)
}
return resp
}
// Lee board.
resp := mk("GET", "/api/board", "")
defer resp.Body.Close()
var board struct {
Columns []map[string]any `json:"columns"`
Cards []map[string]any `json:"cards"`
}
json.NewDecoder(resp.Body).Decode(&board)
var colID string
if len(board.Columns) == 0 {
r := mk("POST", "/api/columns", `{"name":"e2e"}`)
var c map[string]any
json.NewDecoder(r.Body).Decode(&c)
r.Body.Close()
colID = c["id"].(string)
} else {
colID = board.Columns[0]["id"].(string)
}
if len(board.Cards) == 0 {
r := mk("POST", "/api/cards", fmt.Sprintf(`{"column_id":%q,"title":"e2e card"}`, colID))
r.Body.Close()
}
}
// authedSetup hace login + inyecta cookie en el browser CDP.
func authedSetup(t *testing.T) (*ctx, string) {
t.Helper()
c := setup(t)
user := envOr("KANBAN_USER", "e2etest")
pass := envOr("KANBAN_PASS", "e2etest")
cookie := loginAndGetCookie(t, c.baseURL, user, pass)
ensureBoardSeed(t, c.baseURL, cookie)
// Navegar a la home primero para que el browser tenga el dominio en su jar.
c.navigate("/")
host := strings.TrimPrefix(strings.TrimPrefix(c.baseURL, "http://"), "https://")
host = strings.SplitN(host, ":", 2)[0]
if err := browser.CdpSetCookie(c.conn, "kanban_session", cookie, host, "/", true); err != nil {
t.Fatalf("set_cookie: %v", err)
}
c.navigate("/")
return c, cookie
}
func TestColorPicker_AvatarMenu_ModalStaysOpen(t *testing.T) {
c, _ := authedSetup(t)
// Esperar avatar (header).
if err := browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second); err != nil {
c.screenshot("debug_no_avatar")
t.Fatalf("avatar no aparecio (login fallo?): %v", err)
}
if err := browser.CdpClick(c.conn, "[aria-label='Usuario']"); err != nil {
t.Fatalf("click avatar: %v", err)
}
// Esperar el grid de colores.
if err := browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second); err != nil {
c.screenshot("debug_no_picker_grid")
t.Fatalf("picker grid no visible: %v", err)
}
c.screenshot("avatar_menu_open")
// Click "+".
if err := browser.CdpClick(c.conn, "[aria-label='Color personalizado']"); err != nil {
t.Fatalf("click +: %v", err)
}
// Esperar 350ms — si el bug persiste, el modal habra desaparecido.
time.Sleep(350 * time.Millisecond)
c.screenshot("avatar_after_plus_click")
// Mantine Modal renderiza con role="dialog". Comprobar visible.
val := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
t.Logf("dialogs en DOM tras 350ms: %s", val)
if strings.Contains(val, "0") {
// Bug confirmado: modal cerro.
t.Errorf("BUG: Modal del color picker se cerro inmediatamente (avatar menu)")
}
// Comprobar header del modal.
val = c.eval(`(() => { const m = document.querySelector('[role="dialog"] .mantine-Modal-title'); return m ? m.textContent : 'NULL'; })()`)
t.Logf("modal title: %s", val)
}
func TestColorPicker_AvatarModal_ClicksInsideKeepOpen(t *testing.T) {
c, _ := authedSetup(t)
if err := browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second); err != nil {
t.Fatalf("avatar: %v", err)
}
if err := browser.CdpClick(c.conn, "[aria-label='Usuario']"); err != nil {
t.Fatalf("click avatar: %v", err)
}
if err := browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second); err != nil {
t.Fatalf("picker grid: %v", err)
}
if err := browser.CdpClick(c.conn, "[aria-label='Color personalizado']"); err != nil {
t.Fatalf("click +: %v", err)
}
if err := browser.CdpWaitElement(c.conn, "[role='dialog']", 2*time.Second); err != nil {
t.Fatalf("modal: %v", err)
}
// Click 1: input hex
if err := browser.CdpClick(c.conn, "[role='dialog'] input"); err != nil {
t.Fatalf("click input: %v", err)
}
time.Sleep(150 * time.Millisecond)
val := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if strings.Contains(val, "0") {
c.screenshot("modal_closed_after_input_click")
t.Errorf("BUG: modal cerro tras click en input hex (dialogs=%s)", val)
}
// Click 2: zona saturation del ColorPicker
if err := browser.CdpClick(c.conn, "[role='dialog'] .mantine-ColorPicker-saturation"); err != nil {
t.Logf("click saturation no fue posible: %v", err)
} else {
time.Sleep(150 * time.Millisecond)
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if strings.Contains(val, "0") {
c.screenshot("modal_closed_after_saturation")
t.Errorf("BUG: modal cerro tras click en saturation (dialogs=%s)", val)
}
}
// Click 3: swatch
val = c.eval(`(() => { const s = document.querySelector('[role="dialog"] .mantine-ColorPicker-swatch'); if (!s) return 'NO_SWATCH'; s.click(); return 'OK'; })()`)
t.Logf("swatch click: %s", val)
time.Sleep(150 * time.Millisecond)
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if strings.Contains(val, "0") {
c.screenshot("modal_closed_after_swatch")
t.Errorf("BUG: modal cerro tras click en swatch (dialogs=%s)", val)
}
// Click 4: titulo del modal (zona muerta)
val = c.eval(`(() => { const t = document.querySelector('.mantine-Modal-title'); if (!t) return 'NO_TITLE'; t.click(); return 'OK'; })()`)
t.Logf("title click: %s", val)
time.Sleep(150 * time.Millisecond)
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if strings.Contains(val, "0") {
c.screenshot("modal_closed_after_title")
t.Errorf("BUG: modal cerro tras click en title (dialogs=%s)", val)
}
c.screenshot("modal_after_all_clicks")
}
// Simula drag desde dentro del ColorPicker hasta fuera del modal,
// que es el patron de uso humano cuando arrastra el saturation.
func TestColorPicker_DragInsideThenOutside_StaysOpen(t *testing.T) {
c, _ := authedSetup(t)
if err := browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second); err != nil {
t.Fatalf("avatar: %v", err)
}
if err := browser.CdpClick(c.conn, "[aria-label='Usuario']"); err != nil {
t.Fatalf("click avatar: %v", err)
}
if err := browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second); err != nil {
t.Fatalf("picker grid: %v", err)
}
if err := browser.CdpClick(c.conn, "[aria-label='Color personalizado']"); err != nil {
t.Fatalf("click +: %v", err)
}
if err := browser.CdpWaitElement(c.conn, "[role='dialog'] .mantine-ColorPicker-saturation", 3*time.Second); err != nil {
t.Fatalf("saturation no aparecio: %v", err)
}
// Despachar drag manual via JS: pointerdown sat, pointermove out, pointerup out.
out := c.eval(`(() => {
const sat = document.querySelector('[role="dialog"] .mantine-ColorPicker-saturation');
if (!sat) return 'NO_SAT';
const r = sat.getBoundingClientRect();
const startX = r.left + r.width / 2;
const startY = r.top + r.height / 2;
const endX = r.left - 200; // fuera del modal por la izquierda
const endY = r.top - 200; // fuera por arriba
const fire = (target, type, x, y) => {
const ev = new PointerEvent(type, {
bubbles: true, cancelable: true, composed: true,
clientX: x, clientY: y, pointerId: 1, pointerType: 'mouse', isPrimary: true, button: 0, buttons: 1,
});
target.dispatchEvent(ev);
const m = new MouseEvent(type === 'pointerdown' ? 'mousedown' : type === 'pointerup' ? 'mouseup' : 'mousemove', {
bubbles: true, cancelable: true, view: window,
clientX: x, clientY: y, button: 0, buttons: type === 'pointerup' ? 0 : 1,
});
target.dispatchEvent(m);
};
fire(sat, 'pointerdown', startX, startY);
// Mover en pasos
for (let i = 1; i <= 10; i++) {
const x = startX + (endX - startX) * i / 10;
const y = startY + (endY - startY) * i / 10;
const elAt = document.elementFromPoint(x, y) || document;
fire(elAt, 'pointermove', x, y);
}
const finalEl = document.elementFromPoint(endX, endY) || document;
fire(finalEl, 'pointerup', endX, endY);
return 'OK';
})()`)
t.Logf("drag result: %s", out)
time.Sleep(200 * time.Millisecond)
val := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
t.Logf("dialogs tras drag fuera: %s", val)
c.screenshot("modal_after_drag_outside")
if strings.Contains(val, "0") {
t.Errorf("BUG: modal cerro tras drag desde saturation hasta fuera del modal")
}
// Click en una zona vacia del modal (no input, no buttons).
val = c.eval(`(() => {
const dlg = document.querySelector('[role="dialog"]');
if (!dlg) return 'NO_DLG';
const r = dlg.getBoundingClientRect();
// Click en el header del modal (margen superior).
const x = r.left + 10;
const y = r.top + 10;
const target = document.elementFromPoint(x, y);
if (!target) return 'NO_TARGET';
const ev = new MouseEvent('click', { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y });
target.dispatchEvent(ev);
return 'OK target=' + target.tagName + '.' + target.className;
})()`)
t.Logf("click header zone: %s", val)
time.Sleep(150 * time.Millisecond)
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
t.Logf("dialogs tras click header: %s", val)
if strings.Contains(val, "0") {
c.screenshot("modal_closed_after_header_click")
t.Errorf("BUG: modal cerro tras click en header del modal")
}
}
func jsonQuote(s string) string {
b, _ := json.Marshal(s)
return string(b)
}
// Click en cada region clickeable del modal — verifica que ninguna cierre.
func TestColorPicker_AllRegionsKeepModalOpen(t *testing.T) {
c, _ := authedSetup(t)
if err := browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second); err != nil {
t.Fatalf("avatar: %v", err)
}
browser.CdpClick(c.conn, "[aria-label='Usuario']")
browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second)
browser.CdpClick(c.conn, "[aria-label='Color personalizado']")
browser.CdpWaitElement(c.conn, "[role='dialog']", 3*time.Second)
regions := []struct {
name string
selector string
}{
{"body padding", "[role='dialog'] .mantine-Modal-body"},
{"saturation", "[role='dialog'] .mantine-ColorPicker-saturation"},
{"hue slider", "[role='dialog'] .mantine-ColorPicker-slider"},
{"swatch 0", "[role='dialog'] .mantine-ColorPicker-swatch"},
{"hex input", "[role='dialog'] input"},
{"hex label", "[role='dialog'] .mantine-TextInput-label"},
{"stack gap", "[role='dialog'] .mantine-Stack-root"},
}
for _, r := range regions {
v := c.eval(`(() => {
const el = document.querySelector(` + jsonQuote(r.selector) + `);
if (!el) return 'NO_EL';
const rect = el.getBoundingClientRect();
const x = rect.left + rect.width / 2;
const y = rect.top + rect.height / 2;
const target = document.elementFromPoint(x, y);
if (!target) return 'NO_TARGET';
['pointerdown','mousedown','pointerup','mouseup','click'].forEach(type => {
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent;
const ev = new Ctor(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, button: 0, view: window });
target.dispatchEvent(ev);
});
return 'OK ' + target.tagName + '.' + (target.className || '').slice(0, 40);
})()`)
t.Logf("region %s: %s", r.name, v)
time.Sleep(80 * time.Millisecond)
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if strings.Contains(count, "0") {
c.screenshot("modal_closed_at_" + strings.ReplaceAll(r.name, " ", "_"))
t.Errorf("BUG: modal cerro tras click en region %q", r.name)
break
}
}
}
// Verifica que clicar el ColorPicker en zonas de uso real (dragging del saturation,
// click en el slider de hue, click en swatches) NO cierre el modal.
// Sleep extra de 600ms tras cada accion para esperar transiciones Mantine.
func TestColorPicker_RealisticInteractions(t *testing.T) {
c, _ := authedSetup(t)
browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second)
browser.CdpClick(c.conn, "[aria-label='Usuario']")
time.Sleep(300 * time.Millisecond)
browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second)
browser.CdpClick(c.conn, "[aria-label='Color personalizado']")
browser.CdpWaitElement(c.conn, "[role='dialog'] .mantine-ColorPicker-saturation", 3*time.Second)
time.Sleep(500 * time.Millisecond) // animacion modal entrada
// 1. Drag DENTRO del saturation (movimientos cortos, sin salir)
out := c.eval(`(() => {
const sat = document.querySelector('[role="dialog"] .mantine-ColorPicker-saturation');
if (!sat) return 'NO_SAT';
const r = sat.getBoundingClientRect();
const mid = (axis) => axis === 'x' ? r.left + r.width / 2 : r.top + r.height / 2;
const fire = (target, type, x, y) => {
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent;
target.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, button: 0, buttons: type === 'pointerup' || type === 'mouseup' ? 0 : 1, view: window, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
};
fire(sat, 'pointerdown', mid('x'), mid('y'));
fire(sat, 'mousedown', mid('x'), mid('y'));
for (let i = 0; i < 5; i++) {
const x = r.left + (r.width * (0.3 + i * 0.1));
const y = r.top + (r.height * (0.3 + i * 0.1));
fire(sat, 'pointermove', x, y);
fire(sat, 'mousemove', x, y);
}
fire(sat, 'pointerup', mid('x'), mid('y'));
fire(sat, 'mouseup', mid('x'), mid('y'));
return 'OK';
})()`)
t.Logf("drag interno saturation: %s", out)
time.Sleep(600 * time.Millisecond)
c.screenshot("after_drag_internal")
val := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
t.Logf("dialogs tras drag interno (600ms): %s", val)
if strings.Contains(val, "0") {
t.Errorf("BUG: modal cerro tras drag interno de saturation")
}
// 2. Verificar que Mantine NO añade close button (X) — debe estar deshabilitado.
closeBtn := c.eval(`document.querySelectorAll('[role="dialog"] .mantine-Modal-close').length`)
t.Logf("close buttons en modal: %s", closeBtn)
if !strings.Contains(closeBtn, "0") {
t.Errorf("BUG: modal tiene close button (X). Click accidental cierra. Usar withCloseButton={false}")
}
}
// Helper: abre modal avatar y devuelve ctx con modal listo.
func openAvatarColorModal(t *testing.T) *ctx {
t.Helper()
c, _ := authedSetup(t)
browser.CdpWaitElement(c.conn, "[aria-label='Usuario']", 8*time.Second)
browser.CdpClick(c.conn, "[aria-label='Usuario']")
time.Sleep(300 * time.Millisecond)
browser.CdpWaitElement(c.conn, "[aria-label='Color personalizado']", 5*time.Second)
browser.CdpClick(c.conn, "[aria-label='Color personalizado']")
browser.CdpWaitElement(c.conn, "[role='dialog']", 3*time.Second)
time.Sleep(500 * time.Millisecond)
return c
}
// Comportamiento deseado: clicks DENTRO del modal NO cierran. Tests granulares
// con sleep generoso para esperar animaciones de Mantine.
func TestColorPicker_InsideClicks_DoNotClose(t *testing.T) {
c := openAvatarColorModal(t)
regions := []struct {
name string
selector string
}{
{"hex_input", "[role='dialog'] input"},
{"hex_label", "[role='dialog'] .mantine-TextInput-label"},
{"saturation_center", "[role='dialog'] .mantine-ColorPicker-saturation"},
{"hue_slider", "[role='dialog'] .mantine-ColorPicker-slider"},
{"swatch_first", "[role='dialog'] .mantine-ColorPicker-swatch"},
{"body", "[role='dialog'] .mantine-Modal-body"},
{"stack", "[role='dialog'] .mantine-Stack-root"},
{"title", "[role='dialog'] .mantine-Modal-title"},
{"header", "[role='dialog'] .mantine-Modal-header"},
{"content", "[role='dialog']"},
}
for _, r := range regions {
t.Run(r.name, func(t *testing.T) {
res := c.eval(`(() => {
const el = document.querySelector(` + jsonQuote(r.selector) + `);
if (!el) return 'NO_EL';
const rc = el.getBoundingClientRect();
const x = rc.left + Math.min(rc.width / 2, 30);
const y = rc.top + Math.min(rc.height / 2, 12);
const target = document.elementFromPoint(x, y);
if (!target) return 'NO_TARGET';
['pointerdown','mousedown','pointerup','mouseup','click'].forEach(type => {
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent;
target.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, button: 0, buttons: type.includes('up') ? 0 : 1, view: window, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
});
return 'OK ' + target.tagName;
})()`)
t.Logf("region %s: %s", r.name, res)
time.Sleep(500 * time.Millisecond) // esperar animaciones
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if strings.Contains(count, "0") {
c.screenshot("inside_closed_" + r.name)
t.Errorf("BUG: modal cerro tras click en %q", r.name)
}
})
}
}
// Click en overlay (zona oscura fuera del panel del modal) DEBE cerrar.
func TestColorPicker_OverlayClick_Closes(t *testing.T) {
c := openAvatarColorModal(t)
res := c.eval(`(() => {
const overlay = document.querySelector('.mantine-Overlay-root, .mantine-Modal-overlay');
if (!overlay) return 'NO_OVERLAY';
const rc = overlay.getBoundingClientRect();
// click en esquina superior izquierda del overlay (lejos del modal centrado)
const x = rc.left + 10;
const y = rc.top + 10;
const target = document.elementFromPoint(x, y);
if (!target) return 'NO_TARGET';
['pointerdown','mousedown','pointerup','mouseup','click'].forEach(type => {
const Ctor = type.startsWith('pointer') ? PointerEvent : MouseEvent;
target.dispatchEvent(new Ctor(type, { bubbles: true, cancelable: true, composed: true, clientX: x, clientY: y, button: 0, view: window, pointerId: 1, pointerType: 'mouse', isPrimary: true }));
});
return 'OK ' + target.tagName + '.' + (target.className || '').slice(0,30);
})()`)
t.Logf("overlay click: %s", res)
time.Sleep(500 * time.Millisecond)
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if !strings.Contains(count, "0") {
c.screenshot("overlay_did_not_close")
t.Errorf("BUG: modal NO cerro tras click en overlay (esperado: cierra)")
}
}
// Boton Cancelar DEBE cerrar.
func TestColorPicker_CancelButton_Closes(t *testing.T) {
c := openAvatarColorModal(t)
res := c.eval(`(() => { const b = [...document.querySelectorAll('[role="dialog"] button')].find(x => x.textContent.trim() === 'Cancelar'); if (!b) return 'NO_BTN'; b.click(); return 'OK'; })()`)
t.Logf("cancelar: %s", res)
time.Sleep(500 * time.Millisecond)
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if !strings.Contains(count, "0") {
t.Errorf("BUG: modal NO cerro tras Cancelar")
}
}
// Boton Aceptar DEBE cerrar.
func TestColorPicker_AcceptButton_Closes(t *testing.T) {
c := openAvatarColorModal(t)
res := c.eval(`(() => { const b = [...document.querySelectorAll('[role="dialog"] button')].find(x => x.textContent.trim() === 'Aceptar'); if (!b) return 'NO_BTN'; b.click(); return 'OK'; })()`)
t.Logf("aceptar: %s", res)
time.Sleep(500 * time.Millisecond)
count := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
if !strings.Contains(count, "0") {
t.Errorf("BUG: modal NO cerro tras Aceptar")
}
}
// Modal NO debe tener close button (X) — clicks accidentales cierran.
func TestColorPicker_NoXCloseButton(t *testing.T) {
c := openAvatarColorModal(t)
count := c.eval(`document.querySelectorAll('[role="dialog"] .mantine-Modal-close').length`)
t.Logf("X buttons: %s", count)
if !strings.Contains(count, "0") {
t.Errorf("BUG: modal tiene close button (X). Quitar withCloseButton={false}")
}
}
func TestColorPicker_CardMenu_ModalStaysOpen(t *testing.T) {
c, _ := authedSetup(t)
// Esperar al menos una card.
if err := browser.CdpWaitElement(c.conn, "[aria-label='Acciones']", 8*time.Second); err != nil {
c.screenshot("debug_no_card")
t.Fatalf("card menu trigger no visible: %v", err)
}
if err := browser.CdpClick(c.conn, "[aria-label='Acciones']"); err != nil {
t.Fatalf("click card menu: %v", err)
}
time.Sleep(150 * time.Millisecond)
// Click submenu Color.
val := c.eval(`(() => { const items = [...document.querySelectorAll('.mantine-Menu-item')]; const t = items.find(i => i.textContent.trim() === 'Color'); if (!t) return 'NOT_FOUND'; t.click(); return 'OK'; })()`)
if !strings.Contains(val, "OK") {
c.screenshot("debug_no_color_item")
t.Fatalf("item Color no encontrado en menu: %s", val)
}
time.Sleep(200 * time.Millisecond)
c.screenshot("card_color_popover_open")
// Inyectar capturador de logs ahora (despues de la nav, antes del click).
c.eval(`(() => { window.__logs = []; const orig = console.log; console.log = function(...a) { window.__logs.push(a.map(x => typeof x === 'object' ? JSON.stringify(x) : String(x)).join(' ')); orig.apply(console, a); }; })()`)
// Diagnostico DOM: cuantos "+" hay y donde estan?
plus := c.eval(`document.querySelectorAll('[aria-label="Color personalizado"]').length`)
t.Logf("'+' en DOM: %s", plus)
plusVisible := c.eval(`(() => { const el = document.querySelector('[aria-label="Color personalizado"]'); if (!el) return 'NO_EL'; const r = el.getBoundingClientRect(); return JSON.stringify({x: r.x, y: r.y, w: r.width, h: r.height}); })()`)
t.Logf("'+' rect: %s", plusVisible)
// Click "+" custom.
if err := browser.CdpClick(c.conn, "[aria-label='Color personalizado']"); err != nil {
t.Fatalf("click + en card popover: %v", err)
}
// Sondear cada 50ms hasta 800ms para ver si el modal aparece y luego desaparece.
for i := 0; i < 16; i++ {
time.Sleep(50 * time.Millisecond)
v := c.eval(`document.querySelectorAll('[role="dialog"]').length`)
t.Logf("[%dms] dialogs=%s", (i+1)*50, v)
}
c.screenshot("card_after_plus_click")
val = c.eval(`document.querySelectorAll('[role="dialog"]').length`)
t.Logf("dialogs final (card): %s", val)
logs := c.eval(`JSON.stringify(window.__logs || [])`)
t.Logf("console logs: %s", logs)
if strings.Contains(val, "0") {
t.Errorf("BUG: Modal del color picker se cerro inmediatamente (card menu)")
}
}
+7
View File
@@ -0,0 +1,7 @@
module kanban-e2e
go 1.25.0
require fn-registry v0.0.0-00010101000000-000000000000
replace fn-registry => ../../..
+178
View File
@@ -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
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 66 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 71 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB