Skip to content

Commit 1908f7c

Browse files
authored
Merge pull request #150 from KubrickCode/develop/shlee/145-3
ifix: Playwright dragTo() not triggering @dnd-kit drag and drop
2 parents de4d925 + a6273e8 commit 1908f7c

5 files changed

Lines changed: 501 additions & 0 deletions

File tree

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
import { type Locator, test } from "@playwright/test";
2+
3+
import {
4+
COMMAND_CARD_SELECTOR,
5+
DRAG_HANDLE_SELECTOR,
6+
getCapturedEvents,
7+
injectEventListeners,
8+
} from "./helpers/test-helpers";
9+
10+
// Debug test constants
11+
const TARGET_POSITION = { x: 100, y: 50 };
12+
const FINAL_WAIT_MS = 500;
13+
const DRAG_START_WAIT_MS = 100;
14+
const MOVE_STEPS = 5;
15+
const MOVE_INTERVAL_MS = 50;
16+
17+
/**
18+
* Log captured drag events to console
19+
*/
20+
const logCapturedEvents = (capturedEvents: string[], title: string) => {
21+
console.log(`\n=== ${title} ===`);
22+
capturedEvents.forEach((event: string, index: number) => {
23+
console.log(`${index + 1}. ${event}`);
24+
});
25+
console.log(`\nTotal events: ${capturedEvents.length}\n`);
26+
};
27+
28+
test.describe("Debug: Drag Events Analysis", () => {
29+
let commandCards: Locator;
30+
let count: number;
31+
32+
test.beforeEach(async ({ page }) => {
33+
await page.goto("/");
34+
await injectEventListeners(page);
35+
36+
commandCards = page.locator(COMMAND_CARD_SELECTOR);
37+
count = await commandCards.count();
38+
39+
if (count < 2) {
40+
test.skip();
41+
}
42+
});
43+
44+
test("should log all events during Playwright dragTo()", async ({ page }) => {
45+
const secondCard = commandCards.nth(1);
46+
const firstCard = commandCards.nth(0);
47+
const dragHandle = secondCard.locator(DRAG_HANDLE_SELECTOR);
48+
49+
console.log("=== Starting Playwright dragTo() ===");
50+
51+
// Perform Playwright's dragTo
52+
await dragHandle.dragTo(firstCard, {
53+
force: true,
54+
targetPosition: TARGET_POSITION,
55+
});
56+
57+
await page.waitForTimeout(FINAL_WAIT_MS);
58+
59+
// Retrieve and log captured events
60+
const capturedEvents = await getCapturedEvents(page);
61+
logCapturedEvents(capturedEvents, "Events captured during dragTo()");
62+
});
63+
64+
test("should log all events during manual mouse events", async ({ page }) => {
65+
const secondCard = commandCards.nth(1);
66+
const dragHandle = secondCard.locator(DRAG_HANDLE_SELECTOR);
67+
const firstCard = commandCards.nth(0);
68+
69+
const handleBox = await dragHandle.boundingBox();
70+
const targetBox = await firstCard.boundingBox();
71+
72+
if (!handleBox || !targetBox) {
73+
test.skip();
74+
return;
75+
}
76+
77+
console.log("=== Starting manual mouse events ===");
78+
79+
const startX = handleBox.x + handleBox.width / 2;
80+
const startY = handleBox.y + handleBox.height / 2;
81+
const endX = targetBox.x + targetBox.width / 2;
82+
const endY = targetBox.y + targetBox.height / 2;
83+
84+
// Manual drag with page.mouse
85+
await page.mouse.move(startX, startY);
86+
await page.mouse.down();
87+
await page.waitForTimeout(DRAG_START_WAIT_MS);
88+
89+
// Move in steps
90+
for (let i = 1; i <= MOVE_STEPS; i++) {
91+
const x = startX + (endX - startX) * (i / MOVE_STEPS);
92+
const y = startY + (endY - startY) * (i / MOVE_STEPS);
93+
await page.mouse.move(x, y);
94+
await page.waitForTimeout(MOVE_INTERVAL_MS);
95+
}
96+
97+
await page.mouse.up();
98+
await page.waitForTimeout(FINAL_WAIT_MS);
99+
100+
// Retrieve and log captured events
101+
const capturedEvents = await getCapturedEvents(page);
102+
logCapturedEvents(capturedEvents, "Events captured during manual mouse events");
103+
});
104+
});

