/** * C-series (UI): Shopping list CRUD flows — create, complete, trash. * * Live Keycloak login per test (no cached storageState). See fixtures/login.ts * and the comment in $lib/supabase for why the Supabase client uses a * pass-through lock. */ import { test, expect, type Page } from '@playwright/test'; import { USERS } from '../fixtures/users.js'; import { loginAs } from '../fixtures/login.js'; const PLACEHOLDER_NEW_LIST = /weekly shop|compra semanal/i; /** Heading for a list — may render as h2 (featured card) or h3 (grid item). */ function listHeading(page: Page, name: string) { return page.getByRole('heading', { name: new RegExp(`^${name}$`) }); } /** * The action menu button inside the card for a specific list. The DOM has one * "List actions" button per active card, so we scope by the card wrapper * (ancestor of the heading) — this avoids the strict-mode ambiguity of picking * from many grid siblings. */ function cardActionsButton(page: Page, name: string) { // The card wrapper is the `
` two levels above // the `

` (or one level above `

` for featured). An xpath that walks // up until it finds the button gives us a stable selector. return page .locator( `xpath=//h2[normalize-space()="${name}"]/ancestor::div[.//button[@aria-label]][1]` + ` | //h3[normalize-space()="${name}"]/ancestor::div[.//button[@aria-label]][1]` ) .first() .getByRole('button', { name: /list actions|acciones de lista/i }); } async function gotoListsClean(page: Page) { await page.goto('/lists'); // The masthead "New list" button is the anchor now (replaces the sticky input). await expect( page.getByRole('button', { name: /new list|nueva lista/i }) ).toBeVisible({ timeout: 15_000 }); } async function createList(page: Page, name: string) { await page.getByRole('button', { name: /new list|nueva lista/i }).click(); // Lands on /lists/[id]; the editable title input carries the placeholder. await expect(page).toHaveURL(/\/lists\/[0-9a-f-]+$/, { timeout: 10_000 }); const titleInput = page.getByPlaceholder(PLACEHOLDER_NEW_LIST); await expect(titleInput).toBeVisible({ timeout: 10_000 }); await titleInput.fill(name); // Blur + wait for the 500 ms autosave debounce + buffer. await titleInput.blur(); await page.waitForTimeout(900); // Navigate back to /lists to give callers a consistent starting state. await page.goto('/lists'); await expect(listHeading(page, name)).toBeVisible({ timeout: 10_000 }); } test.describe('Shopping lists — member (Borja)', () => { test.beforeEach(async ({ page }) => { await loginAs(page, USERS.borja); }); test('C-07: create a new shopping list', async ({ page }) => { await gotoListsClean(page); const listName = `E2E list ${Date.now()}`; await createList(page, listName); // Reload to prove the list persists server-side (not just optimistic). await page.reload(); await expect(listHeading(page, listName)).toBeVisible({ timeout: 15_000 }); }); test('C-11: soft-delete a list — it moves to trash', async ({ page }) => { await gotoListsClean(page); const listName = `Trash me ${Date.now()}`; await createList(page, listName); // Scope the menu button to the card containing our new heading — it's // the nearest ancestor that also has a List actions button. await cardActionsButton(page, listName).click(); await page.getByRole('button', { name: /move to trash|enviar a la papelera/i }).click(); await expect(listHeading(page, listName)).not.toBeVisible({ timeout: 5_000 }); // It shows up under the Trash drawer. await page.getByRole('button', { name: /^trash$|^papelera$/i }).first().click(); await expect(page.getByText(listName)).toBeVisible({ timeout: 5_000 }); }); test('C-12: archive a list — it moves out of the active grid', async ({ page }) => { await gotoListsClean(page); const listName = `Archive me ${Date.now()}`; await createList(page, listName); await cardActionsButton(page, listName).click(); await page.getByRole('button', { name: /^archive$|^archivar$/i }).click(); // Active grid no longer shows the archived list. await expect(listHeading(page, listName)).not.toBeVisible({ timeout: 5_000 }); }); }); test.describe('Shopping lists — guest (David, read-only)', () => { test.beforeEach(async ({ page }) => { await loginAs(page, USERS.david); }); test('C-13: guest sees the seed list (read-only)', async ({ page }) => { await page.goto('/lists'); await expect( page.getByRole('button', { name: /Casa García-López/ }) ).toBeVisible({ timeout: 15_000 }); await expect(listHeading(page, 'Weekly shop')).toBeVisible({ timeout: 15_000 }); // If the create-list input is even rendered for guests, typing into it // and pressing Enter must NOT create a committed list (RLS blocks the // insert; the optimistic row rolls back). const input = page.getByPlaceholder(PLACEHOLDER_NEW_LIST); if (await input.isVisible({ timeout: 2_000 }).catch(() => false)) { const name = `David sneaky ${Date.now()}`; await input.fill(name); await input.press('Enter'); await page.waitForTimeout(1_500); await expect(listHeading(page, name)).not.toBeVisible({ timeout: 2_000 }); } }); });