@assertrx/dino 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +293 -0
- package/extensions/dino.ts +70 -0
- package/extensions/mabl-mcp.ts +306 -0
- package/extensions/playwright-mcp.ts +142 -0
- package/package.json +47 -0
- package/prompts/create-pr.md +110 -0
- package/prompts/generate-bdd-from-url.md +65 -0
- package/prompts/generate-pom-from-url-cli.md +48 -0
- package/prompts/generate-pom-from-url.md +45 -0
- package/prompts/generate-spec-from-feature.md +64 -0
- package/prompts/heal-playwright-failures.md +106 -0
- package/prompts/setup-playwright.md +81 -0
- package/scripts/start-dino.cmd +8 -0
- package/scripts/start-dino.mjs +226 -0
- package/scripts/start-dino.ps1 +9 -0
- package/scripts/start-dino.sh +10 -0
- package/skills/acli/SKILL.md +292 -0
- package/skills/bdd-gherkin/SKILL.md +552 -0
- package/skills/build-review-interface/SKILL.md +96 -0
- package/skills/error-analysis/SKILL.md +164 -0
- package/skills/eval-audit/SKILL.md +183 -0
- package/skills/evaluate-rag/SKILL.md +177 -0
- package/skills/generate-synthetic-data/SKILL.md +131 -0
- package/skills/mabl-mcp/SKILL.md +79 -0
- package/skills/playwright-cli/SKILL.md +246 -0
- package/skills/playwright-cli/advanced-workflows.md +679 -0
- package/skills/playwright-cli/core-commands.md +375 -0
- package/skills/playwright-cli/device-emulation.md +475 -0
- package/skills/playwright-cli/request-mocking.md +373 -0
- package/skills/playwright-cli/running-custom-code.md +553 -0
- package/skills/playwright-cli/screenshots-and-media.md +457 -0
- package/skills/playwright-cli/session-management.md +425 -0
- package/skills/playwright-cli/storage-and-auth.md +487 -0
- package/skills/playwright-cli/test-generation.md +336 -0
- package/skills/playwright-cli/tracing-and-debugging.md +501 -0
- package/skills/playwright-core/SKILL.md +101 -0
- package/skills/playwright-core/accessibility.md +1517 -0
- package/skills/playwright-core/angular.md +1086 -0
- package/skills/playwright-core/api-testing.md +1623 -0
- package/skills/playwright-core/assertions-and-waiting.md +703 -0
- package/skills/playwright-core/auth-flows.md +1081 -0
- package/skills/playwright-core/authentication.md +1409 -0
- package/skills/playwright-core/browser-apis.md +673 -0
- package/skills/playwright-core/browser-extensions.md +319 -0
- package/skills/playwright-core/canvas-and-webgl.md +494 -0
- package/skills/playwright-core/clock-and-time-mocking.md +427 -0
- package/skills/playwright-core/common-pitfalls.md +1318 -0
- package/skills/playwright-core/component-testing.md +1179 -0
- package/skills/playwright-core/configuration.md +731 -0
- package/skills/playwright-core/crud-testing.md +945 -0
- package/skills/playwright-core/debugging.md +827 -0
- package/skills/playwright-core/drag-and-drop.md +992 -0
- package/skills/playwright-core/electron-testing.md +622 -0
- package/skills/playwright-core/error-and-edge-cases.md +1181 -0
- package/skills/playwright-core/error-index.md +1903 -0
- package/skills/playwright-core/file-operations.md +749 -0
- package/skills/playwright-core/file-upload-download.md +1012 -0
- package/skills/playwright-core/fixtures-and-hooks.md +1072 -0
- package/skills/playwright-core/flaky-tests.md +883 -0
- package/skills/playwright-core/forms-and-validation.md +1056 -0
- package/skills/playwright-core/i18n-and-localization.md +622 -0
- package/skills/playwright-core/iframes-and-shadow-dom.md +488 -0
- package/skills/playwright-core/locator-strategy.md +588 -0
- package/skills/playwright-core/locators.md +743 -0
- package/skills/playwright-core/mobile-and-responsive.md +1669 -0
- package/skills/playwright-core/multi-context-and-popups.md +581 -0
- package/skills/playwright-core/multi-user-and-collaboration.md +477 -0
- package/skills/playwright-core/network-mocking.md +1395 -0
- package/skills/playwright-core/nextjs.md +1013 -0
- package/skills/playwright-core/performance-testing.md +608 -0
- package/skills/playwright-core/react.md +1082 -0
- package/skills/playwright-core/search-and-filter.md +1366 -0
- package/skills/playwright-core/security-testing.md +624 -0
- package/skills/playwright-core/service-workers-and-pwa.md +524 -0
- package/skills/playwright-core/test-architecture.md +569 -0
- package/skills/playwright-core/test-data-management.md +1209 -0
- package/skills/playwright-core/test-organization.md +947 -0
- package/skills/playwright-core/third-party-integrations.md +754 -0
- package/skills/playwright-core/trace-analysis.md +161 -0
- package/skills/playwright-core/visual-regression.md +1027 -0
- package/skills/playwright-core/vue.md +1162 -0
- package/skills/playwright-core/websockets-and-realtime.md +623 -0
- package/skills/playwright-core/when-to-mock.md +827 -0
- package/skills/playwright-pom/SKILL.md +94 -0
- package/skills/playwright-pom/page-object-model.md +921 -0
- package/skills/playwright-pom/pom-vs-fixtures-vs-helpers.md +937 -0
- package/skills/validate-evaluator/SKILL.md +215 -0
- package/skills/write-judge-prompt/SKILL.md +144 -0
- package/themes/dino.json +66 -0
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
# Drag and Drop Recipes
|
|
2
|
+
|
|
3
|
+
> **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.
|
|
4
|
+
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
## Recipe 1: Native HTML5 Drag and Drop
|
|
8
|
+
|
|
9
|
+
### Complete Example
|
|
10
|
+
|
|
11
|
+
**TypeScript**
|
|
12
|
+
|
|
13
|
+
```typescript
|
|
14
|
+
import { test, expect } from '@playwright/test';
|
|
15
|
+
|
|
16
|
+
test('drags an item from one container to another', async ({ page }) => {
|
|
17
|
+
await page.goto('/drag-demo');
|
|
18
|
+
|
|
19
|
+
const sourceItem = page.getByText('Draggable Item');
|
|
20
|
+
const dropZone = page.locator('#drop-zone');
|
|
21
|
+
|
|
22
|
+
// Verify initial state
|
|
23
|
+
await expect(sourceItem).toBeVisible();
|
|
24
|
+
await expect(dropZone).not.toContainText('Draggable Item');
|
|
25
|
+
|
|
26
|
+
// Perform drag and drop
|
|
27
|
+
await sourceItem.dragTo(dropZone);
|
|
28
|
+
|
|
29
|
+
// Verify the item moved to the drop zone
|
|
30
|
+
await expect(dropZone).toContainText('Draggable Item');
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
test('drags between two drop zones using locators', async ({ page }) => {
|
|
34
|
+
await page.goto('/drag-demo');
|
|
35
|
+
|
|
36
|
+
const item = page.locator('[data-testid="item-1"]');
|
|
37
|
+
const zoneA = page.locator('[data-testid="zone-a"]');
|
|
38
|
+
const zoneB = page.locator('[data-testid="zone-b"]');
|
|
39
|
+
|
|
40
|
+
// Item starts in zone A
|
|
41
|
+
await expect(zoneA).toContainText('Item 1');
|
|
42
|
+
|
|
43
|
+
// Drag to zone B
|
|
44
|
+
await item.dragTo(zoneB);
|
|
45
|
+
|
|
46
|
+
// Item is now in zone B, not zone A
|
|
47
|
+
await expect(zoneB).toContainText('Item 1');
|
|
48
|
+
await expect(zoneA).not.toContainText('Item 1');
|
|
49
|
+
|
|
50
|
+
// Drag back to zone A
|
|
51
|
+
await zoneB.getByText('Item 1').dragTo(zoneA);
|
|
52
|
+
|
|
53
|
+
await expect(zoneA).toContainText('Item 1');
|
|
54
|
+
await expect(zoneB).not.toContainText('Item 1');
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('verifies drag visual feedback', async ({ page }) => {
|
|
58
|
+
await page.goto('/drag-demo');
|
|
59
|
+
|
|
60
|
+
const sourceItem = page.getByText('Draggable Item');
|
|
61
|
+
const dropZone = page.locator('#drop-zone');
|
|
62
|
+
|
|
63
|
+
// Start drag manually to check intermediate states
|
|
64
|
+
await sourceItem.hover();
|
|
65
|
+
await page.mouse.down();
|
|
66
|
+
|
|
67
|
+
// Move toward the drop zone
|
|
68
|
+
const dropBox = await dropZone.boundingBox();
|
|
69
|
+
await page.mouse.move(dropBox!.x + dropBox!.width / 2, dropBox!.y + dropBox!.height / 2);
|
|
70
|
+
|
|
71
|
+
// Verify the drop zone shows a visual indicator while hovering
|
|
72
|
+
await expect(dropZone).toHaveClass(/drag-over|highlight/);
|
|
73
|
+
|
|
74
|
+
// Complete the drop
|
|
75
|
+
await page.mouse.up();
|
|
76
|
+
|
|
77
|
+
// Verify drop zone returns to normal styling
|
|
78
|
+
await expect(dropZone).not.toHaveClass(/drag-over|highlight/);
|
|
79
|
+
await expect(dropZone).toContainText('Draggable Item');
|
|
80
|
+
});
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**JavaScript**
|
|
84
|
+
|
|
85
|
+
```javascript
|
|
86
|
+
const { test, expect } = require('@playwright/test');
|
|
87
|
+
|
|
88
|
+
test('drags an item from one container to another', async ({ page }) => {
|
|
89
|
+
await page.goto('/drag-demo');
|
|
90
|
+
|
|
91
|
+
const sourceItem = page.getByText('Draggable Item');
|
|
92
|
+
const dropZone = page.locator('#drop-zone');
|
|
93
|
+
|
|
94
|
+
await expect(sourceItem).toBeVisible();
|
|
95
|
+
await expect(dropZone).not.toContainText('Draggable Item');
|
|
96
|
+
|
|
97
|
+
await sourceItem.dragTo(dropZone);
|
|
98
|
+
|
|
99
|
+
await expect(dropZone).toContainText('Draggable Item');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('drags between two drop zones', async ({ page }) => {
|
|
103
|
+
await page.goto('/drag-demo');
|
|
104
|
+
|
|
105
|
+
const item = page.locator('[data-testid="item-1"]');
|
|
106
|
+
const zoneA = page.locator('[data-testid="zone-a"]');
|
|
107
|
+
const zoneB = page.locator('[data-testid="zone-b"]');
|
|
108
|
+
|
|
109
|
+
await expect(zoneA).toContainText('Item 1');
|
|
110
|
+
|
|
111
|
+
await item.dragTo(zoneB);
|
|
112
|
+
|
|
113
|
+
await expect(zoneB).toContainText('Item 1');
|
|
114
|
+
await expect(zoneA).not.toContainText('Item 1');
|
|
115
|
+
});
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
---
|
|
119
|
+
|
|
120
|
+
## Recipe 2: Sortable Lists (Reordering Items)
|
|
121
|
+
|
|
122
|
+
### Complete Example
|
|
123
|
+
|
|
124
|
+
**TypeScript**
|
|
125
|
+
|
|
126
|
+
```typescript
|
|
127
|
+
import { test, expect } from '@playwright/test';
|
|
128
|
+
|
|
129
|
+
test('reorders items in a sortable list', async ({ page }) => {
|
|
130
|
+
await page.goto('/tasks');
|
|
131
|
+
|
|
132
|
+
const list = page.getByRole('list', { name: 'Task list' });
|
|
133
|
+
|
|
134
|
+
// Verify initial order
|
|
135
|
+
const initialItems = await list.getByRole('listitem').allTextContents();
|
|
136
|
+
expect(initialItems[0]).toContain('Task A');
|
|
137
|
+
expect(initialItems[1]).toContain('Task B');
|
|
138
|
+
expect(initialItems[2]).toContain('Task C');
|
|
139
|
+
|
|
140
|
+
// Drag Task C to the top (above Task A)
|
|
141
|
+
const taskC = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
|
142
|
+
const taskA = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
|
143
|
+
|
|
144
|
+
await taskC.dragTo(taskA);
|
|
145
|
+
|
|
146
|
+
// Verify new order
|
|
147
|
+
const reorderedItems = await list.getByRole('listitem').allTextContents();
|
|
148
|
+
expect(reorderedItems[0]).toContain('Task C');
|
|
149
|
+
expect(reorderedItems[1]).toContain('Task A');
|
|
150
|
+
expect(reorderedItems[2]).toContain('Task B');
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test('reorders using drag handle', async ({ page }) => {
|
|
154
|
+
await page.goto('/tasks');
|
|
155
|
+
|
|
156
|
+
const list = page.getByRole('list', { name: 'Task list' });
|
|
157
|
+
|
|
158
|
+
// Use the drag handle (grip icon) instead of the whole item
|
|
159
|
+
const dragHandle = list
|
|
160
|
+
.getByRole('listitem')
|
|
161
|
+
.filter({ hasText: 'Task C' })
|
|
162
|
+
.getByRole('button', { name: /drag|reorder|grip/i });
|
|
163
|
+
|
|
164
|
+
const targetItem = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
|
165
|
+
|
|
166
|
+
await dragHandle.dragTo(targetItem);
|
|
167
|
+
|
|
168
|
+
const reorderedItems = await list.getByRole('listitem').allTextContents();
|
|
169
|
+
expect(reorderedItems[0]).toContain('Task C');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('reorder persists after page reload', async ({ page }) => {
|
|
173
|
+
await page.goto('/tasks');
|
|
174
|
+
|
|
175
|
+
const list = page.getByRole('list', { name: 'Task list' });
|
|
176
|
+
|
|
177
|
+
const taskC = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
|
178
|
+
const taskA = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
|
179
|
+
|
|
180
|
+
await taskC.dragTo(taskA);
|
|
181
|
+
|
|
182
|
+
// Wait for the save API call to complete
|
|
183
|
+
await page.waitForResponse((response) =>
|
|
184
|
+
response.url().includes('/api/tasks/reorder') && response.status() === 200
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
// Reload and verify persistence
|
|
188
|
+
await page.reload();
|
|
189
|
+
|
|
190
|
+
const items = await list.getByRole('listitem').allTextContents();
|
|
191
|
+
expect(items[0]).toContain('Task C');
|
|
192
|
+
expect(items[1]).toContain('Task A');
|
|
193
|
+
expect(items[2]).toContain('Task B');
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('reorders with precise mouse movements for libraries that need it', async ({ page }) => {
|
|
197
|
+
await page.goto('/tasks');
|
|
198
|
+
|
|
199
|
+
const list = page.getByRole('list', { name: 'Task list' });
|
|
200
|
+
const sourceItem = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
|
201
|
+
const targetItem = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
|
202
|
+
|
|
203
|
+
const sourceBox = await sourceItem.boundingBox();
|
|
204
|
+
const targetBox = await targetItem.boundingBox();
|
|
205
|
+
|
|
206
|
+
// Some drag libraries (react-beautiful-dnd, dnd-kit) require specific mouse event sequences
|
|
207
|
+
await sourceItem.hover();
|
|
208
|
+
await page.mouse.down();
|
|
209
|
+
|
|
210
|
+
// Move in small steps -- some libraries require incremental movement to register
|
|
211
|
+
const steps = 10;
|
|
212
|
+
for (let i = 1; i <= steps; i++) {
|
|
213
|
+
await page.mouse.move(
|
|
214
|
+
sourceBox!.x + sourceBox!.width / 2,
|
|
215
|
+
sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / steps),
|
|
216
|
+
{ steps: 1 }
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
await page.mouse.up();
|
|
221
|
+
|
|
222
|
+
const items = await list.getByRole('listitem').allTextContents();
|
|
223
|
+
expect(items[0]).toContain('Task C');
|
|
224
|
+
});
|
|
225
|
+
```
|
|
226
|
+
|
|
227
|
+
**JavaScript**
|
|
228
|
+
|
|
229
|
+
```javascript
|
|
230
|
+
const { test, expect } = require('@playwright/test');
|
|
231
|
+
|
|
232
|
+
test('reorders items in a sortable list', async ({ page }) => {
|
|
233
|
+
await page.goto('/tasks');
|
|
234
|
+
|
|
235
|
+
const list = page.getByRole('list', { name: 'Task list' });
|
|
236
|
+
|
|
237
|
+
const initialItems = await list.getByRole('listitem').allTextContents();
|
|
238
|
+
expect(initialItems[0]).toContain('Task A');
|
|
239
|
+
expect(initialItems[1]).toContain('Task B');
|
|
240
|
+
expect(initialItems[2]).toContain('Task C');
|
|
241
|
+
|
|
242
|
+
const taskC = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
|
243
|
+
const taskA = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
|
244
|
+
|
|
245
|
+
await taskC.dragTo(taskA);
|
|
246
|
+
|
|
247
|
+
const reorderedItems = await list.getByRole('listitem').allTextContents();
|
|
248
|
+
expect(reorderedItems[0]).toContain('Task C');
|
|
249
|
+
expect(reorderedItems[1]).toContain('Task A');
|
|
250
|
+
expect(reorderedItems[2]).toContain('Task B');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('reorders with precise mouse movements', async ({ page }) => {
|
|
254
|
+
await page.goto('/tasks');
|
|
255
|
+
|
|
256
|
+
const list = page.getByRole('list', { name: 'Task list' });
|
|
257
|
+
const sourceItem = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
|
258
|
+
const targetItem = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
|
259
|
+
|
|
260
|
+
const sourceBox = await sourceItem.boundingBox();
|
|
261
|
+
const targetBox = await targetItem.boundingBox();
|
|
262
|
+
|
|
263
|
+
await sourceItem.hover();
|
|
264
|
+
await page.mouse.down();
|
|
265
|
+
|
|
266
|
+
const steps = 10;
|
|
267
|
+
for (let i = 1; i <= steps; i++) {
|
|
268
|
+
await page.mouse.move(
|
|
269
|
+
sourceBox.x + sourceBox.width / 2,
|
|
270
|
+
sourceBox.y + (targetBox.y - sourceBox.y) * (i / steps),
|
|
271
|
+
{ steps: 1 }
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
await page.mouse.up();
|
|
276
|
+
|
|
277
|
+
const items = await list.getByRole('listitem').allTextContents();
|
|
278
|
+
expect(items[0]).toContain('Task C');
|
|
279
|
+
});
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
---
|
|
283
|
+
|
|
284
|
+
## Recipe 3: Kanban Board (Moving Between Columns)
|
|
285
|
+
|
|
286
|
+
### Complete Example
|
|
287
|
+
|
|
288
|
+
**TypeScript**
|
|
289
|
+
|
|
290
|
+
```typescript
|
|
291
|
+
import { test, expect } from '@playwright/test';
|
|
292
|
+
|
|
293
|
+
test('moves a card from Todo to In Progress', async ({ page }) => {
|
|
294
|
+
await page.goto('/board');
|
|
295
|
+
|
|
296
|
+
const todoColumn = page.locator('[data-column="todo"]');
|
|
297
|
+
const inProgressColumn = page.locator('[data-column="in-progress"]');
|
|
298
|
+
|
|
299
|
+
// Verify initial state
|
|
300
|
+
const card = todoColumn.getByText('Fix login bug');
|
|
301
|
+
await expect(card).toBeVisible();
|
|
302
|
+
|
|
303
|
+
const todoCountBefore = await todoColumn.getByRole('article').count();
|
|
304
|
+
const inProgressCountBefore = await inProgressColumn.getByRole('article').count();
|
|
305
|
+
|
|
306
|
+
// Drag the card to In Progress
|
|
307
|
+
await card.dragTo(inProgressColumn);
|
|
308
|
+
|
|
309
|
+
// Verify the card moved
|
|
310
|
+
await expect(inProgressColumn.getByText('Fix login bug')).toBeVisible();
|
|
311
|
+
await expect(todoColumn.getByText('Fix login bug')).not.toBeVisible();
|
|
312
|
+
|
|
313
|
+
// Verify counts updated
|
|
314
|
+
await expect(todoColumn.getByRole('article')).toHaveCount(todoCountBefore - 1);
|
|
315
|
+
await expect(inProgressColumn.getByRole('article')).toHaveCount(inProgressCountBefore + 1);
|
|
316
|
+
|
|
317
|
+
// Verify column header count badge updated
|
|
318
|
+
await expect(todoColumn.getByText(`(${todoCountBefore - 1})`)).toBeVisible();
|
|
319
|
+
await expect(inProgressColumn.getByText(`(${inProgressCountBefore + 1})`)).toBeVisible();
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
test('moves a card through all stages', async ({ page }) => {
|
|
323
|
+
await page.goto('/board');
|
|
324
|
+
|
|
325
|
+
const columns = {
|
|
326
|
+
todo: page.locator('[data-column="todo"]'),
|
|
327
|
+
inProgress: page.locator('[data-column="in-progress"]'),
|
|
328
|
+
review: page.locator('[data-column="review"]'),
|
|
329
|
+
done: page.locator('[data-column="done"]'),
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
// Todo -> In Progress
|
|
333
|
+
await columns.todo.getByText('Fix login bug').dragTo(columns.inProgress);
|
|
334
|
+
await expect(columns.inProgress.getByText('Fix login bug')).toBeVisible();
|
|
335
|
+
|
|
336
|
+
// In Progress -> Review
|
|
337
|
+
await columns.inProgress.getByText('Fix login bug').dragTo(columns.review);
|
|
338
|
+
await expect(columns.review.getByText('Fix login bug')).toBeVisible();
|
|
339
|
+
|
|
340
|
+
// Review -> Done
|
|
341
|
+
await columns.review.getByText('Fix login bug').dragTo(columns.done);
|
|
342
|
+
await expect(columns.done.getByText('Fix login bug')).toBeVisible();
|
|
343
|
+
|
|
344
|
+
// Verify the card is only in the Done column
|
|
345
|
+
await expect(columns.todo.getByText('Fix login bug')).not.toBeVisible();
|
|
346
|
+
await expect(columns.inProgress.getByText('Fix login bug')).not.toBeVisible();
|
|
347
|
+
await expect(columns.review.getByText('Fix login bug')).not.toBeVisible();
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('reorders cards within the same column', async ({ page }) => {
|
|
351
|
+
await page.goto('/board');
|
|
352
|
+
|
|
353
|
+
const todoColumn = page.locator('[data-column="todo"]');
|
|
354
|
+
|
|
355
|
+
const cardA = todoColumn.getByRole('article').filter({ hasText: 'Task A' });
|
|
356
|
+
const cardC = todoColumn.getByRole('article').filter({ hasText: 'Task C' });
|
|
357
|
+
|
|
358
|
+
// Drag Task C above Task A within the same column
|
|
359
|
+
await cardC.dragTo(cardA);
|
|
360
|
+
|
|
361
|
+
// Verify new order within the column
|
|
362
|
+
const cards = await todoColumn.getByRole('article').allTextContents();
|
|
363
|
+
expect(cards.indexOf('Task C')).toBeLessThan(cards.indexOf('Task A'));
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('kanban board state persists via API', async ({ page }) => {
|
|
367
|
+
await page.goto('/board');
|
|
368
|
+
|
|
369
|
+
const todoColumn = page.locator('[data-column="todo"]');
|
|
370
|
+
const inProgressColumn = page.locator('[data-column="in-progress"]');
|
|
371
|
+
|
|
372
|
+
// Wait for the PATCH/PUT to complete after the drag
|
|
373
|
+
const responsePromise = page.waitForResponse(
|
|
374
|
+
(r) => r.url().includes('/api/cards') && r.request().method() === 'PATCH'
|
|
375
|
+
);
|
|
376
|
+
|
|
377
|
+
await todoColumn.getByText('Fix login bug').dragTo(inProgressColumn);
|
|
378
|
+
|
|
379
|
+
const response = await responsePromise;
|
|
380
|
+
expect(response.status()).toBe(200);
|
|
381
|
+
|
|
382
|
+
const body = await response.json();
|
|
383
|
+
expect(body.column).toBe('in-progress');
|
|
384
|
+
|
|
385
|
+
// Reload to confirm persistence
|
|
386
|
+
await page.reload();
|
|
387
|
+
await expect(inProgressColumn.getByText('Fix login bug')).toBeVisible();
|
|
388
|
+
});
|
|
389
|
+
```
|
|
390
|
+
|
|
391
|
+
**JavaScript**
|
|
392
|
+
|
|
393
|
+
```javascript
|
|
394
|
+
const { test, expect } = require('@playwright/test');
|
|
395
|
+
|
|
396
|
+
test('moves a card from Todo to In Progress', async ({ page }) => {
|
|
397
|
+
await page.goto('/board');
|
|
398
|
+
|
|
399
|
+
const todoColumn = page.locator('[data-column="todo"]');
|
|
400
|
+
const inProgressColumn = page.locator('[data-column="in-progress"]');
|
|
401
|
+
|
|
402
|
+
const card = todoColumn.getByText('Fix login bug');
|
|
403
|
+
await expect(card).toBeVisible();
|
|
404
|
+
|
|
405
|
+
await card.dragTo(inProgressColumn);
|
|
406
|
+
|
|
407
|
+
await expect(inProgressColumn.getByText('Fix login bug')).toBeVisible();
|
|
408
|
+
await expect(todoColumn.getByText('Fix login bug')).not.toBeVisible();
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test('moves a card through all stages', async ({ page }) => {
|
|
412
|
+
await page.goto('/board');
|
|
413
|
+
|
|
414
|
+
const columns = {
|
|
415
|
+
todo: page.locator('[data-column="todo"]'),
|
|
416
|
+
inProgress: page.locator('[data-column="in-progress"]'),
|
|
417
|
+
review: page.locator('[data-column="review"]'),
|
|
418
|
+
done: page.locator('[data-column="done"]'),
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
await columns.todo.getByText('Fix login bug').dragTo(columns.inProgress);
|
|
422
|
+
await expect(columns.inProgress.getByText('Fix login bug')).toBeVisible();
|
|
423
|
+
|
|
424
|
+
await columns.inProgress.getByText('Fix login bug').dragTo(columns.review);
|
|
425
|
+
await expect(columns.review.getByText('Fix login bug')).toBeVisible();
|
|
426
|
+
|
|
427
|
+
await columns.review.getByText('Fix login bug').dragTo(columns.done);
|
|
428
|
+
await expect(columns.done.getByText('Fix login bug')).toBeVisible();
|
|
429
|
+
});
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
---
|
|
433
|
+
|
|
434
|
+
## Recipe 4: File Drop Zone
|
|
435
|
+
|
|
436
|
+
> **Playwright 1.60+**: `locator.drop()` now simulates a *native* external file drop — dispatching real `dragenter` / `dragover` / `drop` events with a synthetic `DataTransfer`. This is the preferred approach for drop zones that listen for `drop` events (not just a hidden `input[type=file]`). On earlier versions, fall back to `setInputFiles` on the underlying file input (shown below).
|
|
437
|
+
|
|
438
|
+
### Recommended: `locator.drop()` (Playwright 1.60+)
|
|
439
|
+
|
|
440
|
+
**Use when**: The drop zone is driven by `drop`/`dragover` DOM events (most modern uploaders, editors that accept dropped images, or zones with no `input[type=file]` fallback).
|
|
441
|
+
**Avoid when**: You're on Playwright < 1.60 — use the `setInputFiles` approach instead.
|
|
442
|
+
|
|
443
|
+
**TypeScript**
|
|
444
|
+
|
|
445
|
+
```typescript
|
|
446
|
+
import { test, expect } from '@playwright/test';
|
|
447
|
+
import path from 'path';
|
|
448
|
+
|
|
449
|
+
test('drops a file directly onto the drop zone', async ({ page }) => {
|
|
450
|
+
await page.goto('/upload');
|
|
451
|
+
|
|
452
|
+
const dropZone = page.locator('[data-testid="file-drop-zone"]');
|
|
453
|
+
await expect(dropZone).toContainText('Drag files here');
|
|
454
|
+
|
|
455
|
+
// Drop a real file from disk — fires native dragenter/dragover/drop events
|
|
456
|
+
await dropZone.drop({
|
|
457
|
+
files: path.resolve(__dirname, '../fixtures/sample-document.pdf'),
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
await expect(page.getByText('sample-document.pdf')).toBeVisible();
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
test('drops an in-memory file buffer', async ({ page }) => {
|
|
464
|
+
await page.goto('/upload');
|
|
465
|
+
|
|
466
|
+
await page.locator('[data-testid="file-drop-zone"]').drop({
|
|
467
|
+
files: {
|
|
468
|
+
name: 'note.txt',
|
|
469
|
+
mimeType: 'text/plain',
|
|
470
|
+
buffer: Buffer.from('hello from a dropped file'),
|
|
471
|
+
},
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
await expect(page.getByText('note.txt')).toBeVisible();
|
|
475
|
+
});
|
|
476
|
+
|
|
477
|
+
test('drops text/url payloads (not a file)', async ({ page }) => {
|
|
478
|
+
await page.goto('/editor');
|
|
479
|
+
|
|
480
|
+
// `data` maps MIME types to string values — great for dropped links/text
|
|
481
|
+
await page.locator('#canvas').drop({
|
|
482
|
+
data: {
|
|
483
|
+
'text/plain': 'Hello world',
|
|
484
|
+
'text/uri-list': 'https://example.com/image.png',
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
await expect(page.getByText('https://example.com/image.png')).toBeVisible();
|
|
489
|
+
});
|
|
490
|
+
```
|
|
491
|
+
|
|
492
|
+
**JavaScript**
|
|
493
|
+
|
|
494
|
+
```javascript
|
|
495
|
+
const { test, expect } = require('@playwright/test');
|
|
496
|
+
const path = require('path');
|
|
497
|
+
|
|
498
|
+
test('drops a file directly onto the drop zone', async ({ page }) => {
|
|
499
|
+
await page.goto('/upload');
|
|
500
|
+
|
|
501
|
+
const dropZone = page.locator('[data-testid="file-drop-zone"]');
|
|
502
|
+
await dropZone.drop({
|
|
503
|
+
files: path.resolve(__dirname, '../fixtures/sample-document.pdf'),
|
|
504
|
+
});
|
|
505
|
+
|
|
506
|
+
await expect(page.getByText('sample-document.pdf')).toBeVisible();
|
|
507
|
+
});
|
|
508
|
+
```
|
|
509
|
+
|
|
510
|
+
### Legacy / fallback: file input + dispatched events
|
|
511
|
+
|
|
512
|
+
**TypeScript**
|
|
513
|
+
|
|
514
|
+
```typescript
|
|
515
|
+
import { test, expect } from '@playwright/test';
|
|
516
|
+
import path from 'path';
|
|
517
|
+
|
|
518
|
+
test('uploads a file via the underlying input (Playwright < 1.60)', async ({ page }) => {
|
|
519
|
+
await page.goto('/upload');
|
|
520
|
+
|
|
521
|
+
const dropZone = page.locator('[data-testid="file-drop-zone"]');
|
|
522
|
+
|
|
523
|
+
// Verify initial state
|
|
524
|
+
await expect(dropZone).toContainText('Drag files here');
|
|
525
|
+
|
|
526
|
+
// On older Playwright, native OS file drops can't be simulated — drive the
|
|
527
|
+
// underlying input[type=file] that most drop zones fall back to instead.
|
|
528
|
+
const fileInput = page.locator('input[type="file"]');
|
|
529
|
+
|
|
530
|
+
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/sample-document.pdf'));
|
|
531
|
+
|
|
532
|
+
// Verify file appears in the upload list
|
|
533
|
+
await expect(page.getByText('sample-document.pdf')).toBeVisible();
|
|
534
|
+
await expect(page.getByText(/\d+ KB/)).toBeVisible();
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
test('simulates drag-over visual feedback via JavaScript dispatch', async ({ page }) => {
|
|
538
|
+
await page.goto('/upload');
|
|
539
|
+
|
|
540
|
+
const dropZone = page.locator('[data-testid="file-drop-zone"]');
|
|
541
|
+
|
|
542
|
+
// Dispatch dragenter/dragover events to test visual feedback
|
|
543
|
+
await dropZone.dispatchEvent('dragenter', {
|
|
544
|
+
dataTransfer: { types: ['Files'] },
|
|
545
|
+
});
|
|
546
|
+
|
|
547
|
+
// Verify the drop zone shows the active/hover state
|
|
548
|
+
await expect(dropZone).toHaveClass(/drag-active|drop-highlight/);
|
|
549
|
+
await expect(dropZone).toContainText(/drop.*here|release.*upload/i);
|
|
550
|
+
|
|
551
|
+
// Dispatch dragleave to reset
|
|
552
|
+
await dropZone.dispatchEvent('dragleave');
|
|
553
|
+
|
|
554
|
+
await expect(dropZone).not.toHaveClass(/drag-active|drop-highlight/);
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
test('rejects invalid file types in drop zone', async ({ page }) => {
|
|
558
|
+
await page.goto('/upload');
|
|
559
|
+
|
|
560
|
+
const fileInput = page.locator('input[type="file"]');
|
|
561
|
+
|
|
562
|
+
// Try to upload a file type that is not allowed
|
|
563
|
+
await fileInput.setInputFiles({
|
|
564
|
+
name: 'malware.exe',
|
|
565
|
+
mimeType: 'application/x-msdownload',
|
|
566
|
+
buffer: Buffer.from('fake-content'),
|
|
567
|
+
});
|
|
568
|
+
|
|
569
|
+
await expect(page.getByRole('alert')).toContainText(/not allowed|invalid file type/i);
|
|
570
|
+
await expect(page.getByText('malware.exe')).not.toBeVisible();
|
|
571
|
+
});
|
|
572
|
+
```
|
|
573
|
+
|
|
574
|
+
**JavaScript**
|
|
575
|
+
|
|
576
|
+
```javascript
|
|
577
|
+
const { test, expect } = require('@playwright/test');
|
|
578
|
+
const path = require('path');
|
|
579
|
+
|
|
580
|
+
test('uploads a file by dropping it on the drop zone', async ({ page }) => {
|
|
581
|
+
await page.goto('/upload');
|
|
582
|
+
|
|
583
|
+
const dropZone = page.locator('[data-testid="file-drop-zone"]');
|
|
584
|
+
await expect(dropZone).toContainText('Drag files here');
|
|
585
|
+
|
|
586
|
+
const fileInput = page.locator('input[type="file"]');
|
|
587
|
+
await fileInput.setInputFiles(path.resolve(__dirname, '../fixtures/sample-document.pdf'));
|
|
588
|
+
|
|
589
|
+
await expect(page.getByText('sample-document.pdf')).toBeVisible();
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
test('rejects invalid file types in drop zone', async ({ page }) => {
|
|
593
|
+
await page.goto('/upload');
|
|
594
|
+
|
|
595
|
+
const fileInput = page.locator('input[type="file"]');
|
|
596
|
+
|
|
597
|
+
await fileInput.setInputFiles({
|
|
598
|
+
name: 'malware.exe',
|
|
599
|
+
mimeType: 'application/x-msdownload',
|
|
600
|
+
buffer: Buffer.from('fake-content'),
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
await expect(page.getByRole('alert')).toContainText(/not allowed|invalid file type/i);
|
|
604
|
+
});
|
|
605
|
+
```
|
|
606
|
+
|
|
607
|
+
---
|
|
608
|
+
|
|
609
|
+
## Recipe 5: Drag with Custom Preview / Ghost Image
|
|
610
|
+
|
|
611
|
+
### Complete Example
|
|
612
|
+
|
|
613
|
+
**TypeScript**
|
|
614
|
+
|
|
615
|
+
```typescript
|
|
616
|
+
import { test, expect } from '@playwright/test';
|
|
617
|
+
|
|
618
|
+
test('shows custom drag preview during drag operation', async ({ page }) => {
|
|
619
|
+
await page.goto('/board');
|
|
620
|
+
|
|
621
|
+
const card = page.locator('[data-testid="card-1"]');
|
|
622
|
+
const targetColumn = page.locator('[data-column="in-progress"]');
|
|
623
|
+
|
|
624
|
+
const cardBox = await card.boundingBox();
|
|
625
|
+
const targetBox = await targetColumn.boundingBox();
|
|
626
|
+
|
|
627
|
+
// Start dragging
|
|
628
|
+
await card.hover();
|
|
629
|
+
await page.mouse.down();
|
|
630
|
+
|
|
631
|
+
// Move partially toward the target
|
|
632
|
+
const midX = (cardBox!.x + targetBox!.x) / 2;
|
|
633
|
+
const midY = (cardBox!.y + targetBox!.y) / 2;
|
|
634
|
+
await page.mouse.move(midX, midY, { steps: 5 });
|
|
635
|
+
|
|
636
|
+
// Take a screenshot to visually verify the drag preview
|
|
637
|
+
// The custom preview element should be visible during drag
|
|
638
|
+
await expect(page.locator('.drag-preview')).toBeVisible();
|
|
639
|
+
|
|
640
|
+
// Verify the original card shows a placeholder/ghost
|
|
641
|
+
await expect(card).toHaveClass(/dragging|placeholder/);
|
|
642
|
+
|
|
643
|
+
// Complete the drag
|
|
644
|
+
await page.mouse.move(
|
|
645
|
+
targetBox!.x + targetBox!.width / 2,
|
|
646
|
+
targetBox!.y + targetBox!.height / 2,
|
|
647
|
+
{ steps: 5 }
|
|
648
|
+
);
|
|
649
|
+
await page.mouse.up();
|
|
650
|
+
|
|
651
|
+
// Preview should disappear after drop
|
|
652
|
+
await expect(page.locator('.drag-preview')).not.toBeVisible();
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test('drag preview shows item count for multi-select drag', async ({ page }) => {
|
|
656
|
+
await page.goto('/board');
|
|
657
|
+
|
|
658
|
+
// Select multiple cards
|
|
659
|
+
await page.locator('[data-testid="card-1"]').click();
|
|
660
|
+
await page.locator('[data-testid="card-2"]').click({ modifiers: ['Shift'] });
|
|
661
|
+
await page.locator('[data-testid="card-3"]').click({ modifiers: ['Shift'] });
|
|
662
|
+
|
|
663
|
+
// Start dragging one of the selected cards
|
|
664
|
+
const card = page.locator('[data-testid="card-1"]');
|
|
665
|
+
const targetColumn = page.locator('[data-column="done"]');
|
|
666
|
+
|
|
667
|
+
await card.hover();
|
|
668
|
+
await page.mouse.down();
|
|
669
|
+
|
|
670
|
+
const targetBox = await targetColumn.boundingBox();
|
|
671
|
+
await page.mouse.move(targetBox!.x + 50, targetBox!.y + 50, { steps: 5 });
|
|
672
|
+
|
|
673
|
+
// Verify the preview shows the count of selected items
|
|
674
|
+
await expect(page.locator('.drag-preview')).toContainText('3 items');
|
|
675
|
+
|
|
676
|
+
await page.mouse.up();
|
|
677
|
+
|
|
678
|
+
// All three cards should be in the target column
|
|
679
|
+
await expect(targetColumn.locator('[data-testid="card-1"]')).toBeVisible();
|
|
680
|
+
await expect(targetColumn.locator('[data-testid="card-2"]')).toBeVisible();
|
|
681
|
+
await expect(targetColumn.locator('[data-testid="card-3"]')).toBeVisible();
|
|
682
|
+
});
|
|
683
|
+
```
|
|
684
|
+
|
|
685
|
+
**JavaScript**
|
|
686
|
+
|
|
687
|
+
```javascript
|
|
688
|
+
const { test, expect } = require('@playwright/test');
|
|
689
|
+
|
|
690
|
+
test('shows custom drag preview during drag operation', async ({ page }) => {
|
|
691
|
+
await page.goto('/board');
|
|
692
|
+
|
|
693
|
+
const card = page.locator('[data-testid="card-1"]');
|
|
694
|
+
const targetColumn = page.locator('[data-column="in-progress"]');
|
|
695
|
+
|
|
696
|
+
const cardBox = await card.boundingBox();
|
|
697
|
+
const targetBox = await targetColumn.boundingBox();
|
|
698
|
+
|
|
699
|
+
await card.hover();
|
|
700
|
+
await page.mouse.down();
|
|
701
|
+
|
|
702
|
+
const midX = (cardBox.x + targetBox.x) / 2;
|
|
703
|
+
const midY = (cardBox.y + targetBox.y) / 2;
|
|
704
|
+
await page.mouse.move(midX, midY, { steps: 5 });
|
|
705
|
+
|
|
706
|
+
await expect(page.locator('.drag-preview')).toBeVisible();
|
|
707
|
+
await expect(card).toHaveClass(/dragging|placeholder/);
|
|
708
|
+
|
|
709
|
+
await page.mouse.move(
|
|
710
|
+
targetBox.x + targetBox.width / 2,
|
|
711
|
+
targetBox.y + targetBox.height / 2,
|
|
712
|
+
{ steps: 5 }
|
|
713
|
+
);
|
|
714
|
+
await page.mouse.up();
|
|
715
|
+
|
|
716
|
+
await expect(page.locator('.drag-preview')).not.toBeVisible();
|
|
717
|
+
});
|
|
718
|
+
```
|
|
719
|
+
|
|
720
|
+
---
|
|
721
|
+
|
|
722
|
+
## Recipe 6: Testing Drag Position and Coordinates
|
|
723
|
+
|
|
724
|
+
### Complete Example
|
|
725
|
+
|
|
726
|
+
**TypeScript**
|
|
727
|
+
|
|
728
|
+
```typescript
|
|
729
|
+
import { test, expect } from '@playwright/test';
|
|
730
|
+
|
|
731
|
+
test('drags an element to a specific coordinate on a canvas', async ({ page }) => {
|
|
732
|
+
await page.goto('/canvas-editor');
|
|
733
|
+
|
|
734
|
+
const canvas = page.locator('#design-canvas');
|
|
735
|
+
const element = page.locator('[data-testid="shape-1"]');
|
|
736
|
+
|
|
737
|
+
// Get the initial position
|
|
738
|
+
const initialBox = await element.boundingBox();
|
|
739
|
+
expect(initialBox).toBeTruthy();
|
|
740
|
+
|
|
741
|
+
// Define target position (absolute coordinates within the canvas)
|
|
742
|
+
const canvasBox = await canvas.boundingBox();
|
|
743
|
+
const targetX = canvasBox!.x + 300;
|
|
744
|
+
const targetY = canvasBox!.y + 200;
|
|
745
|
+
|
|
746
|
+
// Drag to specific coordinates
|
|
747
|
+
await element.hover();
|
|
748
|
+
await page.mouse.down();
|
|
749
|
+
await page.mouse.move(targetX, targetY, { steps: 10 });
|
|
750
|
+
await page.mouse.up();
|
|
751
|
+
|
|
752
|
+
// Verify element moved to approximately the right position
|
|
753
|
+
const newBox = await element.boundingBox();
|
|
754
|
+
expect(newBox!.x).toBeCloseTo(targetX - newBox!.width / 2, -1);
|
|
755
|
+
expect(newBox!.y).toBeCloseTo(targetY - newBox!.height / 2, -1);
|
|
756
|
+
});
|
|
757
|
+
|
|
758
|
+
test('snaps element to grid when dropped', async ({ page }) => {
|
|
759
|
+
await page.goto('/canvas-editor');
|
|
760
|
+
|
|
761
|
+
const element = page.locator('[data-testid="shape-1"]');
|
|
762
|
+
const canvas = page.locator('#design-canvas');
|
|
763
|
+
|
|
764
|
+
const canvasBox = await canvas.boundingBox();
|
|
765
|
+
|
|
766
|
+
// Drag to a position that is not on the grid
|
|
767
|
+
await element.hover();
|
|
768
|
+
await page.mouse.down();
|
|
769
|
+
await page.mouse.move(canvasBox!.x + 147, canvasBox!.y + 83, { steps: 10 });
|
|
770
|
+
await page.mouse.up();
|
|
771
|
+
|
|
772
|
+
// With a 20px grid, position should snap to nearest grid point
|
|
773
|
+
const snappedBox = await element.boundingBox();
|
|
774
|
+
expect(snappedBox!.x % 20).toBeCloseTo(0, 0);
|
|
775
|
+
expect(snappedBox!.y % 20).toBeCloseTo(0, 0);
|
|
776
|
+
});
|
|
777
|
+
|
|
778
|
+
test('constrains drag within boundaries', async ({ page }) => {
|
|
779
|
+
await page.goto('/canvas-editor');
|
|
780
|
+
|
|
781
|
+
const element = page.locator('[data-testid="constrained-element"]');
|
|
782
|
+
const container = page.locator('#constraint-box');
|
|
783
|
+
|
|
784
|
+
const containerBox = await container.boundingBox();
|
|
785
|
+
|
|
786
|
+
// Try to drag far outside the container
|
|
787
|
+
await element.hover();
|
|
788
|
+
await page.mouse.down();
|
|
789
|
+
await page.mouse.move(containerBox!.x + containerBox!.width + 500, containerBox!.y - 200, {
|
|
790
|
+
steps: 10,
|
|
791
|
+
});
|
|
792
|
+
await page.mouse.up();
|
|
793
|
+
|
|
794
|
+
// Element should be clamped within the container
|
|
795
|
+
const elementBox = await element.boundingBox();
|
|
796
|
+
|
|
797
|
+
expect(elementBox!.x).toBeGreaterThanOrEqual(containerBox!.x);
|
|
798
|
+
expect(elementBox!.y).toBeGreaterThanOrEqual(containerBox!.y);
|
|
799
|
+
expect(elementBox!.x + elementBox!.width).toBeLessThanOrEqual(
|
|
800
|
+
containerBox!.x + containerBox!.width
|
|
801
|
+
);
|
|
802
|
+
expect(elementBox!.y + elementBox!.height).toBeLessThanOrEqual(
|
|
803
|
+
containerBox!.y + containerBox!.height
|
|
804
|
+
);
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
test('resizes an element by dragging its handle', async ({ page }) => {
|
|
808
|
+
await page.goto('/canvas-editor');
|
|
809
|
+
|
|
810
|
+
const element = page.locator('[data-testid="shape-1"]');
|
|
811
|
+
await element.click(); // Select to show resize handles
|
|
812
|
+
|
|
813
|
+
const resizeHandle = element.locator('.resize-handle-se'); // south-east corner
|
|
814
|
+
const handleBox = await resizeHandle.boundingBox();
|
|
815
|
+
|
|
816
|
+
const initialBox = await element.boundingBox();
|
|
817
|
+
|
|
818
|
+
// Drag the resize handle to make the element larger
|
|
819
|
+
await resizeHandle.hover();
|
|
820
|
+
await page.mouse.down();
|
|
821
|
+
await page.mouse.move(handleBox!.x + 100, handleBox!.y + 80, { steps: 5 });
|
|
822
|
+
await page.mouse.up();
|
|
823
|
+
|
|
824
|
+
const newBox = await element.boundingBox();
|
|
825
|
+
expect(newBox!.width).toBeCloseTo(initialBox!.width + 100, -1);
|
|
826
|
+
expect(newBox!.height).toBeCloseTo(initialBox!.height + 80, -1);
|
|
827
|
+
});
|
|
828
|
+
```
|
|
829
|
+
|
|
830
|
+
**JavaScript**
|
|
831
|
+
|
|
832
|
+
```javascript
|
|
833
|
+
const { test, expect } = require('@playwright/test');
|
|
834
|
+
|
|
835
|
+
test('drags an element to a specific coordinate on a canvas', async ({ page }) => {
|
|
836
|
+
await page.goto('/canvas-editor');
|
|
837
|
+
|
|
838
|
+
const canvas = page.locator('#design-canvas');
|
|
839
|
+
const element = page.locator('[data-testid="shape-1"]');
|
|
840
|
+
|
|
841
|
+
const canvasBox = await canvas.boundingBox();
|
|
842
|
+
const targetX = canvasBox.x + 300;
|
|
843
|
+
const targetY = canvasBox.y + 200;
|
|
844
|
+
|
|
845
|
+
await element.hover();
|
|
846
|
+
await page.mouse.down();
|
|
847
|
+
await page.mouse.move(targetX, targetY, { steps: 10 });
|
|
848
|
+
await page.mouse.up();
|
|
849
|
+
|
|
850
|
+
const newBox = await element.boundingBox();
|
|
851
|
+
expect(newBox.x).toBeCloseTo(targetX - newBox.width / 2, -1);
|
|
852
|
+
expect(newBox.y).toBeCloseTo(targetY - newBox.height / 2, -1);
|
|
853
|
+
});
|
|
854
|
+
|
|
855
|
+
test('constrains drag within boundaries', async ({ page }) => {
|
|
856
|
+
await page.goto('/canvas-editor');
|
|
857
|
+
|
|
858
|
+
const element = page.locator('[data-testid="constrained-element"]');
|
|
859
|
+
const container = page.locator('#constraint-box');
|
|
860
|
+
|
|
861
|
+
const containerBox = await container.boundingBox();
|
|
862
|
+
|
|
863
|
+
await element.hover();
|
|
864
|
+
await page.mouse.down();
|
|
865
|
+
await page.mouse.move(containerBox.x + containerBox.width + 500, containerBox.y - 200, {
|
|
866
|
+
steps: 10,
|
|
867
|
+
});
|
|
868
|
+
await page.mouse.up();
|
|
869
|
+
|
|
870
|
+
const elementBox = await element.boundingBox();
|
|
871
|
+
expect(elementBox.x).toBeGreaterThanOrEqual(containerBox.x);
|
|
872
|
+
expect(elementBox.y).toBeGreaterThanOrEqual(containerBox.y);
|
|
873
|
+
});
|
|
874
|
+
```
|
|
875
|
+
|
|
876
|
+
---
|
|
877
|
+
|
|
878
|
+
## Variations
|
|
879
|
+
|
|
880
|
+
### Drag and Drop with Keyboard Accessibility
|
|
881
|
+
|
|
882
|
+
```typescript
|
|
883
|
+
test('reorders items using keyboard', async ({ page }) => {
|
|
884
|
+
await page.goto('/tasks');
|
|
885
|
+
|
|
886
|
+
const list = page.getByRole('list', { name: 'Task list' });
|
|
887
|
+
const taskC = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
|
888
|
+
|
|
889
|
+
// Focus the item
|
|
890
|
+
await taskC.focus();
|
|
891
|
+
|
|
892
|
+
// Use keyboard shortcut to pick up the item
|
|
893
|
+
await page.keyboard.press('Space');
|
|
894
|
+
|
|
895
|
+
// Move up twice
|
|
896
|
+
await page.keyboard.press('ArrowUp');
|
|
897
|
+
await page.keyboard.press('ArrowUp');
|
|
898
|
+
|
|
899
|
+
// Drop the item
|
|
900
|
+
await page.keyboard.press('Space');
|
|
901
|
+
|
|
902
|
+
const items = await list.getByRole('listitem').allTextContents();
|
|
903
|
+
expect(items[0]).toContain('Task C');
|
|
904
|
+
});
|
|
905
|
+
```
|
|
906
|
+
|
|
907
|
+
### Drag and Drop Between iframes
|
|
908
|
+
|
|
909
|
+
```typescript
|
|
910
|
+
test('drags between main page and iframe', async ({ page }) => {
|
|
911
|
+
await page.goto('/editor');
|
|
912
|
+
|
|
913
|
+
const sourceItem = page.getByText('Widget A');
|
|
914
|
+
const iframe = page.frameLocator('#preview-frame');
|
|
915
|
+
const dropTarget = iframe.locator('#content-area');
|
|
916
|
+
|
|
917
|
+
// Cross-frame drag requires coordinates since dragTo does not work across frames
|
|
918
|
+
const sourceBox = await sourceItem.boundingBox();
|
|
919
|
+
const iframeElement = page.locator('#preview-frame');
|
|
920
|
+
const iframeBox = await iframeElement.boundingBox();
|
|
921
|
+
|
|
922
|
+
// Calculate target position within the iframe
|
|
923
|
+
const targetX = iframeBox!.x + 100;
|
|
924
|
+
const targetY = iframeBox!.y + 100;
|
|
925
|
+
|
|
926
|
+
await sourceItem.hover();
|
|
927
|
+
await page.mouse.down();
|
|
928
|
+
await page.mouse.move(targetX, targetY, { steps: 20 });
|
|
929
|
+
await page.mouse.up();
|
|
930
|
+
|
|
931
|
+
await expect(iframe.getByText('Widget A')).toBeVisible();
|
|
932
|
+
});
|
|
933
|
+
```
|
|
934
|
+
|
|
935
|
+
### Touch-Based Drag on Mobile
|
|
936
|
+
|
|
937
|
+
```typescript
|
|
938
|
+
test('drags items on mobile via touch events', async ({ page }) => {
|
|
939
|
+
await page.goto('/tasks');
|
|
940
|
+
|
|
941
|
+
const list = page.getByRole('list', { name: 'Task list' });
|
|
942
|
+
const sourceItem = list.getByRole('listitem').filter({ hasText: 'Task C' });
|
|
943
|
+
const targetItem = list.getByRole('listitem').filter({ hasText: 'Task A' });
|
|
944
|
+
|
|
945
|
+
const sourceBox = await sourceItem.boundingBox();
|
|
946
|
+
const targetBox = await targetItem.boundingBox();
|
|
947
|
+
|
|
948
|
+
// Simulate long-press then drag using touch
|
|
949
|
+
await page.touchscreen.tap(sourceBox!.x + sourceBox!.width / 2, sourceBox!.y + sourceBox!.height / 2);
|
|
950
|
+
|
|
951
|
+
// Dispatch touchstart, touchmove, touchend for libraries that use touch events
|
|
952
|
+
await sourceItem.dispatchEvent('touchstart', {
|
|
953
|
+
touches: [{ clientX: sourceBox!.x + 10, clientY: sourceBox!.y + 10 }],
|
|
954
|
+
});
|
|
955
|
+
|
|
956
|
+
// Move in steps
|
|
957
|
+
for (let i = 1; i <= 5; i++) {
|
|
958
|
+
const y = sourceBox!.y + (targetBox!.y - sourceBox!.y) * (i / 5);
|
|
959
|
+
await sourceItem.dispatchEvent('touchmove', {
|
|
960
|
+
touches: [{ clientX: sourceBox!.x + 10, clientY: y }],
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
await sourceItem.dispatchEvent('touchend');
|
|
965
|
+
|
|
966
|
+
const items = await list.getByRole('listitem').allTextContents();
|
|
967
|
+
expect(items[0]).toContain('Task C');
|
|
968
|
+
});
|
|
969
|
+
```
|
|
970
|
+
|
|
971
|
+
---
|
|
972
|
+
|
|
973
|
+
## Tips
|
|
974
|
+
|
|
975
|
+
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.
|
|
976
|
+
|
|
977
|
+
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.
|
|
978
|
+
|
|
979
|
+
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.
|
|
980
|
+
|
|
981
|
+
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.
|
|
982
|
+
|
|
983
|
+
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.
|
|
984
|
+
|
|
985
|
+
---
|
|
986
|
+
|
|
987
|
+
## Related
|
|
988
|
+
|
|
989
|
+
- [Playwright Drag and Drop Docs](https://playwright.dev/docs/input#drag-and-drop)
|
|
990
|
+
- `recipes/file-upload-download.md` -- File drop zones specifically
|
|
991
|
+
- `foundations/actions.md` -- Mouse and keyboard interaction basics
|
|
992
|
+
- `patterns/page-objects.md` -- Encapsulate complex drag flows
|