175 lines
7.1 KiB
TypeScript
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}`);
|
|
});
|
|
});
|