|
| 1 | +/** |
| 2 | + * RepoTabStrip.vue — pointer-based drag-to-reorder regression guards. |
| 3 | + * |
| 4 | + * Native HTML5 drag-and-drop is unreliable in the WebKit webviews that power |
| 5 | + * the packaged Tauri app, so tab reordering is driven by Pointer Events |
| 6 | + * instead (see the "Drag to reorder" comment block in the component) — one |
| 7 | + * code path for mouse, touch and pen. That hand-rolled state machine has |
| 8 | + * sharp edges a component test can pin down without a real browser: a click |
| 9 | + * near a tab's own edge must not itself register as a reorder, a drag must |
| 10 | + * only ever be committed by the button that started it, and a re-snapshot |
| 11 | + * mid-drag (scroll, resize) must not double-count the dragged tab's own live |
| 12 | + * offset. |
| 13 | + * |
| 14 | + * jsdom has no PointerEvent constructor and no setPointerCapture, so drags |
| 15 | + * are driven with plain `Event` objects carrying the same fields the |
| 16 | + * component reads off a real PointerEvent (clientX/clientY/button/pointerId) |
| 17 | + * — dispatchEvent doesn't care which Event subclass a listener was declared |
| 18 | + * against, only the `type` string. The component itself feature-detects |
| 19 | + * setPointerCapture, so it degrades to window-listener-only tracking under |
| 20 | + * jsdom exactly like it would in a webview that lacks pointer capture. |
| 21 | + * |
| 22 | + * Mounted with native `createApp` into jsdom (no @vue/test-utils dep), |
| 23 | + * mirroring MergeEditor.test / LlmTracePanel.test / LaunchpadView.test. |
| 24 | + */ |
| 25 | + |
| 26 | +import { describe, it, expect, afterEach } from "vitest"; |
| 27 | +import { createApp, h, nextTick, type App } from "vue"; |
| 28 | +import RepoTabStrip from "../header/RepoTabStrip.vue"; |
| 29 | +import type { RepoTab } from "../../composables/useRepoTabs"; |
| 30 | + |
| 31 | +function makeTabs(n: number): RepoTab[] { |
| 32 | + return Array.from({ length: n }, (_, i) => ({ |
| 33 | + id: i + 1, |
| 34 | + path: `/repo-${i}`, |
| 35 | + name: `repo-${i}`, |
| 36 | + })); |
| 37 | +} |
| 38 | + |
| 39 | +let app: App | null = null; |
| 40 | +let container: HTMLElement | null = null; |
| 41 | + |
| 42 | +afterEach(() => { |
| 43 | + app?.unmount(); |
| 44 | + app = null; |
| 45 | + container?.remove(); |
| 46 | + container = null; |
| 47 | +}); |
| 48 | + |
| 49 | +interface Mounted { |
| 50 | + tabEls: HTMLElement[]; |
| 51 | + reorders: Array<[number, number]>; |
| 52 | +} |
| 53 | + |
| 54 | +function mount(tabs: RepoTab[]): Mounted { |
| 55 | + container = document.createElement("div"); |
| 56 | + document.body.appendChild(container); |
| 57 | + const reorders: Array<[number, number]> = []; |
| 58 | + |
| 59 | + app = createApp({ |
| 60 | + render() { |
| 61 | + return h(RepoTabStrip, { |
| 62 | + tabs, |
| 63 | + activeTabId: tabs[0]?.id ?? null, |
| 64 | + onReorderTabs: (oldIndex: number, newIndex: number) => { |
| 65 | + reorders.push([oldIndex, newIndex]); |
| 66 | + }, |
| 67 | + }); |
| 68 | + }, |
| 69 | + }); |
| 70 | + app.mount(container); |
| 71 | + |
| 72 | + const tabEls = Array.from(container.querySelectorAll<HTMLElement>(".repo-tab")); |
| 73 | + return { tabEls, reorders }; |
| 74 | +} |
| 75 | + |
| 76 | +/** Stubs the geometry each tab needs for the drag hit-test to run in jsdom. */ |
| 77 | +function stubRects(tabEls: HTMLElement[], widths: number[], lefts?: number[]) { |
| 78 | + let left = 0; |
| 79 | + for (let i = 0; i < tabEls.length; i++) { |
| 80 | + const width = widths[i]; |
| 81 | + const l = lefts ? lefts[i] : left; |
| 82 | + const rect = { left: l, right: l + width, top: 0, bottom: 30, width, height: 30 } as DOMRect; |
| 83 | + tabEls[i].getBoundingClientRect = () => rect; |
| 84 | + left += width; |
| 85 | + } |
| 86 | +} |
| 87 | + |
| 88 | +let nextPointerId = 1; |
| 89 | + |
| 90 | +function firePointer( |
| 91 | + target: EventTarget, |
| 92 | + type: string, |
| 93 | + opts: { clientX?: number; clientY?: number; button?: number; pointerId?: number }, |
| 94 | +) { |
| 95 | + const event = new Event(type, { bubbles: true, cancelable: true }); |
| 96 | + Object.defineProperty(event, "clientX", { get: () => opts.clientX ?? 0 }); |
| 97 | + Object.defineProperty(event, "clientY", { get: () => opts.clientY ?? 15 }); |
| 98 | + Object.defineProperty(event, "button", { get: () => opts.button ?? 0 }); |
| 99 | + Object.defineProperty(event, "pointerId", { get: () => opts.pointerId ?? nextPointerId }); |
| 100 | + target.dispatchEvent(event); |
| 101 | +} |
| 102 | + |
| 103 | +describe("RepoTabStrip — drag to reorder", () => { |
| 104 | + it("a few pixels of wobble on a tab's right half does not reorder it", async () => { |
| 105 | + // Regression: the hit-test used to compare the raw cursor position |
| 106 | + // against each tab's center, so pressing the right half of a wide tab |
| 107 | + // put the cursor on the far side of its own center *before any |
| 108 | + // movement at all* — a subsequent few-pixel wobble (well under what |
| 109 | + // anyone would call a drag) was enough to swap it with its neighbour. |
| 110 | + const { tabEls, reorders } = mount(makeTabs(3)); |
| 111 | + stubRects(tabEls, [100, 100, 100]); |
| 112 | + |
| 113 | + // Press near the right edge of tab 0 (center is at x=50). |
| 114 | + firePointer(tabEls[0], "pointerdown", { clientX: 90 }); |
| 115 | + // Wobble 5px right — just past the 4px drag threshold. |
| 116 | + firePointer(window, "pointermove", { clientX: 95 }); |
| 117 | + firePointer(window, "pointerup", { clientX: 95 }); |
| 118 | + await nextTick(); |
| 119 | + |
| 120 | + expect(reorders).toEqual([]); |
| 121 | + }); |
| 122 | + |
| 123 | + it("dragging a tab past its neighbour's center reorders it", async () => { |
| 124 | + const { tabEls, reorders } = mount(makeTabs(3)); |
| 125 | + stubRects(tabEls, [100, 100, 100]); |
| 126 | + |
| 127 | + // Press at the center of tab 0 and drag well past tab 1's center (150). |
| 128 | + firePointer(tabEls[0], "pointerdown", { clientX: 50 }); |
| 129 | + firePointer(window, "pointermove", { clientX: 160 }); |
| 130 | + firePointer(window, "pointerup", { clientX: 160 }); |
| 131 | + await nextTick(); |
| 132 | + |
| 133 | + expect(reorders).toEqual([[0, 1]]); |
| 134 | + }); |
| 135 | + |
| 136 | + it("a right-click released mid-drag does not commit or cancel it", async () => { |
| 137 | + const { tabEls, reorders } = mount(makeTabs(3)); |
| 138 | + stubRects(tabEls, [100, 100, 100]); |
| 139 | + |
| 140 | + firePointer(tabEls[0], "pointerdown", { clientX: 50, button: 0 }); |
| 141 | + firePointer(window, "pointermove", { clientX: 160 }); |
| 142 | + // A right-button pointerup mid-drag must be ignored... |
| 143 | + firePointer(window, "pointerup", { clientX: 160, button: 2 }); |
| 144 | + await nextTick(); |
| 145 | + expect(reorders).toEqual([]); |
| 146 | + // ...the left-button release still ends the drag normally. |
| 147 | + firePointer(window, "pointerup", { clientX: 160, button: 0 }); |
| 148 | + await nextTick(); |
| 149 | + expect(reorders).toEqual([[0, 1]]); |
| 150 | + }); |
| 151 | + |
| 152 | + it("releasing far outside the strip cancels the drag instead of reordering", async () => { |
| 153 | + const { tabEls, reorders } = mount(makeTabs(3)); |
| 154 | + stubRects(tabEls, [100, 100, 100]); |
| 155 | + |
| 156 | + firePointer(tabEls[0], "pointerdown", { clientX: 50 }); |
| 157 | + // Drag past tab 1's center but with the cursor far below the strip. |
| 158 | + firePointer(window, "pointermove", { clientX: 160, clientY: 500 }); |
| 159 | + firePointer(window, "pointerup", { clientX: 160, clientY: 500 }); |
| 160 | + await nextTick(); |
| 161 | + |
| 162 | + expect(reorders).toEqual([]); |
| 163 | + }); |
| 164 | + |
| 165 | + it("a window blur mid-drag cancels it", async () => { |
| 166 | + const { tabEls, reorders } = mount(makeTabs(3)); |
| 167 | + stubRects(tabEls, [100, 100, 100]); |
| 168 | + |
| 169 | + firePointer(tabEls[0], "pointerdown", { clientX: 50 }); |
| 170 | + firePointer(window, "pointermove", { clientX: 160 }); |
| 171 | + window.dispatchEvent(new Event("blur")); |
| 172 | + // The pointerup that follows (returning from another app) must not commit. |
| 173 | + firePointer(window, "pointerup", { clientX: 160 }); |
| 174 | + await nextTick(); |
| 175 | + |
| 176 | + expect(reorders).toEqual([]); |
| 177 | + }); |
| 178 | + |
| 179 | + it("a pointercancel mid-drag (e.g. a touch gesture interrupted) cancels it", async () => { |
| 180 | + const { tabEls, reorders } = mount(makeTabs(3)); |
| 181 | + stubRects(tabEls, [100, 100, 100]); |
| 182 | + |
| 183 | + firePointer(tabEls[0], "pointerdown", { clientX: 50 }); |
| 184 | + firePointer(window, "pointermove", { clientX: 160 }); |
| 185 | + firePointer(window, "pointercancel", { clientX: 160 }); |
| 186 | + firePointer(window, "pointerup", { clientX: 160 }); |
| 187 | + await nextTick(); |
| 188 | + |
| 189 | + expect(reorders).toEqual([]); |
| 190 | + }); |
| 191 | + |
| 192 | + it("a resize mid-drag re-snapshots without double-counting the dragged tab's own offset", async () => { |
| 193 | + // Regression: re-snapshotting centers mid-drag (on scroll or resize) used |
| 194 | + // to re-measure the dragged tab's rect as-is — but that rect already |
| 195 | + // includes the live translateX a real browser would have painted, so the |
| 196 | + // offset got added to the hit-test a second time and the drop landed one |
| 197 | + // slot further than it should have. |
| 198 | + const { tabEls, reorders } = mount(makeTabs(3)); |
| 199 | + // Resting centers: 50, 150, 250 (widths 100 each, lefts 0/100/200). |
| 200 | + stubRects(tabEls, [100, 100, 100]); |
| 201 | + |
| 202 | + firePointer(tabEls[0], "pointerdown", { clientX: 50 }); |
| 203 | + // Drag 100px right — didDrag flips true and snapshots the resting |
| 204 | + // centers; hoveredIndex is still 0 (hasn't crossed tab 1's center yet). |
| 205 | + firePointer(window, "pointermove", { clientX: 150 }); |
| 206 | + |
| 207 | + // Simulate the DOM now reflecting that 100px live translateX: tab 0's |
| 208 | + // rect has visually shifted right by 100, exactly as a real browser |
| 209 | + // would render it mid-drag (the other tabs don't move). |
| 210 | + stubRects(tabEls, [100, 100, 100], [100, 100, 200]); |
| 211 | + window.dispatchEvent(new Event("resize")); |
| 212 | + |
| 213 | + // Move on to a total offset of 110px from the press point — enough to |
| 214 | + // cross tab 1's resting center (150) but not tab 2's (250) once the |
| 215 | + // resize re-snapshot has correctly stripped the baked-in offset back out. |
| 216 | + firePointer(window, "pointermove", { clientX: 160 }); |
| 217 | + firePointer(window, "pointerup", { clientX: 160 }); |
| 218 | + await nextTick(); |
| 219 | + |
| 220 | + expect(reorders).toEqual([[0, 1]]); |
| 221 | + }); |
| 222 | +}); |
| 223 | + |
| 224 | +describe("RepoTabStrip — keyboard reordering", () => { |
| 225 | + function fireCtrlShiftArrow(target: EventTarget, key: "ArrowLeft" | "ArrowRight") { |
| 226 | + target.dispatchEvent( |
| 227 | + new KeyboardEvent("keydown", { key, ctrlKey: true, shiftKey: true, bubbles: true, cancelable: true }), |
| 228 | + ); |
| 229 | + } |
| 230 | + |
| 231 | + it("Ctrl+Shift+ArrowRight moves the focused tab one slot right and announces it", async () => { |
| 232 | + const { tabEls, reorders } = mount(makeTabs(3)); |
| 233 | + |
| 234 | + fireCtrlShiftArrow(tabEls[0], "ArrowRight"); |
| 235 | + await nextTick(); |
| 236 | + |
| 237 | + expect(reorders).toEqual([[0, 1]]); |
| 238 | + expect(container?.querySelector('[role="status"]')?.textContent).toBe("repo-0 moved to position 2 of 3"); |
| 239 | + }); |
| 240 | + |
| 241 | + it("Ctrl+Shift+ArrowLeft moves the focused tab one slot left", async () => { |
| 242 | + const { tabEls, reorders } = mount(makeTabs(3)); |
| 243 | + |
| 244 | + fireCtrlShiftArrow(tabEls[1], "ArrowLeft"); |
| 245 | + await nextTick(); |
| 246 | + |
| 247 | + expect(reorders).toEqual([[1, 0]]); |
| 248 | + }); |
| 249 | + |
| 250 | + it("does nothing at the boundary (first tab can't move left, last can't move right)", async () => { |
| 251 | + const { tabEls, reorders } = mount(makeTabs(3)); |
| 252 | + |
| 253 | + fireCtrlShiftArrow(tabEls[0], "ArrowLeft"); |
| 254 | + fireCtrlShiftArrow(tabEls[2], "ArrowRight"); |
| 255 | + await nextTick(); |
| 256 | + |
| 257 | + expect(reorders).toEqual([]); |
| 258 | + }); |
| 259 | + |
| 260 | + it("a plain ArrowRight (no modifier) does not reorder — only switches focus natively", async () => { |
| 261 | + const { tabEls, reorders } = mount(makeTabs(3)); |
| 262 | + |
| 263 | + tabEls[0].dispatchEvent(new KeyboardEvent("keydown", { key: "ArrowRight", bubbles: true, cancelable: true })); |
| 264 | + await nextTick(); |
| 265 | + |
| 266 | + expect(reorders).toEqual([]); |
| 267 | + }); |
| 268 | + |
| 269 | + it("bare Ctrl+ArrowRight (no Shift) does not reorder", async () => { |
| 270 | + // Regression: a bare Ctrl+Arrow is macOS's default Mission Control |
| 271 | + // "switch Space" shortcut, intercepted by the OS before it would even |
| 272 | + // reach the app — Shift is required precisely to stay clear of that (and |
| 273 | + // of third-party window-manager shortcuts using the same bare combo). |
| 274 | + // This only guards our own handler's condition; it can't simulate the |
| 275 | + // OS swallowing the event outright. |
| 276 | + const { tabEls, reorders } = mount(makeTabs(3)); |
| 277 | + |
| 278 | + tabEls[0].dispatchEvent( |
| 279 | + new KeyboardEvent("keydown", { key: "ArrowRight", ctrlKey: true, bubbles: true, cancelable: true }), |
| 280 | + ); |
| 281 | + await nextTick(); |
| 282 | + |
| 283 | + expect(reorders).toEqual([]); |
| 284 | + }); |
| 285 | +}); |
0 commit comments