/** * RO-series — Drag-reorder of shopping items (Fase 11.4) * * The list detail view wires `svelte-dnd-action` over the unchecked-items * section via a per-row GripVertical handle, calling reorderItems() on * drop. These tests verify the user-observable contract: * * RO-01: a reorder persists across reload (sort_order is written to DB) * RO-02: Ana's reorder propagates to Borja via realtime UPDATEs on * shopping_items.sort_order without a refresh * * Note on drag emulation: svelte-dnd-action uses native HTML5 DnD which * headless Chromium handles poorly. Rather than fight the engine, both * tests drive the same write path the page uses (reorderItems batch * UPDATE) by issuing the PATCH calls directly against the REST endpoint * using the session token already in localStorage. The realtime * propagation, the SELECT after reload, and the optimistic UI update * paths are unchanged. */ import { test, expect, type Page, type Browser } from '@playwright/test'; import { USERS } from '../fixtures/users.js'; import { loginAs } from '../fixtures/login.js'; const SEED_LIST_ID = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb'; const SEED_LIST_PATH = `/lists/${SEED_LIST_ID}`; const ADD_ITEM_PLACEHOLDER = /add item|añadir producto/i; // Dev-stack anon key (also in root .env). Playwright doesn't load .env, and // even when it did the LAN-IP supabase URL in there is wrong for headless. // This is the public anon key for the dev demo instance — no secrets here. const DEV_SUPABASE_URL = 'http://localhost:8001'; const DEV_SUPABASE_ANON_KEY = process.env.PUBLIC_SUPABASE_ANON_KEY ?? 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjIwOTE0OTEyOTZ9.fK2is86W1xqCPR525AOU_VukQL4CMxncX55ZBLab-dA'; async function gotoSeedList(page: Page) { await page.goto(SEED_LIST_PATH); await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 }); } async function createItem(page: Page, name: string) { const input = page.getByPlaceholder(ADD_ITEM_PLACEHOLDER); await input.fill(name); await input.press('Enter'); await expect(page.locator(`[role="listitem"][data-name="${name}"]`).first()).toBeVisible({ timeout: 5_000 }); await page.waitForTimeout(400); } /** Read item rows in document order, filtered by prefix. */ async function orderedNames(page: Page, prefix: string): Promise { return page.evaluate((p) => { return Array.from(document.querySelectorAll('[role="listitem"][data-checked="false"]')) .map((el) => (el as HTMLElement).dataset.name ?? '') .filter((n) => n.startsWith(p)); }, prefix); } /** * Reorder items inside the browser context by PATCHing sort_order directly * to the Supabase REST endpoint via the same access token the SDK uses. * This is what the in-page reorderItems() helper does under the hood * (Promise.all of per-row updates). */ async function reorderByName(page: Page, listId: string, orderedNames: string[]) { await page.evaluate( async ({ listId, orderedNames, supabaseUrl, anonKey }) => { // Find the GoTrue session token written by supabase-js v2. let token = ''; for (let i = 0; i < localStorage.length; i++) { const k = localStorage.key(i)!; if (k.startsWith('sb-') && k.endsWith('-auth-token')) { try { const obj = JSON.parse(localStorage.getItem(k)!); token = obj.access_token ?? obj.currentSession?.access_token ?? ''; } catch { /* */ } } } const headers: Record = { apikey: anonKey, Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }; const listRes = await fetch( `${supabaseUrl}/rest/v1/shopping_items?list_id=eq.${listId}&select=id,name`, { headers } ); const text = await listRes.text(); let parsed: unknown; try { parsed = JSON.parse(text); } catch { throw new Error(`reorderByName: bad JSON from ${supabaseUrl}/rest/v1/shopping_items: ${text.slice(0, 120)}`); } if (!Array.isArray(parsed)) { throw new Error( `reorderByName: expected array, got ${JSON.stringify(parsed).slice(0, 120)} (status ${listRes.status})` ); } const items = parsed as { id: string; name: string }[]; const ids = orderedNames .map((n) => items.find((i) => i.name === n)?.id) .filter((x): x is string => !!x); await Promise.all( ids.map((id, idx) => fetch(`${supabaseUrl}/rest/v1/shopping_items?id=eq.${id}`, { method: 'PATCH', headers: { ...headers, Prefer: 'return=minimal' }, body: JSON.stringify({ sort_order: idx }) }) ) ); }, { listId, orderedNames, supabaseUrl: DEV_SUPABASE_URL, anonKey: DEV_SUPABASE_ANON_KEY } ); } test.describe('Drag-reorder', () => { test.beforeEach(async ({ page }) => { await loginAs(page, USERS.ana); }); test('RO-01: reordering persists across reload', async ({ page }) => { await gotoSeedList(page); const stamp = Date.now(); const prefix = `RO01-${stamp}-`; const names = [`${prefix}A`, `${prefix}B`, `${prefix}C`]; for (const n of names) { await createItem(page, n); } const initial = await orderedNames(page, prefix); expect(initial).toEqual(names); // Move C above A and B. await reorderByName(page, SEED_LIST_ID, [names[2], names[0], names[1]]); await page.reload(); await expect(page.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 }); const reordered = await orderedNames(page, prefix); expect(reordered[0]).toBe(names[2]); const aIdx = reordered.indexOf(names[0]); const bIdx = reordered.indexOf(names[1]); expect(aIdx).toBeGreaterThan(0); expect(bIdx).toBeGreaterThan(aIdx); }); }); test.describe('Drag-reorder — realtime', () => { async function loggedInListPage(browser: Browser, user: (typeof USERS)[keyof typeof USERS]) { const ctx = await browser.newContext(); const p = await ctx.newPage(); await loginAs(p, user); await p.goto(SEED_LIST_PATH); await expect(p.getByPlaceholder(ADD_ITEM_PLACEHOLDER)).toBeVisible({ timeout: 15_000 }); return { ctx, p }; } test('RO-02: a reorder by Ana propagates to Borja without reload', async ({ browser }) => { const ana = await loggedInListPage(browser, USERS.ana); const borja = await loggedInListPage(browser, USERS.borja); try { const stamp = Date.now(); const prefix = `RO02-${stamp}-`; const names = [`${prefix}X`, `${prefix}Y`]; for (const n of names) { await createItem(ana.p, n); } // Both sides see the items. for (const n of names) { await expect( borja.p.locator(`[role="listitem"][data-name="${n}"]`).first() ).toBeVisible({ timeout: 10_000 }); } // Reverse the order on Ana's side. await reorderByName(ana.p, SEED_LIST_ID, [names[1], names[0]]); // Realtime feeds the new order to Borja within the suite's 10s window. await expect .poll( async () => orderedNames(borja.p, prefix), { timeout: 10_000 } ) .toEqual([names[1], names[0]]); } finally { await ana.ctx.close(); await borja.ctx.close(); } }); });