src/view/e2e/helpers/test-helpers.ts

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,3 +96,244 @@ export const verifySuccessToast = async (page: Page, message: string) => {
9696
// Wait for toast to disappear
9797
await toast.waitFor({ state: "hidden", timeout: TOAST_TIMEOUT });
9898
};
99+
100+
// Test selectors (exported for use in test files)
101+
export const COMMAND_CARD_SELECTOR = '[data-testid="command-card"]';
102+
export const COMMAND_NAME_SELECTOR = '[data-testid="command-name"]';
103+
export const DRAG_HANDLE_SELECTOR = '[aria-label*="Drag handle"]';
104+
105+
// Drag and drop constants
106+
// Increased values for CI environment stability
107+
const MOUSE_MOVE_DELAY_MS = 200;
108+
const DRAG_START_DELAY_MS = 300;
109+
const ACTIVATION_MOVE_PX = 15;
110+
const DRAG_ACTIVATION_DELAY_MS = 300;
111+
const DRAG_STEPS = 30;
112+
const DRAG_MOVE_INTERVAL_MS = 30;
113+
const DRAG_END_HOLD_MS = 400;
114+
const DRAG_FINAL_WAIT_MS = 800;
115+
const DRAG_TARGET_UPPER_RATIO = 0.3; // Upper third for upward drag
116+
const DRAG_TARGET_LOWER_RATIO = 0.7; // Lower third for downward drag
117+
118+
/**
119+
* Helper to perform drag and drop using low-level mouse events
120+
* This is necessary for @dnd-kit which requires:
121+
* 1. Minimum 8px movement to activate drag (activationConstraint.distance)
122+
* 2. Smooth pointer events to trigger collision detection
123+
* 3. Proper collision detection timing
124+
*/
125+
export const dragCommandByMouse = async ({
126+
page,
127+
sourceIndex,
128+
targetIndex,
129+
}: {
130+
page: Page;
131+
sourceIndex: number;
132+
targetIndex: number;
133+
}) => {
134+
const commandCards = page.locator(COMMAND_CARD_SELECTOR);
135+
136+
// Wait for cards to be stable
137+
await page.waitForLoadState("networkidle");
138+
await commandCards.first().waitFor({ state: "visible" });
139+
140+
// Get source and target cards
141+
const sourceCard = commandCards.nth(sourceIndex);
142+
const targetCard = commandCards.nth(targetIndex);
143+
144+
// Get drag handles
145+
const sourceDragHandle = sourceCard.locator(DRAG_HANDLE_SELECTOR);
146+
147+
// Wait for elements to be ready
148+
await sourceDragHandle.waitFor({ state: "visible" });
149+
await targetCard.waitFor({ state: "visible" });
150+
151+
// Get bounding boxes
152+
const sourceBox = await sourceDragHandle.boundingBox();
153+
const targetCardBox = await targetCard.boundingBox();
154+
155+
if (!sourceBox || !targetCardBox) {
156+
throw new Error(`Could not find elements for cards ${sourceIndex} and ${targetIndex}`);
157+
}
158+
159+
// Calculate positions
160+
const startX = sourceBox.x + sourceBox.width / 2;
161+
const startY = sourceBox.y + sourceBox.height / 2;
162+
163+
// When dragging:
164+
// - Upward (sourceIndex > targetIndex): aim slightly above target center
165+
// - Downward (sourceIndex < targetIndex): aim slightly below target center
166+
const isUpward = sourceIndex > targetIndex;
167+
const endX = targetCardBox.x + targetCardBox.width / 2;
168+
const endY = isUpward
169+
? targetCardBox.y + targetCardBox.height * DRAG_TARGET_UPPER_RATIO
170+
: targetCardBox.y + targetCardBox.height * DRAG_TARGET_LOWER_RATIO;
171+
172+
// Step 1: Move mouse to the drag handle
173+
await page.mouse.move(startX, startY);
174+
await page.waitForTimeout(MOUSE_MOVE_DELAY_MS);
175+
176+
// Step 2: Press mouse down to start drag
177+
await page.mouse.down();
178+
await page.waitForTimeout(DRAG_START_DELAY_MS);
179+
180+
// Step 3: Move to activate drag (>8px threshold)
181+
await page.mouse.move(startX + ACTIVATION_MOVE_PX, startY);
182+
await page.waitForTimeout(DRAG_ACTIVATION_DELAY_MS);
183+
184+
// Step 4: Move to target in smooth steps
185+
for (let i = 1; i <= DRAG_STEPS; i++) {
186+
const progress = i / DRAG_STEPS;
187+
const x = startX + (endX - startX) * progress;
188+
const y = startY + (endY - startY) * progress;
189+
await page.mouse.move(x, y);
190+
await page.waitForTimeout(DRAG_MOVE_INTERVAL_MS);
191+
}
192+
193+
// Step 5: Hold at target for collision detection
194+
await page.waitForTimeout(DRAG_END_HOLD_MS);
195+
196+
// Step 6: Release mouse to drop
197+
await page.mouse.up();
198+
199+
// Step 7: Wait for drag end animation and state update
200+
await page.waitForTimeout(DRAG_FINAL_WAIT_MS);
201+
};
202+
203+
/**
204+
* Helper to get command names in order
205+
*/
206+
export const getCommandOrder = async (page: Page): Promise<string[]> => {
207+
const cards = await page.locator(COMMAND_CARD_SELECTOR).all();
208+
const names = await Promise.all(
209+
cards.map(async (card) => {
210+
const name = await card.locator(COMMAND_NAME_SELECTOR).textContent();
211+
return name?.trim();
212+
}),
213+
);
214+
return names.filter((name): name is string => !!name);
215+
};
216+
217+
// Event types for drag and drop debugging
218+
export const DRAG_EVENT_TYPES = [
219+
// HTML5 Drag and Drop API
220+
"drag",
221+
"dragstart",
222+
"dragend",
223+
"dragover",
224+
"dragenter",
225+
"dragleave",
226+
"drop",
227+
// Pointer Events API (used by @dnd-kit)
228+
"pointerdown",
229+
"pointerup",
230+
"pointermove",
231+
"pointercancel",
232+
"pointerenter",
233+
"pointerleave",
234+
"pointerover",
235+
"pointerout",
236+
// Mouse Events
237+
"mousedown",
238+
"mouseup",
239+
"mousemove",
240+
"mouseenter",
241+
"mouseleave",
242+
// Touch Events
243+
"touchstart",
244+
"touchend",
245+
"touchmove",
246+
"touchcancel",
247+
];
248+
249+
type WindowWithEvents = Window & typeof globalThis & {
250+
__capturedEvents: string[];
251+
};
252+
253+
/**
254+
* Inject event listeners to capture drag-related events
255+
*/
256+
export const injectEventListeners = async (page: Page) => {
257+
await page.evaluate((eventTypes) => {
258+
const events: string[] = [];
259+
260+
eventTypes.forEach((eventType) => {
261+
document.addEventListener(
262+
eventType,
263+
(e) => {
264+
const target = e.target as HTMLElement;
265+
const targetInfo =
266+
target.getAttribute?.("data-testid") ||
267+
target.getAttribute?.("aria-label") ||
268+
target.tagName;
269+
events.push(`${eventType} on ${targetInfo}`);
270+
},
271+
true,
272+
);
273+
});
274+
275+
// Store events in window for retrieval
276+
(window as WindowWithEvents).__capturedEvents = events;
277+
}, DRAG_EVENT_TYPES);
278+
};
279+
280+
/**
281+
* Get captured events from window
282+
*/
283+
export const getCapturedEvents = async (page: Page): Promise<string[]> => {
284+
return page.evaluate(() => {
285+
return (window as WindowWithEvents).__capturedEvents || [];
286+
});
287+
};
288+
289+
/**
290+
* Clear all existing commands
291+
*/
292+
export const clearAllCommands = async (page: Page) => {
293+
const commandCards = page.locator(COMMAND_CARD_SELECTOR);
294+
295+
while ((await commandCards.count()) > 0) {
296+
// Always delete the first card
297+
const deleteButton = commandCards.first().getByRole("button", { name: /delete/i });
298+
await deleteButton.click();
299+
300+
// Confirm deletion in dialog
301+
const confirmButton = page.getByRole("button", { name: /delete/i });
302+
await confirmButton.click();
303+
304+
// Wait for toast to disappear
305+
const toast = page.locator("[data-sonner-toast]");
306+
await toast.waitFor({ state: "hidden", timeout: TOAST_TIMEOUT });
307+
}
308+
};
309+
310+
/**
311+
* Create test commands
312+
*/
313+
export const createTestCommands = async (
314+
page: Page,
315+
commands: Array<{ name: string; command: string }>,
316+
) => {
317+
for (const cmd of commands) {
318+
// Click the first available add button (could be "Add your first command" or "Add new command")
319+
const addButton = page.getByRole("button", { name: /add/i }).first();
320+
await addButton.click();
321+
await fillCommandForm(page, cmd);
322+
await saveCommandDialog(page);
323+
324+
// Wait for toast to disappear
325+
const toast = page.locator("[data-sonner-toast]");
326+
await toast.waitFor({ state: "hidden", timeout: TOAST_TIMEOUT });
327+
}
328+
};
329+
330+
/**
331+
* Setup test environment by clearing existing commands and creating new ones
332+
*/
333+
export const setupTestCommands = async (
334+
page: Page,
335+
commands: Array<{ name: string; command: string }>,
336+
) => {
337+
await clearAllCommands(page);
338+
await createTestCommands(page, commands);
339+
};

0 commit comments

Comments
 (0)