@@ -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 : / d e l e t e / i } ) ;
298+ await deleteButton . click ( ) ;
299+
300+ // Confirm deletion in dialog
301+ const confirmButton = page . getByRole ( "button" , { name : / d e l e t e / 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 : / a d d / 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