From 16e08d3dc9e0c202b98731cfeebc10f2d41ece27 Mon Sep 17 00:00:00 2001 From: Dannon Baker Date: Wed, 10 Jun 2026 15:42:37 -0400 Subject: [PATCH] Add pointer drag/resize tests for WindowManagerWindow These demonstrate galaxyproject/galaxy#22875: dragging or resizing the floating window has to capture the pointer, otherwise the interaction dies as soon as the cursor crosses an iframe (the center frame, or the window's own iframe body). The tests drive the header drag and corner resize through pointer events and assert the handle captures the pointer; they fail before the fix since the component only listens for mouse events. --- .../WindowManager/WindowManagerWindow.test.ts | 149 ++++++++++++++++++ 1 file changed, 149 insertions(+) create mode 100644 client/src/components/WindowManager/WindowManagerWindow.test.ts diff --git a/client/src/components/WindowManager/WindowManagerWindow.test.ts b/client/src/components/WindowManager/WindowManagerWindow.test.ts new file mode 100644 index 00000000000..6aff6ba8d58 --- /dev/null +++ b/client/src/components/WindowManager/WindowManagerWindow.test.ts @@ -0,0 +1,149 @@ +import { createTestingPinia } from "@pinia/testing"; +import { getLocalVue } from "@tests/vitest/helpers"; +import { mount } from "@vue/test-utils"; +import { setActivePinia } from "pinia"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { useWindowManagerStore } from "@/stores/windowManagerStore"; + +import WindowManagerWindow from "./WindowManagerWindow.vue"; + +const localVue = getLocalVue(); + +// happy-dom does not implement pointer capture, so stub it. Capturing the +// pointer is what keeps drag/resize alive when the cursor crosses an iframe +// (the center frame or the window's own body), so we assert on these calls. +const setPointerCapture = vi.fn(); + +function pointerEvent(type: string, options: PointerEventInit = {}) { + return new PointerEvent(type, { bubbles: true, cancelable: true, button: 0, pointerId: 7, ...options }); +} + +function mountWindow() { + const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false }); + setActivePinia(pinia); + const store = useWindowManagerStore(); + store.add({ title: "GalaxyAI", url: "/galaxyai", x: 50, y: 60, width: 400, height: 300 }); + const win = store.windows[0]!; + const wrapper = mount(WindowManagerWindow as object, { + localVue, + pinia, + propsData: { window: win }, + }); + return { wrapper, win }; +} + +describe("WindowManagerWindow", () => { + beforeEach(() => { + (HTMLElement.prototype as any).setPointerCapture = setPointerCapture; + }); + + afterEach(() => { + vi.clearAllMocks(); + delete (HTMLElement.prototype as any).setPointerCapture; + }); + + it("resizes with the pointer and captures it on the handle", () => { + const { wrapper, win } = mountWindow(); + const handle = wrapper.find(".window-manager-resize-handle"); + + handle.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 450, clientY: 360 })); + expect(setPointerCapture).toHaveBeenCalledWith(7); + + document.dispatchEvent(pointerEvent("pointermove", { clientX: 550, clientY: 410 })); + expect(win.width).toBe(500); + expect(win.height).toBe(350); + + document.dispatchEvent(pointerEvent("pointerup", { clientX: 550, clientY: 410 })); + document.dispatchEvent(pointerEvent("pointermove", { clientX: 650, clientY: 460 })); + expect(win.width).toBe(500); + expect(win.height).toBe(350); + }); + + it("drags with the pointer and captures it on the header", () => { + const { wrapper, win } = mountWindow(); + const header = wrapper.find(".window-manager-window-header"); + + header.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 200, clientY: 80 })); + expect(setPointerCapture).toHaveBeenCalledWith(7); + + document.dispatchEvent(pointerEvent("pointermove", { clientX: 230, clientY: 120 })); + expect(win.x).toBe(80); + expect(win.y).toBe(100); + + document.dispatchEvent(pointerEvent("pointerup", { clientX: 230, clientY: 120 })); + document.dispatchEvent(pointerEvent("pointermove", { clientX: 300, clientY: 200 })); + expect(win.x).toBe(80); + expect(win.y).toBe(100); + }); + + it("stops resizing when the pointer is cancelled", () => { + const { wrapper, win } = mountWindow(); + const handle = wrapper.find(".window-manager-resize-handle"); + + handle.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 450, clientY: 360 })); + document.dispatchEvent(pointerEvent("pointermove", { clientX: 550, clientY: 410 })); + expect(win.width).toBe(500); + + document.dispatchEvent(pointerEvent("pointercancel", { clientX: 550, clientY: 410 })); + document.dispatchEvent(pointerEvent("pointermove", { clientX: 650, clientY: 460 })); + expect(win.width).toBe(500); + expect(win.height).toBe(350); + }); + + it("does not start a drag from the window controls", () => { + const { wrapper, win } = mountWindow(); + const controls = wrapper.find(".window-manager-window-controls"); + + controls.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 420, clientY: 70 })); + document.dispatchEvent(pointerEvent("pointermove", { clientX: 500, clientY: 200 })); + expect(win.x).toBe(50); + expect(win.y).toBe(60); + }); + + it("ignores a second pointer while dragging", () => { + const { wrapper, win } = mountWindow(); + const header = wrapper.find(".window-manager-window-header"); + + header.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 200, clientY: 80 })); + header.element.dispatchEvent(pointerEvent("pointerdown", { pointerId: 9, clientX: 300, clientY: 200 })); + + document.dispatchEvent(pointerEvent("pointermove", { pointerId: 9, clientX: 400, clientY: 300 })); + expect(win.x).toBe(50); + expect(win.y).toBe(60); + + document.dispatchEvent(pointerEvent("pointermove", { clientX: 230, clientY: 120 })); + expect(win.x).toBe(80); + expect(win.y).toBe(100); + + document.dispatchEvent(pointerEvent("pointerup", { clientX: 230, clientY: 120 })); + }); + + it("does not steal focus when clicking the controls of an unfocused window", () => { + const pinia = createTestingPinia({ createSpy: vi.fn, stubActions: false }); + setActivePinia(pinia); + const store = useWindowManagerStore(); + store.add({ title: "First", url: "/a", x: 50, y: 60, width: 400, height: 300 }); + store.add({ title: "Second", url: "/b" }); + const first = store.windows[0]!; + const second = store.windows[1]!; + const wrapper = mount(WindowManagerWindow as object, { + localVue, + pinia, + propsData: { window: first }, + }); + expect(store.focusedId).toBe(second.id); + + // a real click delivers pointerdown plus a compatibility mousedown; + // neither may bubble to the root focus handler from the controls + const controls = wrapper.find(".window-manager-window-controls"); + controls.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 420, clientY: 70 })); + controls.element.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, cancelable: true, button: 0 })); + expect(store.focusedId).toBe(second.id); + + const header = wrapper.find(".window-manager-window-header"); + header.element.dispatchEvent(pointerEvent("pointerdown", { clientX: 200, clientY: 80 })); + expect(store.focusedId).toBe(first.id); + document.dispatchEvent(pointerEvent("pointerup", { clientX: 200, clientY: 80 })); + }); +});