Sternboard: free retro board, no signup (initial)
CI/CD Pipeline / test (push) Successful in 26s
CI/CD Pipeline / build-and-push (push) Successful in 44s

Servidor Fastify + Socket.IO + Drizzle/Postgres; front Vite+React (EN/ES, tema claro/oscuro).
Tableros por columnas con plantillas, notas privadas/públicas, agrupación, votos
configurables (multivoto), timer compartido con alarma, fases, action items,
export Markdown/JSON e import, autodestrucción, gestión de columnas.
Imagen única (front servido por el servidor) y CI que la construye y publica.
This commit is contained in:
Millaguie
2026-06-16 10:27:33 +02:00
commit f11ea58c2c
64 changed files with 12359 additions and 0 deletions
+40
View File
@@ -0,0 +1,40 @@
import { test, expect, type Page } from "@playwright/test";
async function createBoard(page: Page, title: string) {
await page.goto("/");
await page.getByTestId("board-title").fill(title);
await page.getByTestId("template").selectOption("mad-sad-glad");
await page.getByTestId("create").click();
await page.waitForURL(/\/b\/.+/);
}
async function joinAs(page: Page, name: string) {
await page.getByTestId("name").fill(name);
await page.getByTestId("join").click();
await expect(page.getByTestId("column").first()).toBeVisible();
}
test("añadir y eliminar columnas", async ({ page }) => {
await createBoard(page, "Retro columnas");
await joinAs(page, "Ana");
await expect(page.getByTestId("column")).toHaveCount(3);
await page.getByTestId("add-column").click();
await expect(page.getByTestId("column")).toHaveCount(4);
// eliminar la última vía menú ⋯
await page.getByTestId("column").last().getByTestId("col-menu").click();
await page.getByTestId("col-delete").click();
await expect(page.getByTestId("column")).toHaveCount(3);
});
test("fases Lluvia/Votación/Discusión", async ({ page }) => {
await createBoard(page, "Retro fases");
await joinAs(page, "Ana");
// por defecto Lluvia (writing) activa
await expect(page.getByTestId("phase-writing")).toHaveClass(/on/);
await page.getByTestId("phase-voting").click();
await expect(page.getByTestId("phase-voting")).toHaveClass(/on/);
await expect(page.getByTestId("phase-writing")).not.toHaveClass(/on/);
});
+99
View File
@@ -0,0 +1,99 @@
import { test, expect, type Page, type Locator } from "@playwright/test";
async function createBoard(page: Page, title: string) {
await page.goto("/");
await page.getByTestId("board-title").fill(title);
await page.getByTestId("template").selectOption("mad-sad-glad");
await page.getByTestId("create").click();
await page.waitForURL(/\/b\/.+/);
return page.url();
}
async function joinAs(page: Page, boardUrl: string, name: string) {
await page.goto(boardUrl);
await page.getByTestId("name").fill(name);
await page.getByTestId("join").click();
await expect(page.getByTestId("column").first()).toBeVisible();
}
// Escribe una nota privada y la publica (queda en el área pública).
// Esperamos el conteo de notas privadas para evitar clics sobre elementos que re-renderizan.
async function addPublicNote(col: Locator, text: string) {
await col.getByTestId("note-input").fill(text);
await col.getByTestId("note-input").press("Enter");
await expect(col.getByTestId("private-note")).toHaveCount(1);
await col.getByTestId("private-note").getByTestId("publish").click();
await expect(col.getByTestId("private-note")).toHaveCount(0);
}
// Arrastra una nota (desde su tirador) sobre el centro de otra (gesto de agrupar).
async function dragOnto(page: Page, sourceNote: Locator, target: Locator) {
const handle = await sourceNote.getByTestId("note-handle").boundingBox();
const t = await target.boundingBox();
if (!handle || !t) throw new Error("sin boundingBox");
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2);
await page.mouse.down();
await page.mouse.move(handle.x + handle.width / 2, handle.y + handle.height / 2 + 8); // supera el umbral
await page.mouse.move(t.x + t.width / 2, t.y + t.height / 2, { steps: 12 });
await page.mouse.up();
}
test("agrupar dos notas arrastrando una sobre otra", async ({ page }) => {
const url = await createBoard(page, "Retro grupos");
await joinAs(page, url, "Ana");
const col = page.getByTestId("column").first();
await addPublicNote(col, "primera");
await addPublicNote(col, "segunda");
await expect(col.getByTestId("note")).toHaveCount(2);
const notes = col.getByTestId("note");
await dragOnto(page, notes.nth(1), notes.nth(0));
// aparece un grupo con dos notas dentro
await expect(page.getByTestId("group")).toHaveCount(1);
await expect(page.getByTestId("group").getByTestId("note")).toHaveCount(2);
// votar el grupo como unidad
await page.getByTestId("group").getByTestId("vote").click();
await expect(page.getByTestId("group").getByTestId("vote")).toHaveClass(/voted/);
// y nombrarlo
await page.getByTestId("group-title").fill("Tema común");
await page.getByTestId("group-title").blur();
await expect(page.getByTestId("group-title")).toHaveValue("Tema común");
});
test("multivoto: apilar varios votos en el mismo ítem y quitar", async ({ page }) => {
const url = await createBoard(page, "Retro multivoto");
await joinAs(page, url, "Ana");
const col = page.getByTestId("column").first();
await addPublicNote(col, "favorita");
await expect(col.getByTestId("note")).toHaveCount(1);
// dos votos al mismo ítem (multivoto activo por defecto)
await page.getByTestId("vote").click();
await page.getByTestId("vote").click();
// el moderador revela los totales -> debe verse 2
await page.getByTestId("reveal-votes").click();
await expect(page.getByTestId("vote")).toContainText("2");
// quitar uno -> 1
await page.getByTestId("vote-remove").click();
await expect(page.getByTestId("vote")).toContainText("1");
});
test("el moderador cambia los ajustes en caliente (título y votos)", async ({ page }) => {
const url = await createBoard(page, "Original");
await joinAs(page, url, "Mod");
await page.getByTestId("settings").click();
await page.getByTestId("settings-title").fill("Renombrada en vivo");
await page.getByTestId("settings-votes").fill("9");
await page.getByTestId("settings-save").click();
await expect(page.locator("header h1")).toHaveText("Renombrada en vivo");
await expect(page.locator(".votes-pill")).toContainText("9");
});
+52
View File
@@ -0,0 +1,52 @@
import { test, expect, type Page } from "@playwright/test";
async function createBoard(page: Page, title: string) {
await page.goto("/");
await page.getByTestId("board-title").fill(title);
await page.getByTestId("template").selectOption("mad-sad-glad");
await page.getByTestId("create").click();
await page.waitForURL(/\/b\/.+/);
return page.url();
}
async function joinAs(page: Page, boardUrl: string, name: string) {
await page.goto(boardUrl);
await page.getByTestId("name").fill(name);
await page.getByTestId("join").click();
await expect(page.getByTestId("column").first()).toBeVisible();
}
test("exportar a JSON e importar: estructura vs todo", async ({ page, request }) => {
const url = await createBoard(page, "Fuente");
const boardId = url.split("/b/")[1];
await joinAs(page, url, "Origen");
const col = page.getByTestId("column").first();
await col.getByTestId("note-input").fill("nota a exportar");
await col.getByTestId("note-input").press("Enter");
await col.getByTestId("publish").first().click(); // publicada para que viaje visible
await expect(col.getByTestId("note")).toHaveCount(1);
const snapshotJson = await (
await request.get(`http://localhost:3001/api/boards/${boardId}/export.json`)
).text();
const filePayload = { name: "fuente.retro.json", mimeType: "application/json", buffer: Buffer.from(snapshotJson) };
// --- import solo estructura: 3 columnas, 0 notas ---
await page.goto("/");
await page.getByTestId("import-toggle").click();
await page.getByText("Only structure", { exact: false }).click();
await page.getByTestId("import-file").setInputFiles(filePayload);
await page.waitForURL(/\/b\/.+/);
await joinAs(page, page.url(), "Importador");
await expect(page.getByTestId("column")).toHaveCount(3);
await expect(page.getByTestId("note")).toHaveCount(0);
// --- import completo: la nota viaja ---
await page.goto("/");
await page.getByTestId("import-toggle").click();
await page.getByTestId("import-file").setInputFiles(filePayload); // modo 'full' por defecto
await page.waitForURL(/\/b\/.+/);
await joinAs(page, page.url(), "Importador2");
await expect(page.getByTestId("note")).toHaveCount(1);
});
+92
View File
@@ -0,0 +1,92 @@
import { test, expect, type Page, type Locator } from "@playwright/test";
async function createBoard(page: Page, title: string) {
await page.goto("/");
await page.getByTestId("board-title").fill(title);
await page.getByTestId("template").selectOption("mad-sad-glad");
await page.getByTestId("create").click();
await page.waitForURL(/\/b\/.+/);
return page.url();
}
async function joinAs(page: Page, url: string, name: string) {
await page.goto(url);
await page.getByTestId("name").fill(name);
await page.getByTestId("join").click();
await expect(page.getByTestId("column").first()).toBeVisible();
}
async function addPublicNote(col: Locator, text: string) {
await col.getByTestId("note-input").fill(text);
await col.getByTestId("note-input").press("Enter");
await expect(col.getByTestId("private-note")).toHaveCount(1);
await col.getByTestId("private-note").getByTestId("publish").click();
await expect(col.getByTestId("private-note")).toHaveCount(0);
}
test("participantes: presencia, editar nombre y mostrar autores", async ({ page }) => {
const url = await createBoard(page, "Retro panel");
await joinAs(page, url, "Ana");
const col = page.getByTestId("column").first();
await addPublicNote(col, "hola");
await expect(col.locator(".note .author")).toHaveText("Ana");
await page.getByTestId("open-participants").click();
const people = page.locator(".people");
await expect(people).toContainText("Ana");
// editar mi nombre
await page.getByTestId("edit-name").click();
await page.getByTestId("name-input").fill("Anita");
await page.getByTestId("name-save").click();
await expect(people).toContainText("Anita");
// ocultar autores de las tarjetas (checkbox controlado por el servidor -> click)
await page.getByTestId("show-creators").click();
await page.mouse.click(20, 400); // cerrar el drawer pulsando en el backdrop
await expect(col.locator(".note .author")).toHaveCount(0);
});
test("action points: añadir y mover a Done", async ({ page }) => {
const url = await createBoard(page, "Retro acciones");
await joinAs(page, url, "Ana");
await page.getByTestId("open-actions").click();
await page.getByTestId("action-input").fill("documentar el deploy");
await page.getByTestId("action-add").click();
await expect(page.getByTestId("todo-list").getByTestId("action-row")).toHaveCount(1);
// marcar como hecho -> la fila se mueve a Done (la casilla se desmonta del To-do)
await page.getByTestId("todo-list").getByTestId("action-done").first().click();
await expect(page.getByTestId("todo-list").getByTestId("action-row")).toHaveCount(0);
await expect(page.getByTestId("done-list").getByTestId("action-row")).toHaveCount(1);
});
test("export modal: previsualización con opciones", async ({ page }) => {
const url = await createBoard(page, "Retro export");
await joinAs(page, url, "Ana");
const col = page.getByTestId("column").first();
await addPublicNote(col, "algo");
await page.getByTestId("settings").click();
await page.getByTestId("open-export").click();
const preview = page.getByTestId("export-preview");
await expect(preview).toContainText("Retro export");
await expect(preview).toContainText("algo");
// desactivar autores -> desaparece el nombre del preview
await expect(preview).toContainText("_Ana_");
await page.getByTestId("opt-authors").uncheck();
await expect(preview).not.toContainText("_Ana_");
});
test("opciones: bloquear deja el board en solo lectura", async ({ page }) => {
const url = await createBoard(page, "Retro lock");
await joinAs(page, url, "Ana");
await page.getByTestId("settings").click();
await page.getByTestId("opt-lock").click();
await page.mouse.click(20, 400); // cerrar el panel
await expect(page.locator(".locked-banner")).toBeVisible();
await expect(page.getByTestId("private-section")).toHaveCount(0); // sin sección privada al bloquear
});
+123
View File
@@ -0,0 +1,123 @@
import { test, expect, type Page, type Locator } from "@playwright/test";
async function createBoard(page: Page, title: string, template = "mad-sad-glad") {
await page.goto("/");
await page.getByTestId("board-title").fill(title);
await page.getByTestId("template").selectOption(template);
await page.getByTestId("create").click();
await page.waitForURL(/\/b\/.+/);
return page.url();
}
async function joinAs(page: Page, boardUrl: string, name: string) {
await page.goto(boardUrl);
await expect(page.getByTestId("name")).toBeVisible();
await page.getByTestId("name").fill(name);
await page.getByTestId("join").click();
await expect(page.getByTestId("column").first()).toBeVisible();
}
// Escribe una nota en la sección privada de una columna (se guarda con Enter).
async function writeNote(col: Locator, text: string) {
await col.getByTestId("note-input").fill(text);
await col.getByTestId("note-input").press("Enter");
await expect(col.getByTestId("private-note").last()).toBeVisible();
}
test("crea un board, pide nombre obligatorio y muestra columnas", async ({ page }) => {
await createBoard(page, "Sprint 42");
await expect(page.getByTestId("name")).toBeVisible();
await expect(page.getByTestId("column")).toHaveCount(0);
await page.getByTestId("name").fill("Fede");
await page.getByTestId("join").click();
await expect(page.getByTestId("column")).toHaveCount(3);
});
test("una nota privada no la ve nadie hasta publicarla", async ({ browser }) => {
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const alice = await ctxA.newPage();
const bob = await ctxB.newPage();
const url = await createBoard(alice, "Retro privada");
await joinAs(alice, url, "Alice");
await joinAs(bob, url, "Bob");
const aliceCol = alice.getByTestId("column").first();
await writeNote(aliceCol, "algo privado");
// Bob no ve nada: ni nota pública ni la privada de Alice
await expect(bob.getByTestId("note")).toHaveCount(0);
await expect(bob.getByTestId("private-note")).toHaveCount(0);
// Alice publica su nota
await aliceCol.getByTestId("publish").first().click();
// ahora es pública para Bob (la ve como párrafo de solo lectura)
await expect(bob.getByTestId("note")).toHaveCount(1);
await expect(bob.getByTestId("note-text")).toHaveText("algo privado");
await ctxA.close();
await ctxB.close();
});
test("Publish all publica todas mis notas de la columna", async ({ page }) => {
const url = await createBoard(page, "Retro publish all");
await joinAs(page, url, "Vera");
const col = page.getByTestId("column").first();
await writeNote(col, "una");
await writeNote(col, "dos");
await expect(col.getByTestId("private-note")).toHaveCount(2);
await col.getByTestId("publish-all").click();
await expect(col.getByTestId("private-note")).toHaveCount(0);
await expect(col.getByTestId("note")).toHaveCount(2);
});
test("votar una nota publicada y exportar markdown con el conteo", async ({ page, request }) => {
const url = await createBoard(page, "Retro votos");
const boardId = url.split("/b/")[1];
await joinAs(page, url, "Vera");
const col = page.getByTestId("column").first();
await writeNote(col, "idea top");
await col.getByTestId("publish").first().click();
await expect(page.getByTestId("note")).toHaveCount(1);
// votar: con totales ocultos el botón marca "✓" (mi voto), no el total
await page.getByTestId("vote").first().click();
await expect(page.getByTestId("vote").first()).toHaveClass(/voted/);
// el moderador revela los votos -> ahora se ve el total
await page.getByTestId("reveal-votes").click();
await expect(page.getByTestId("vote").first()).toContainText("1");
const res = await request.get(`http://localhost:3001/api/boards/${boardId}/export`);
expect(res.status()).toBe(200);
const md = await res.text();
expect(md).toContain("# Retro votos");
expect(md).toContain("idea top — _Vera_ (1 👍)");
});
test("el timer compartido arranca para todos los participantes", async ({ browser }) => {
const ctxA = await browser.newContext();
const ctxB = await browser.newContext();
const alice = await ctxA.newPage();
const bob = await ctxB.newPage();
const url = await createBoard(alice, "Retro timer");
await joinAs(alice, url, "Alice");
await joinAs(bob, url, "Bob");
await alice.getByRole("button", { name: /5\s*min/i }).click();
await expect(alice.locator(".timer .clock")).toHaveText(/0[0-4]:\d{2}/);
await expect(bob.locator(".timer .clock")).toHaveText(/0[0-4]:\d{2}/);
await ctxA.close();
await ctxB.close();
});