import { test, expect, webkit, chromium, devices, type Browser, type Page } from '@playwright/test'; /** * Regression: tapping a block/task must open THAT entry in the block-edit * carousel — centered and active — not a different one. * * This was a real, mobile-only bug (https://schmelczer.dev/towers/). It does NOT * reproduce in desktop Chromium at a small viewport — it needs a true mobile * engine. On WebKit (iOS Safari) the carousel's opening `scrollTo` ran in a * microtask right after the modal host is reparented to , before the * snap container's scroll range was established, so it no-op'd and every tap * surfaced the same wrong card. Hence this spec drives real WebKit + Chromium * mobile profiles directly rather than relying on the configured project. * * docker compose -f docker-compose.dev.yml up --build -d * PLAYWRIGHT_BASE_URL=http://life-towers:8000 npx playwright test carousel-active-card * docker compose -f docker-compose.dev.yml down -v */ const BASE = process.env['PLAYWRIGHT_BASE_URL'] ?? 'http://localhost:8000'; async function loadSample(page: Page): Promise { await page.goto(BASE + '/'); await page.waitForSelector('text=Welcome to Life Towers', { timeout: 15000 }); await page.waitForTimeout(350); await page.getByRole('button', { name: 'Load sample towers' }).click(); await page.waitForSelector('section.modal', { state: 'detached' }); await page.waitForTimeout(1800); // let the falling animation settle } /** Centre offset (px) of the active card relative to the carousel viewport. */ async function activeCardOffCenter(page: Page): Promise { return page.evaluate(() => { const c = document.querySelector('lt-block-edit .carousel') as HTMLElement | null; const card = document.querySelector('lt-block-edit .card.active') as HTMLElement | null; if (!c || !card) return 9999; const cr = c.getBoundingClientRect(); const kr = card.getBoundingClientRect(); return Math.round(kr.left + kr.width / 2 - (cr.left + cr.width / 2)); }); } async function closeCarousel(page: Page): Promise { await page.locator('lt-block-edit .exit').first().click({ force: true }); await page.waitForSelector('section.modal', { state: 'detached' }); await page.waitForTimeout(150); } async function runSuite(browser: Browser, deviceName: string): Promise { const ctx = await browser.newContext({ ...devices[deviceName] }); const page = await ctx.newPage(); await loadSample(page); // ── Done blocks ─────────────────────────────────────────────────────────── // Tap a sample (first / middle / last) of the Reading tower's falling squares // and confirm each opens — and centers — its own card. const reading = page.locator('lt-tower').nth(0); const squares = reading.locator('lt-block'); const ids: string[] = []; for (let i = 0; i < (await squares.count()); i++) { const id = await squares.nth(i).getAttribute('data-block-id'); if (id && !ids.includes(id)) ids.push(id); } expect(ids.length).toBeGreaterThan(2); for (const id of [ids[0], ids[Math.floor(ids.length / 2)], ids[ids.length - 1]]) { await reading.locator(`lt-block[data-block-id="${id}"]`).first().tap(); await page.waitForSelector('section.modal.active'); await page.waitForTimeout(700); // open scroll + the 150ms adjustPosition settle await expect(page.locator('lt-block-edit .card.active')).toHaveAttribute('data-block-id', id); expect(Math.abs(await activeCardOffCenter(page))).toBeLessThan(30); await closeCarousel(page); } // ── Pending task ────────────────────────────────────────────────────────── // Tap the 2nd task of the Side projects tower (it has two); the active card's // description must be the one we tapped. const sideProjects = page.locator('lt-tower').nth(1); const header = sideProjects.locator('lt-tasks .header'); if (await header.count()) { await header.first().click(); await page.waitForTimeout(400); } const task = sideProjects.locator('lt-tasks .task-description').nth(1); const taskText = (await task.innerText()).trim(); await task.tap(); await page.waitForSelector('section.modal.active'); await page.waitForTimeout(700); await expect(page.locator('lt-block-edit .card.active textarea')).toHaveValue(taskText); expect(Math.abs(await activeCardOffCenter(page))).toBeLessThan(30); await closeCarousel(page); await ctx.close(); } test('WebKit mobile: tapping a block/task opens its own carousel card', async () => { const browser = await webkit.launch(); try { await runSuite(browser, 'iPhone 13'); } finally { await browser.close(); } }); test('Chromium mobile: tapping a block/task opens its own carousel card', async () => { const browser = await chromium.launch(); try { await runSuite(browser, 'Pixel 7'); } finally { await browser.close(); } });