Sternboard: free retro board, no signup (initial)
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:
@@ -0,0 +1,61 @@
|
||||
import postgres from "postgres";
|
||||
|
||||
// Crea la base de datos e2e y su esquema desde cero antes de arrancar los servidores.
|
||||
const DDL = `
|
||||
DROP TABLE IF EXISTS action_items, votes, notes, groups, columns, boards CASCADE;
|
||||
CREATE TABLE boards (
|
||||
id text PRIMARY KEY, title text NOT NULL, phase text NOT NULL DEFAULT 'writing',
|
||||
revealed boolean NOT NULL DEFAULT false, votes_revealed boolean NOT NULL DEFAULT false,
|
||||
votes_per_user integer NOT NULL DEFAULT 3, allow_multi_vote boolean NOT NULL DEFAULT true,
|
||||
moderation_mode text NOT NULL DEFAULT 'everyone', show_card_creators boolean NOT NULL DEFAULT true,
|
||||
locked boolean NOT NULL DEFAULT false,
|
||||
timer_ends_at timestamptz, timer_running boolean NOT NULL DEFAULT false,
|
||||
moderator_token text NOT NULL, created_at timestamptz NOT NULL DEFAULT now(), expires_at timestamptz
|
||||
);
|
||||
CREATE TABLE columns (
|
||||
id text PRIMARY KEY, board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
title text NOT NULL, position integer NOT NULL
|
||||
);
|
||||
CREATE TABLE groups (
|
||||
id text PRIMARY KEY, board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
column_id text NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
|
||||
title text NOT NULL DEFAULT '', position double precision NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE TABLE notes (
|
||||
id text PRIMARY KEY, board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
column_id text NOT NULL REFERENCES columns(id) ON DELETE CASCADE,
|
||||
group_id text REFERENCES groups(id) ON DELETE SET NULL,
|
||||
author_id text NOT NULL, author_name text NOT NULL, text text NOT NULL, color text,
|
||||
revealed boolean NOT NULL DEFAULT false, position double precision NOT NULL DEFAULT 0,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE votes (
|
||||
board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
target_type text NOT NULL, target_id text NOT NULL, participant_id text NOT NULL,
|
||||
count integer NOT NULL DEFAULT 1
|
||||
);
|
||||
CREATE UNIQUE INDEX votes_target_participant_uniq ON votes (target_id, participant_id);
|
||||
CREATE TABLE action_items (
|
||||
id text PRIMARY KEY, board_id text NOT NULL REFERENCES boards(id) ON DELETE CASCADE,
|
||||
text text NOT NULL, owner text, done boolean NOT NULL DEFAULT false,
|
||||
created_at timestamptz NOT NULL DEFAULT now()
|
||||
);
|
||||
`;
|
||||
|
||||
export default async function globalSetup() {
|
||||
const url = process.env.E2E_DATABASE_URL ?? "postgres://retro:retro@localhost:5433/retro_e2e";
|
||||
const dbName = new URL(url).pathname.slice(1);
|
||||
const admin = postgres(url.replace(`/${dbName}`, "/postgres"), { max: 1 });
|
||||
try {
|
||||
const exists = await admin`SELECT 1 FROM pg_database WHERE datname = ${dbName}`;
|
||||
if (exists.length === 0) await admin.unsafe(`CREATE DATABASE ${dbName}`);
|
||||
} finally {
|
||||
await admin.end();
|
||||
}
|
||||
const sql = postgres(url, { max: 1 });
|
||||
try {
|
||||
await sql.unsafe(DDL);
|
||||
} finally {
|
||||
await sql.end();
|
||||
}
|
||||
}
|
||||
Generated
+91
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"name": "retropentool-e2e",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "retropentool-e2e",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"postgres": "^3.4.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@playwright/test": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.61.0.tgz",
|
||||
"integrity": "sha512-cKA5B6lpFEMyMGjxF54QihfYpB4FkEGH+qZhtArDEG+wezQAJY8Pq6C7T1SjWz+FFzt3TbyoXBQYk/0292TdJA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.61.0.tgz",
|
||||
"integrity": "sha512-Z+7BeeqQPRRzklHsVFP4KTGIyMxKUmfeRA4WisM6G3/XW6nwGeX6fX9qYaDa+CiUqpOkb2f6X3nar05R3kSuJQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.61.0"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"node_modules/playwright-core": {
|
||||
"version": "1.61.0",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.61.0.tgz",
|
||||
"integrity": "sha512-caX7TrY3Ml6egyDX0WUcTHDxodl/b51y5wJOdCEA36QviK/s2g081hvmGs8eaE3DWb6NYZQ6BjO/QkNRPenoPA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/postgres": {
|
||||
"version": "3.4.9",
|
||||
"resolved": "https://registry.npmjs.org/postgres/-/postgres-3.4.9.tgz",
|
||||
"integrity": "sha512-GD3qdB0x1z9xgFI6cdRD6xu2Sp2WCOEoe3mtnyB5Ee0XrrL5Pe+e4CCnJrRMnL1zYtRDZmQQVbvOttLnKDLnaw==",
|
||||
"dev": true,
|
||||
"license": "Unlicense",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"type": "individual",
|
||||
"url": "https://github.com/sponsors/porsager"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "sternboard-e2e",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"test": "playwright test",
|
||||
"test:headed": "playwright test --headed"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"postgres": "^3.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
import { defineConfig, devices } from "@playwright/test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const E2E_DB = process.env.E2E_DATABASE_URL ?? "postgres://retro:retro@localhost:5433/retro_e2e";
|
||||
const serverDir = fileURLToPath(new URL("../server", import.meta.url));
|
||||
const webDir = fileURLToPath(new URL("../web", import.meta.url));
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests",
|
||||
timeout: 30_000,
|
||||
expect: { timeout: 10_000 },
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
globalSetup: "./global-setup.ts",
|
||||
reporter: [["list"]],
|
||||
use: {
|
||||
baseURL: "http://localhost:5173",
|
||||
locale: "en-US", // i18n -> inglés determinista
|
||||
trace: "on-first-retry",
|
||||
},
|
||||
projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
|
||||
webServer: [
|
||||
{
|
||||
command: "npm run serve",
|
||||
cwd: serverDir,
|
||||
port: 3001,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
env: {
|
||||
DATABASE_URL: E2E_DB,
|
||||
PORT: "3001",
|
||||
WEB_ORIGIN: "http://localhost:5173",
|
||||
},
|
||||
},
|
||||
{
|
||||
command: "npm run dev",
|
||||
cwd: webDir,
|
||||
port: 5173,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 60_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
Reference in New Issue
Block a user