import { test, expect } from '@playwright/test'; import { login, TEST_ORG_SLUG } from './helpers'; /** * Measures the REAL end-to-end latency a user experiences when dragging * a kanban card from one column to another using native mouse events. * * Uses MutationObserver set up BEFORE the drag starts, then performs * a real mouse drag-and-drop sequence. Measures from mouse-up to * card appearing in the target column DOM. */ test.describe('Kanban card move latency', () => { test('real drag-drop: card should appear in target column fast', async ({ page }) => { await login(page); await page.goto(`/${TEST_ORG_SLUG}/kanban`, { waitUntil: 'networkidle' }); // Click into the first board if we're on the board selector const anyBoard = page.locator('text=/board/i').first(); if (await anyBoard.isVisible({ timeout: 3000 }).catch(() => false)) { await anyBoard.click(); } // Wait for columns await page.waitForSelector('[data-column-id]', { timeout: 10000 }); // Ensure there's a card to move — check first column const firstCard = page.locator('[data-column-id]').first().locator('[data-card-id]').first(); if (!(await firstCard.isVisible({ timeout: 2000 }).catch(() => false))) { const addBtn = page.locator('[data-column-id]').first().getByRole('button', { name: /add card/i }); if (await addBtn.isVisible({ timeout: 2000 }).catch(() => false)) { await addBtn.click(); await page.waitForTimeout(300); const titleInput = page.locator('input[placeholder="Card title"]'); await titleInput.fill('Perf Test Card'); await page.getByRole('button', { name: 'Add Card', exact: true }).click(); await page.waitForSelector('[data-card-id]', { timeout: 5000 }); } else { console.log('Cannot create card, skipping'); return; } } // Gather IDs const cardId = await page.locator('[data-column-id]').first().locator('[data-card-id]').first().getAttribute('data-card-id'); const columns = page.locator('[data-column-id]'); if ((await columns.count()) < 2) { console.log('Need 2+ columns'); return; } const dstColId = await columns.nth(1).getAttribute('data-column-id'); console.log(`Card: ${cardId}`); console.log(`Target column: ${dstColId}`); // Set up a MutationObserver on the target column BEFORE the drag starts // This will record the exact timestamp when the card DOM node appears await page.evaluate(({ cardId, dstColId }) => { const dstCol = document.querySelector(`[data-column-id="${dstColId}"]`); if (!dstCol) return; (window as any).__cardAppearedAt = 0; (window as any).__mouseUpAt = 0; const observer = new MutationObserver(() => { if ((window as any).__cardAppearedAt > 0) return; const found = dstCol.querySelector(`[data-card-id="${cardId}"]`); if (found) { (window as any).__cardAppearedAt = performance.now(); observer.disconnect(); } }); observer.observe(dstCol, { childList: true, subtree: true }); // Also hook into mouseup to record the exact drop moment document.addEventListener('mouseup', () => { if ((window as any).__mouseUpAt === 0) { (window as any).__mouseUpAt = performance.now(); } }, { once: true, capture: true }); }, { cardId, dstColId }); // Get bounding boxes const card = page.locator('[data-column-id]').first().locator('[data-card-id]').first(); const cardBox = await card.boundingBox(); const targetBox = await columns.nth(1).boundingBox(); if (!cardBox || !targetBox) { console.log('No bounding boxes'); return; } const srcX = cardBox.x + cardBox.width / 2; const srcY = cardBox.y + cardBox.height / 2; const dstX = targetBox.x + targetBox.width / 2; const dstY = targetBox.y + 100; // Perform REAL mouse drag await page.mouse.move(srcX, srcY); await page.mouse.down(); // Small move to trigger dragstart await page.mouse.move(srcX + 5, srcY, { steps: 2 }); await page.waitForTimeout(50); // Let browser register the drag // Move to target await page.mouse.move(dstX, dstY, { steps: 3 }); await page.waitForTimeout(50); // Let dragover register // Drop await page.mouse.up(); // Wait a bit for everything to settle await page.waitForTimeout(200); // Read the timestamps const result = await page.evaluate(() => { return { mouseUpAt: (window as any).__mouseUpAt as number, cardAppearedAt: (window as any).__cardAppearedAt as number, }; }); const dropToRender = result.cardAppearedAt > 0 && result.mouseUpAt > 0 ? result.cardAppearedAt - result.mouseUpAt : -1; console.log(`\n========================================`); console.log(` mouseup timestamp: ${result.mouseUpAt.toFixed(1)}`); console.log(` card appeared at: ${result.cardAppearedAt.toFixed(1)}`); console.log(` DROP → RENDER LATENCY: ${dropToRender.toFixed(1)}ms`); if (dropToRender < 0) { console.log(' ⚠️ Card never appeared or mouseup not captured'); console.log(' (HTML5 drag may not have fired — trying synthetic fallback)'); } else if (dropToRender < 20) { console.log(' ✅ INSTANT (<20ms)'); } else if (dropToRender < 50) { console.log(' ✅ VERY FAST (<50ms)'); } else if (dropToRender < 100) { console.log(' ⚠️ PERCEPTIBLE (50-100ms)'); } else if (dropToRender < 500) { console.log(' ❌ SLOW (100-500ms)'); } else { console.log(' ❌ VERY SLOW (>500ms)'); } console.log(`========================================\n`); // If native drag didn't work, fall back to synthetic events to at least measure Svelte if (dropToRender < 0) { console.log('Falling back to synthetic drag events...'); const synthLatency = await page.evaluate(({ cardId, dstColId }) => { return new Promise((resolve) => { const cardEl = document.querySelector(`[data-card-id="${cardId}"]`); const dstCol = document.querySelector(`[data-column-id="${dstColId}"]`); if (!cardEl || !dstCol) { resolve(-1); return; } const observer = new MutationObserver(() => { const found = dstCol.querySelector(`[data-card-id="${cardId}"]`); if (found) { observer.disconnect(); resolve(performance.now() - t0); } }); observer.observe(dstCol, { childList: true, subtree: true }); const dt = new DataTransfer(); dt.setData('text/plain', cardId!); cardEl.dispatchEvent(new DragEvent('dragstart', { bubbles: true, cancelable: true, dataTransfer: dt })); dstCol.dispatchEvent(new DragEvent('dragover', { bubbles: true, cancelable: true, dataTransfer: dt })); const t0 = performance.now(); dstCol.dispatchEvent(new DragEvent('drop', { bubbles: true, cancelable: true, dataTransfer: dt })); setTimeout(() => { observer.disconnect(); const found = dstCol.querySelector(`[data-card-id="${cardId}"]`); resolve(found ? performance.now() - t0 : 9999); }, 2000); }); }, { cardId, dstColId }); console.log(` Synthetic drop→render: ${synthLatency.toFixed(1)}ms`); } // Verify card moved const cardInTarget = columns.nth(1).locator(`[data-card-id="${cardId}"]`); const visible = await cardInTarget.isVisible({ timeout: 3000 }).catch(() => false); console.log(` Card visible in target: ${visible}`); }); });