Files
root-org/tests/e2e/kanban-perf.spec.ts

175 lines
7.1 KiB
TypeScript

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<number>((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}`);
});
});