mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-15 18:25:18 +02:00
feat: add e2e test cursor skill
This commit is contained in:
parent
2e1b9b5582
commit
4ac994792b
45 changed files with 39848 additions and 0 deletions
919
.cursor/skills/playwright-testing/drag-and-drop.md
Executable file
919
.cursor/skills/playwright-testing/drag-and-drop.md
Executable file
|
|
@ -0,0 +1,919 @@
|
|||
# Drag and Drop Recipes
|
||||
|
||||
> **When to use**: You need to test drag-and-drop interactions -- sortable lists, kanban boards, file drop zones, or any element that can be repositioned by dragging.
|
||||
|
||||
---
|
||||
|
||||
## Recipe 1: Native HTML5 Drag and Drop
|
||||
|
||||
### Complete Example
|
||||
|
||||
**TypeScript**
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('drags an item from one container to another', async ({ page }) => {
|
||||
await page.goto('/drag-demo');
|
||||
|
||||
const sourceItem = page.getByText('Draggable Item');
|
||||
const dropZone = page.locator('#drop-zone');
|
||||
|
||||
// Verify initial state
|
||||
await expect(sourceItem).toBeVisible();
|
||||
await expect(dropZone).not.toContainText('Draggable Item');
|
||||
|
||||
// Perform drag and drop
|
||||
await sourceItem.dragTo(dropZone);
|
||||
|
||||
// Verify the item moved to the drop zone
|
||||
await expect(dropZone).toContainText('Draggable Item');
|
||||
});
|
||||
|
||||
test('drags between two drop zones using locators', async ({ page }) => {
|
||||
await page.goto('/drag-demo');
|
||||
|
||||
const item = page.locator('[data-testid="item-1"]');
|
||||
const zoneA = page.locator('[data-testid="zone-a"]');
|
||||
const zoneB = page.locator('[data-testid="zone-b"]');
|
||||
|
||||
// Item starts in zone A
|
||||
await expect(zoneA).toContainText('Item 1');
|
||||
|
||||
// Drag to zone B
|
||||
await item.dragTo(zoneB);
|
||||
|
||||
// Item is now in zone B, not zone A
|
||||
await expect(zoneB).toContainText('Item 1');
|
||||
await expect(zoneA).not.toContainText('Item 1');
|
||||
|
||||
// Drag back to zone A
|
||||
await zoneB.getByText('Item 1').dragTo(zoneA);
|
||||
|
||||
await expect(zoneA).toContainText('Item 1');
|
||||
await expect(zoneB).not.toContainText('Item 1');
|
||||
});
|
||||
|
||||
test('verifies drag visual feedback', async ({ page }) => {
|
||||
await page.goto('/drag-demo');
|
||||
|
||||
const sourceItem = page.getByText('Draggable Item');
|
||||
const dropZone = page.locator('#drop-zone');
|
||||
|
||||
// Start drag manually to check intermediate states
|
||||
await sourceItem.hover();
|
||||
await page.mouse.down();
|
||||
|
||||
// Move toward the drop zone
|
||||
const dropBox = await dropZone.boundingBox();
|
||||
await page.mouse.move(dropBox!.x + dropBox!.width / 2, dropBox!.y + dropBox!.height / 2);
|
||||
|
||||
// Verify the drop zone shows a visual indicator while hovering
|
||||
await expect(dropZone).toHaveClass(/drag-over|highlight/);
|
||||
|
||||
// Complete the drop
|
||||
await page.mouse.up();
|
||||
|
||||
// Verify drop zone returns to normal styling
|
||||
await expect(dropZone).not.toHaveClass(/drag-over|highlight/);
|
||||
await expect(dropZone).toContainText('Draggable Item');
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('drags an item from one container to another', async ({ page }) => {
|
||||
await page.goto('/drag-demo');
|
||||
|
||||
const sourceItem = page.getByText('Draggable Item');
|
||||
const dropZone = page.locator('#drop-zone');
|
||||
|
||||
await expect(sourceItem).toBeVisible();
|
||||
await expect(dropZone).not.toContainText('Draggable Item');
|
||||
|
||||
await sourceItem.dragTo(dropZone);
|
||||
|
||||
await expect(dropZone).toContainText('Draggable Item');
|
||||
});
|
||||
|
||||
test('drags between two drop zones', async ({ page }) => {
|
||||
await page.goto('/drag-demo');
|
||||
|
||||
const item = page.locator('[data-testid="item-1"]');
|
||||
const zoneA = page.locator('[data-testid="zone-a"]');
|
||||
const zoneB = page.locator('[data-testid="zone-b"]');
|
||||
|
||||
await expect(zoneA).toContainText('Item 1');
|
||||
|
||||
await item.dragTo(zoneB);
|
||||
|
||||
await expect(zoneB).toContainText('Item 1');
|
||||
await expect(zoneA).not.toContainText('Item 1');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recipe 2: Sortable Lists (Reordering Items)
|
||||
|
||||
### Complete Example
|
||||
|
||||
**TypeScript**
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('reorders items in a sortable list', async ({ page }) => {
|
||||
await page.goto('/tasks');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Task list' });
|
||||
|
||||
// Verify initial order
|
||||
const initialItems = await list.getByRole('listitem').allTextContents();
|
||||
expect(initialItems[0]).toContain('Task A');
|
||||
expect(initialItems[1]).toContain('Task B');
|
||||
expect(initialItems[2]).toContain('Task C');
|
||||
|
||||
// Drag Task C to the top (above Task A)
|
||||
const taskC = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
||||
const taskA = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
||||
|
||||
await taskC.dragTo(taskA);
|
||||
|
||||
// Verify new order
|
||||
const reorderedItems = await list.getByRole('listitem').allTextContents();
|
||||
expect(reorderedItems[0]).toContain('Task C');
|
||||
expect(reorderedItems[1]).toContain('Task A');
|
||||
expect(reorderedItems[2]).toContain('Task B');
|
||||
});
|
||||
|
||||
test('reorders using drag handle', async ({ page }) => {
|
||||
await page.goto('/tasks');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Task list' });
|
||||
|
||||
// Use the drag handle (grip icon) instead of the whole item
|
||||
const dragHandle = list
|
||||
.getByRole('listitem')
|
||||
.filter({ hasText: 'Task C' })
|
||||
.getByRole('button', { name: /drag|reorder|grip/i });
|
||||
|
||||
const targetItem = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
||||
|
||||
await dragHandle.dragTo(targetItem);
|
||||
|
||||
const reorderedItems = await list.getByRole('listitem').allTextContents();
|
||||
expect(reorderedItems[0]).toContain('Task C');
|
||||
});
|
||||
|
||||
test('reorder persists after page reload', async ({ page }) => {
|
||||
await page.goto('/tasks');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Task list' });
|
||||
|
||||
const taskC = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
||||
const taskA = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
||||
|
||||
await taskC.dragTo(taskA);
|
||||
|
||||
// Wait for the save API call to complete
|
||||
await page.waitForResponse((response) =>
|
||||
response.url().includes('/api/tasks/reorder') && response.status() === 200
|
||||
);
|
||||
|
||||
// Reload and verify persistence
|
||||
await page.reload();
|
||||
|
||||
const items = await list.getByRole('listitem').allTextContents();
|
||||
expect(items[0]).toContain('Task C');
|
||||
expect(items[1]).toContain('Task A');
|
||||
expect(items[2]).toContain('Task B');
|
||||
});
|
||||
|
||||
test('reorders with precise mouse movements for libraries that need it', async ({ page }) => {
|
||||
await page.goto('/tasks');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Task list' });
|
||||
const sourceItem = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
||||
const targetItem = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
||||
|
||||
const sourceBox = await sourceItem.boundingBox();
|
||||
const targetBox = await targetItem.boundingBox();
|
||||
|
||||
// Some drag libraries (react-beautiful-dnd, dnd-kit) require specific mouse event sequences
|
||||
await sourceItem.hover();
|
||||
await page.mouse.down();
|
||||
|
||||
// Move in small steps -- some libraries require incremental movement to register
|
||||
const steps = 10;
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await page.mouse.move(
|
||||
sourceBox!.x + sourceBox!.width / 2,
|
||||
sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / steps),
|
||||
{ steps: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
const items = await list.getByRole('listitem').allTextContents();
|
||||
expect(items[0]).toContain('Task C');
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('reorders items in a sortable list', async ({ page }) => {
|
||||
await page.goto('/tasks');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Task list' });
|
||||
|
||||
const initialItems = await list.getByRole('listitem').allTextContents();
|
||||
expect(initialItems[0]).toContain('Task A');
|
||||
expect(initialItems[1]).toContain('Task B');
|
||||
expect(initialItems[2]).toContain('Task C');
|
||||
|
||||
const taskC = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
||||
const taskA = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
||||
|
||||
await taskC.dragTo(taskA);
|
||||
|
||||
const reorderedItems = await list.getByRole('listitem').allTextContents();
|
||||
expect(reorderedItems[0]).toContain('Task C');
|
||||
expect(reorderedItems[1]).toContain('Task A');
|
||||
expect(reorderedItems[2]).toContain('Task B');
|
||||
});
|
||||
|
||||
test('reorders with precise mouse movements', async ({ page }) => {
|
||||
await page.goto('/tasks');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Task list' });
|
||||
const sourceItem = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
||||
const targetItem = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
||||
|
||||
const sourceBox = await sourceItem.boundingBox();
|
||||
const targetBox = await targetItem.boundingBox();
|
||||
|
||||
await sourceItem.hover();
|
||||
await page.mouse.down();
|
||||
|
||||
const steps = 10;
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
await page.mouse.move(
|
||||
sourceBox.x + sourceBox.width / 2,
|
||||
sourceBox.y + (targetBox.y - sourceBox.y) * (i / steps),
|
||||
{ steps: 1 }
|
||||
);
|
||||
}
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
const items = await list.getByRole('listitem').allTextContents();
|
||||
expect(items[0]).toContain('Task C');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recipe 3: Kanban Board (Moving Between Columns)
|
||||
|
||||
### Complete Example
|
||||
|
||||
**TypeScript**
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('moves a card from Todo to In Progress', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
const todoColumn = page.locator('[data-column="todo"]');
|
||||
const inProgressColumn = page.locator('[data-column="in-progress"]');
|
||||
|
||||
// Verify initial state
|
||||
const card = todoColumn.getByText('Fix login bug');
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
const todoCountBefore = await todoColumn.getByRole('article').count();
|
||||
const inProgressCountBefore = await inProgressColumn.getByRole('article').count();
|
||||
|
||||
// Drag the card to In Progress
|
||||
await card.dragTo(inProgressColumn);
|
||||
|
||||
// Verify the card moved
|
||||
await expect(inProgressColumn.getByText('Fix login bug')).toBeVisible();
|
||||
await expect(todoColumn.getByText('Fix login bug')).not.toBeVisible();
|
||||
|
||||
// Verify counts updated
|
||||
await expect(todoColumn.getByRole('article')).toHaveCount(todoCountBefore - 1);
|
||||
await expect(inProgressColumn.getByRole('article')).toHaveCount(inProgressCountBefore + 1);
|
||||
|
||||
// Verify column header count badge updated
|
||||
await expect(todoColumn.getByText(`(${todoCountBefore - 1})`)).toBeVisible();
|
||||
await expect(inProgressColumn.getByText(`(${inProgressCountBefore + 1})`)).toBeVisible();
|
||||
});
|
||||
|
||||
test('moves a card through all stages', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
const columns = {
|
||||
todo: page.locator('[data-column="todo"]'),
|
||||
inProgress: page.locator('[data-column="in-progress"]'),
|
||||
review: page.locator('[data-column="review"]'),
|
||||
done: page.locator('[data-column="done"]'),
|
||||
};
|
||||
|
||||
// Todo -> In Progress
|
||||
await columns.todo.getByText('Fix login bug').dragTo(columns.inProgress);
|
||||
await expect(columns.inProgress.getByText('Fix login bug')).toBeVisible();
|
||||
|
||||
// In Progress -> Review
|
||||
await columns.inProgress.getByText('Fix login bug').dragTo(columns.review);
|
||||
await expect(columns.review.getByText('Fix login bug')).toBeVisible();
|
||||
|
||||
// Review -> Done
|
||||
await columns.review.getByText('Fix login bug').dragTo(columns.done);
|
||||
await expect(columns.done.getByText('Fix login bug')).toBeVisible();
|
||||
|
||||
// Verify the card is only in the Done column
|
||||
await expect(columns.todo.getByText('Fix login bug')).not.toBeVisible();
|
||||
await expect(columns.inProgress.getByText('Fix login bug')).not.toBeVisible();
|
||||
await expect(columns.review.getByText('Fix login bug')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('reorders cards within the same column', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
const todoColumn = page.locator('[data-column="todo"]');
|
||||
|
||||
const cardA = todoColumn.getByRole('article').filter({ hasText: 'Task A' });
|
||||
const cardC = todoColumn.getByRole('article').filter({ hasText: 'Task C' });
|
||||
|
||||
// Drag Task C above Task A within the same column
|
||||
await cardC.dragTo(cardA);
|
||||
|
||||
// Verify new order within the column
|
||||
const cards = await todoColumn.getByRole('article').allTextContents();
|
||||
expect(cards.indexOf('Task C')).toBeLessThan(cards.indexOf('Task A'));
|
||||
});
|
||||
|
||||
test('kanban board state persists via API', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
const todoColumn = page.locator('[data-column="todo"]');
|
||||
const inProgressColumn = page.locator('[data-column="in-progress"]');
|
||||
|
||||
// Wait for the PATCH/PUT to complete after the drag
|
||||
const responsePromise = page.waitForResponse(
|
||||
(r) => r.url().includes('/api/cards') && r.request().method() === 'PATCH'
|
||||
);
|
||||
|
||||
await todoColumn.getByText('Fix login bug').dragTo(inProgressColumn);
|
||||
|
||||
const response = await responsePromise;
|
||||
expect(response.status()).toBe(200);
|
||||
|
||||
const body = await response.json();
|
||||
expect(body.column).toBe('in-progress');
|
||||
|
||||
// Reload to confirm persistence
|
||||
await page.reload();
|
||||
await expect(inProgressColumn.getByText('Fix login bug')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('moves a card from Todo to In Progress', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
const todoColumn = page.locator('[data-column="todo"]');
|
||||
const inProgressColumn = page.locator('[data-column="in-progress"]');
|
||||
|
||||
const card = todoColumn.getByText('Fix login bug');
|
||||
await expect(card).toBeVisible();
|
||||
|
||||
await card.dragTo(inProgressColumn);
|
||||
|
||||
await expect(inProgressColumn.getByText('Fix login bug')).toBeVisible();
|
||||
await expect(todoColumn.getByText('Fix login bug')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('moves a card through all stages', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
const columns = {
|
||||
todo: page.locator('[data-column="todo"]'),
|
||||
inProgress: page.locator('[data-column="in-progress"]'),
|
||||
review: page.locator('[data-column="review"]'),
|
||||
done: page.locator('[data-column="done"]'),
|
||||
};
|
||||
|
||||
await columns.todo.getByText('Fix login bug').dragTo(columns.inProgress);
|
||||
await expect(columns.inProgress.getByText('Fix login bug')).toBeVisible();
|
||||
|
||||
await columns.inProgress.getByText('Fix login bug').dragTo(columns.review);
|
||||
await expect(columns.review.getByText('Fix login bug')).toBeVisible();
|
||||
|
||||
await columns.review.getByText('Fix login bug').dragTo(columns.done);
|
||||
await expect(columns.done.getByText('Fix login bug')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recipe 4: File Drop Zone
|
||||
|
||||
### Complete Example
|
||||
|
||||
**TypeScript**
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
import path from 'path';
|
||||
|
||||
test('uploads a file by dropping it on the drop zone', async ({ page }) => {
|
||||
await page.goto('/upload');
|
||||
|
||||
const dropZone = page.locator('[data-testid="file-drop-zone"]');
|
||||
|
||||
// Verify initial state
|
||||
await expect(dropZone).toContainText('Drag files here');
|
||||
|
||||
// Create a DataTransfer-like event using the file chooser approach
|
||||
// Since Playwright cannot simulate native DnD file events from the OS,
|
||||
// we use the underlying input[type=file] that drop zones rely on
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
|
||||
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/sample-document.pdf'));
|
||||
|
||||
// Verify file appears in the upload list
|
||||
await expect(page.getByText('sample-document.pdf')).toBeVisible();
|
||||
await expect(page.getByText(/\d+ KB/)).toBeVisible();
|
||||
});
|
||||
|
||||
test('simulates drag-over visual feedback via JavaScript dispatch', async ({ page }) => {
|
||||
await page.goto('/upload');
|
||||
|
||||
const dropZone = page.locator('[data-testid="file-drop-zone"]');
|
||||
|
||||
// Dispatch dragenter/dragover events to test visual feedback
|
||||
await dropZone.dispatchEvent('dragenter', {
|
||||
dataTransfer: { types: ['Files'] },
|
||||
});
|
||||
|
||||
// Verify the drop zone shows the active/hover state
|
||||
await expect(dropZone).toHaveClass(/drag-active|drop-highlight/);
|
||||
await expect(dropZone).toContainText(/drop.*here|release.*upload/i);
|
||||
|
||||
// Dispatch dragleave to reset
|
||||
await dropZone.dispatchEvent('dragleave');
|
||||
|
||||
await expect(dropZone).not.toHaveClass(/drag-active|drop-highlight/);
|
||||
});
|
||||
|
||||
test('rejects invalid file types in drop zone', async ({ page }) => {
|
||||
await page.goto('/upload');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
|
||||
// Try to upload a file type that is not allowed
|
||||
await fileInput.setInputFiles({
|
||||
name: 'malware.exe',
|
||||
mimeType: 'application/x-msdownload',
|
||||
buffer: Buffer.from('fake-content'),
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText(/not allowed|invalid file type/i);
|
||||
await expect(page.getByText('malware.exe')).not.toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
const path = require('path');
|
||||
|
||||
test('uploads a file by dropping it on the drop zone', async ({ page }) => {
|
||||
await page.goto('/upload');
|
||||
|
||||
const dropZone = page.locator('[data-testid="file-drop-zone"]');
|
||||
await expect(dropZone).toContainText('Drag files here');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/sample-document.pdf'));
|
||||
|
||||
await expect(page.getByText('sample-document.pdf')).toBeVisible();
|
||||
});
|
||||
|
||||
test('rejects invalid file types in drop zone', async ({ page }) => {
|
||||
await page.goto('/upload');
|
||||
|
||||
const fileInput = page.locator('input[type="file"]');
|
||||
|
||||
await fileInput.setInputFiles({
|
||||
name: 'malware.exe',
|
||||
mimeType: 'application/x-msdownload',
|
||||
buffer: Buffer.from('fake-content'),
|
||||
});
|
||||
|
||||
await expect(page.getByRole('alert')).toContainText(/not allowed|invalid file type/i);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recipe 5: Drag with Custom Preview / Ghost Image
|
||||
|
||||
### Complete Example
|
||||
|
||||
**TypeScript**
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('shows custom drag preview during drag operation', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
const card = page.locator('[data-testid="card-1"]');
|
||||
const targetColumn = page.locator('[data-column="in-progress"]');
|
||||
|
||||
const cardBox = await card.boundingBox();
|
||||
const targetBox = await targetColumn.boundingBox();
|
||||
|
||||
// Start dragging
|
||||
await card.hover();
|
||||
await page.mouse.down();
|
||||
|
||||
// Move partially toward the target
|
||||
const midX = (cardBox!.x + targetBox!.x) / 2;
|
||||
const midY = (cardBox!.y + targetBox!.y) / 2;
|
||||
await page.mouse.move(midX, midY, { steps: 5 });
|
||||
|
||||
// Take a screenshot to visually verify the drag preview
|
||||
// The custom preview element should be visible during drag
|
||||
await expect(page.locator('.drag-preview')).toBeVisible();
|
||||
|
||||
// Verify the original card shows a placeholder/ghost
|
||||
await expect(card).toHaveClass(/dragging|placeholder/);
|
||||
|
||||
// Complete the drag
|
||||
await page.mouse.move(
|
||||
targetBox!.x + targetBox!.width / 2,
|
||||
targetBox!.y + targetBox!.height / 2,
|
||||
{ steps: 5 }
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
// Preview should disappear after drop
|
||||
await expect(page.locator('.drag-preview')).not.toBeVisible();
|
||||
});
|
||||
|
||||
test('drag preview shows item count for multi-select drag', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
// Select multiple cards
|
||||
await page.locator('[data-testid="card-1"]').click();
|
||||
await page.locator('[data-testid="card-2"]').click({ modifiers: ['Shift'] });
|
||||
await page.locator('[data-testid="card-3"]').click({ modifiers: ['Shift'] });
|
||||
|
||||
// Start dragging one of the selected cards
|
||||
const card = page.locator('[data-testid="card-1"]');
|
||||
const targetColumn = page.locator('[data-column="done"]');
|
||||
|
||||
await card.hover();
|
||||
await page.mouse.down();
|
||||
|
||||
const targetBox = await targetColumn.boundingBox();
|
||||
await page.mouse.move(targetBox!.x + 50, targetBox!.y + 50, { steps: 5 });
|
||||
|
||||
// Verify the preview shows the count of selected items
|
||||
await expect(page.locator('.drag-preview')).toContainText('3 items');
|
||||
|
||||
await page.mouse.up();
|
||||
|
||||
// All three cards should be in the target column
|
||||
await expect(targetColumn.locator('[data-testid="card-1"]')).toBeVisible();
|
||||
await expect(targetColumn.locator('[data-testid="card-2"]')).toBeVisible();
|
||||
await expect(targetColumn.locator('[data-testid="card-3"]')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('shows custom drag preview during drag operation', async ({ page }) => {
|
||||
await page.goto('/board');
|
||||
|
||||
const card = page.locator('[data-testid="card-1"]');
|
||||
const targetColumn = page.locator('[data-column="in-progress"]');
|
||||
|
||||
const cardBox = await card.boundingBox();
|
||||
const targetBox = await targetColumn.boundingBox();
|
||||
|
||||
await card.hover();
|
||||
await page.mouse.down();
|
||||
|
||||
const midX = (cardBox.x + targetBox.x) / 2;
|
||||
const midY = (cardBox.y + targetBox.y) / 2;
|
||||
await page.mouse.move(midX, midY, { steps: 5 });
|
||||
|
||||
await expect(page.locator('.drag-preview')).toBeVisible();
|
||||
await expect(card).toHaveClass(/dragging|placeholder/);
|
||||
|
||||
await page.mouse.move(
|
||||
targetBox.x + targetBox.width / 2,
|
||||
targetBox.y + targetBox.height / 2,
|
||||
{ steps: 5 }
|
||||
);
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(page.locator('.drag-preview')).not.toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Recipe 6: Testing Drag Position and Coordinates
|
||||
|
||||
### Complete Example
|
||||
|
||||
**TypeScript**
|
||||
|
||||
```typescript
|
||||
import { test, expect } from '@playwright/test';
|
||||
|
||||
test('drags an element to a specific coordinate on a canvas', async ({ page }) => {
|
||||
await page.goto('/canvas-editor');
|
||||
|
||||
const canvas = page.locator('#design-canvas');
|
||||
const element = page.locator('[data-testid="shape-1"]');
|
||||
|
||||
// Get the initial position
|
||||
const initialBox = await element.boundingBox();
|
||||
expect(initialBox).toBeTruthy();
|
||||
|
||||
// Define target position (absolute coordinates within the canvas)
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
const targetX = canvasBox!.x + 300;
|
||||
const targetY = canvasBox!.y + 200;
|
||||
|
||||
// Drag to specific coordinates
|
||||
await element.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(targetX, targetY, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
// Verify element moved to approximately the right position
|
||||
const newBox = await element.boundingBox();
|
||||
expect(newBox!.x).toBeCloseTo(targetX - newBox!.width / 2, -1);
|
||||
expect(newBox!.y).toBeCloseTo(targetY - newBox!.height / 2, -1);
|
||||
});
|
||||
|
||||
test('snaps element to grid when dropped', async ({ page }) => {
|
||||
await page.goto('/canvas-editor');
|
||||
|
||||
const element = page.locator('[data-testid="shape-1"]');
|
||||
const canvas = page.locator('#design-canvas');
|
||||
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
|
||||
// Drag to a position that is not on the grid
|
||||
await element.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(canvasBox!.x + 147, canvasBox!.y + 83, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
// With a 20px grid, position should snap to nearest grid point
|
||||
const snappedBox = await element.boundingBox();
|
||||
expect(snappedBox!.x % 20).toBeCloseTo(0, 0);
|
||||
expect(snappedBox!.y % 20).toBeCloseTo(0, 0);
|
||||
});
|
||||
|
||||
test('constrains drag within boundaries', async ({ page }) => {
|
||||
await page.goto('/canvas-editor');
|
||||
|
||||
const element = page.locator('[data-testid="constrained-element"]');
|
||||
const container = page.locator('#constraint-box');
|
||||
|
||||
const containerBox = await container.boundingBox();
|
||||
|
||||
// Try to drag far outside the container
|
||||
await element.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(containerBox!.x + containerBox!.width + 500, containerBox!.y - 200, {
|
||||
steps: 10,
|
||||
});
|
||||
await page.mouse.up();
|
||||
|
||||
// Element should be clamped within the container
|
||||
const elementBox = await element.boundingBox();
|
||||
|
||||
expect(elementBox!.x).toBeGreaterThanOrEqual(containerBox!.x);
|
||||
expect(elementBox!.y).toBeGreaterThanOrEqual(containerBox!.y);
|
||||
expect(elementBox!.x + elementBox!.width).toBeLessThanOrEqual(
|
||||
containerBox!.x + containerBox!.width
|
||||
);
|
||||
expect(elementBox!.y + elementBox!.height).toBeLessThanOrEqual(
|
||||
containerBox!.y + containerBox!.height
|
||||
);
|
||||
});
|
||||
|
||||
test('resizes an element by dragging its handle', async ({ page }) => {
|
||||
await page.goto('/canvas-editor');
|
||||
|
||||
const element = page.locator('[data-testid="shape-1"]');
|
||||
await element.click(); // Select to show resize handles
|
||||
|
||||
const resizeHandle = element.locator('.resize-handle-se'); // south-east corner
|
||||
const handleBox = await resizeHandle.boundingBox();
|
||||
|
||||
const initialBox = await element.boundingBox();
|
||||
|
||||
// Drag the resize handle to make the element larger
|
||||
await resizeHandle.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(handleBox!.x + 100, handleBox!.y + 80, { steps: 5 });
|
||||
await page.mouse.up();
|
||||
|
||||
const newBox = await element.boundingBox();
|
||||
expect(newBox!.width).toBeCloseTo(initialBox!.width + 100, -1);
|
||||
expect(newBox!.height).toBeCloseTo(initialBox!.height + 80, -1);
|
||||
});
|
||||
```
|
||||
|
||||
**JavaScript**
|
||||
|
||||
```javascript
|
||||
const { test, expect } = require('@playwright/test');
|
||||
|
||||
test('drags an element to a specific coordinate on a canvas', async ({ page }) => {
|
||||
await page.goto('/canvas-editor');
|
||||
|
||||
const canvas = page.locator('#design-canvas');
|
||||
const element = page.locator('[data-testid="shape-1"]');
|
||||
|
||||
const canvasBox = await canvas.boundingBox();
|
||||
const targetX = canvasBox.x + 300;
|
||||
const targetY = canvasBox.y + 200;
|
||||
|
||||
await element.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(targetX, targetY, { steps: 10 });
|
||||
await page.mouse.up();
|
||||
|
||||
const newBox = await element.boundingBox();
|
||||
expect(newBox.x).toBeCloseTo(targetX - newBox.width / 2, -1);
|
||||
expect(newBox.y).toBeCloseTo(targetY - newBox.height / 2, -1);
|
||||
});
|
||||
|
||||
test('constrains drag within boundaries', async ({ page }) => {
|
||||
await page.goto('/canvas-editor');
|
||||
|
||||
const element = page.locator('[data-testid="constrained-element"]');
|
||||
const container = page.locator('#constraint-box');
|
||||
|
||||
const containerBox = await container.boundingBox();
|
||||
|
||||
await element.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(containerBox.x + containerBox.width + 500, containerBox.y - 200, {
|
||||
steps: 10,
|
||||
});
|
||||
await page.mouse.up();
|
||||
|
||||
const elementBox = await element.boundingBox();
|
||||
expect(elementBox.x).toBeGreaterThanOrEqual(containerBox.x);
|
||||
expect(elementBox.y).toBeGreaterThanOrEqual(containerBox.y);
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Variations
|
||||
|
||||
### Drag and Drop with Keyboard Accessibility
|
||||
|
||||
```typescript
|
||||
test('reorders items using keyboard', async ({ page }) => {
|
||||
await page.goto('/tasks');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Task list' });
|
||||
const taskC = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
||||
|
||||
// Focus the item
|
||||
await taskC.focus();
|
||||
|
||||
// Use keyboard shortcut to pick up the item
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
// Move up twice
|
||||
await page.keyboard.press('ArrowUp');
|
||||
await page.keyboard.press('ArrowUp');
|
||||
|
||||
// Drop the item
|
||||
await page.keyboard.press('Space');
|
||||
|
||||
const items = await list.getByRole('listitem').allTextContents();
|
||||
expect(items[0]).toContain('Task C');
|
||||
});
|
||||
```
|
||||
|
||||
### Drag and Drop Between iframes
|
||||
|
||||
```typescript
|
||||
test('drags between main page and iframe', async ({ page }) => {
|
||||
await page.goto('/editor');
|
||||
|
||||
const sourceItem = page.getByText('Widget A');
|
||||
const iframe = page.frameLocator('#preview-frame');
|
||||
const dropTarget = iframe.locator('#content-area');
|
||||
|
||||
// Cross-frame drag requires coordinates since dragTo does not work across frames
|
||||
const sourceBox = await sourceItem.boundingBox();
|
||||
const iframeElement = page.locator('#preview-frame');
|
||||
const iframeBox = await iframeElement.boundingBox();
|
||||
|
||||
// Calculate target position within the iframe
|
||||
const targetX = iframeBox!.x + 100;
|
||||
const targetY = iframeBox!.y + 100;
|
||||
|
||||
await sourceItem.hover();
|
||||
await page.mouse.down();
|
||||
await page.mouse.move(targetX, targetY, { steps: 20 });
|
||||
await page.mouse.up();
|
||||
|
||||
await expect(iframe.getByText('Widget A')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Touch-Based Drag on Mobile
|
||||
|
||||
```typescript
|
||||
test('drags items on mobile via touch events', async ({ page }) => {
|
||||
await page.goto('/tasks');
|
||||
|
||||
const list = page.getByRole('list', { name: 'Task list' });
|
||||
const sourceItem = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
||||
const targetItem = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
||||
|
||||
const sourceBox = await sourceItem.boundingBox();
|
||||
const targetBox = await targetItem.boundingBox();
|
||||
|
||||
// Simulate long-press then drag using touch
|
||||
await page.touchscreen.tap(sourceBox!.x + sourceBox!.width / 2, sourceBox!.y + sourceBox!.height / 2);
|
||||
|
||||
// Dispatch touchstart, touchmove, touchend for libraries that use touch events
|
||||
await sourceItem.dispatchEvent('touchstart', {
|
||||
touches: [{ clientX: sourceBox!.x + 10, clientY: sourceBox!.y + 10 }],
|
||||
});
|
||||
|
||||
// Move in steps
|
||||
for (let i = 1; i <= 5; i++) {
|
||||
const y = sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / 5);
|
||||
await sourceItem.dispatchEvent('touchmove', {
|
||||
touches: [{ clientX: sourceBox!.x + 10, clientY: y }],
|
||||
});
|
||||
}
|
||||
|
||||
await sourceItem.dispatchEvent('touchend');
|
||||
|
||||
const items = await list.getByRole('listitem').allTextContents();
|
||||
expect(items[0]).toContain('Task C');
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tips
|
||||
|
||||
1. **Use `dragTo()` first, then fall back to manual mouse events**. Playwright's built-in `dragTo()` handles most native HTML5 drag and drop. Only use `page.mouse.down()` / `move()` / `up()` sequences for custom drag libraries (react-beautiful-dnd, dnd-kit, SortableJS) that need specific event sequences.
|
||||
|
||||
2. **Add intermediate mouse move steps for drag libraries**. Libraries like `react-beautiful-dnd` require multiple `mousemove` events with small increments to detect a drag. Use `{ steps: 10 }` or a manual loop. A single jump from source to target often fails silently.
|
||||
|
||||
3. **Always assert the final state, not just the drop event**. After a drag-and-drop, verify that the DOM actually reflects the change -- item order, column contents, position coordinates. Visual feedback during drag is nice to test but the final persisted state matters most.
|
||||
|
||||
4. **Use `boundingBox()` for coordinate-based assertions**. When testing canvas editors, grid layouts, or position-sensitive drops, capture the bounding box after the operation and compare against expected coordinates. Use `toBeCloseTo()` for tolerance.
|
||||
|
||||
5. **Test undo after drag operations**. If your app supports Ctrl+Z to undo a reorder or move, test that the drag operation is reversible. This catches state management bugs that only appear on undo.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [Playwright Drag and Drop Docs](https://playwright.dev/docs/input#drag-and-drop)
|
||||
- `recipes/file-upload-download.md` -- File drop zones specifically
|
||||
- `foundations/actions.md` -- Mouse and keyboard interaction basics
|
||||
- `patterns/page-objects.md` -- Encapsulate complex drag flows
|
||||
Loading…
Add table
Add a link
Reference in a new issue