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>
@@ -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)")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
module kanban-e2e
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require fn-registry v0.0.0-00010101000000-000000000000
|
||||
|
||||
replace fn-registry => ../../..
|
||||
@@ -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
|
||||
}
|
||||
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 66 KiB |
|
After Width: | Height: | Size: 62 KiB |
|
After Width: | Height: | Size: 71 KiB |
|
After Width: | Height: | Size: 60 KiB |
|
After Width: | Height: | Size: 59 KiB |