Files
fusion/packages/dashboard/app/hooks/__tests__/useMobileKeyboard.test.ts
Fusion e3011e9aca feat(FN-3362): add node detail modal with docker config routes
This merge delivers three focused changes: fixes the Git Manager modal mobile overflow (FN-3367) with new CSS, stabilizes the Docker config modal recreate indicator tests (FN-3114) with comprehensive new test coverage, and adds hook baseline cleanup tests (FN-3362). The changes span the core databas

Fusion-Task-Id: FN-3362
2026-05-04 19:17:16 -07:00

544 lines
15 KiB
TypeScript

import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { _resetInitialViewportHeight, useMobileKeyboard } from "../useMobileKeyboard";
describe("useMobileKeyboard", () => {
let savedVisualViewport: typeof window.visualViewport;
let savedInnerWidth: number;
let savedInnerHeight: number;
let savedOntouchstart: typeof window.ontouchstart;
let savedMaxTouchPoints: number;
beforeEach(() => {
_resetInitialViewportHeight();
savedVisualViewport = window.visualViewport;
savedInnerWidth = window.innerWidth;
savedInnerHeight = window.innerHeight;
savedOntouchstart = window.ontouchstart;
savedMaxTouchPoints = navigator.maxTouchPoints;
});
afterEach(() => {
_resetInitialViewportHeight();
Object.defineProperty(window, "visualViewport", {
value: savedVisualViewport,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerWidth", {
value: savedInnerWidth,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: savedInnerHeight,
writable: true,
configurable: true,
});
Object.defineProperty(window, "ontouchstart", {
value: savedOntouchstart,
writable: true,
configurable: true,
});
Object.defineProperty(navigator, "maxTouchPoints", {
value: savedMaxTouchPoints,
configurable: true,
});
vi.restoreAllMocks();
});
function setupMobileVisualViewport({
innerHeight,
vvHeight,
vvOffsetTop = 0,
}: {
innerHeight: number;
vvHeight: number;
vvOffsetTop?: number;
}) {
(window as any).ontouchstart = null;
Object.defineProperty(navigator, "maxTouchPoints", {
value: 5,
configurable: true,
});
Object.defineProperty(window, "innerWidth", {
value: 375,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: innerHeight,
writable: true,
configurable: true,
});
const listeners: Record<string, Array<() => void>> = {
resize: [],
scroll: [],
};
const mockVV = {
width: 375,
height: vvHeight,
offsetTop: vvOffsetTop,
offsetLeft: 0,
addEventListener: vi.fn((event: string, cb: () => void) => {
listeners[event]?.push(cb);
}),
removeEventListener: vi.fn(),
};
Object.defineProperty(window, "visualViewport", {
value: mockVV,
writable: true,
configurable: true,
});
return { listeners, mockVV };
}
it("keeps keyboardOverlap at 0 when not on mobile", async () => {
delete (window as any).ontouchstart;
Object.defineProperty(navigator, "maxTouchPoints", {
value: 0,
configurable: true,
});
Object.defineProperty(window, "innerWidth", {
value: 1280,
writable: true,
configurable: true,
});
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(0);
expect(result.current.viewportHeight).toBeNull();
});
});
it("updates overlap when visualViewport resize fires on mobile", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 800,
vvHeight: 600,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(200);
expect(result.current.viewportHeight).toBe(600);
});
Object.defineProperty(window, "innerHeight", {
value: 800,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 700,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(100);
expect(result.current.viewportHeight).toBe(700);
});
input.remove();
});
it("unsubscribes listeners and resets state when disabled", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 760,
vvHeight: 600,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
const { result, rerender } = renderHook(
({ enabled }) => useMobileKeyboard({ enabled }),
{ initialProps: { enabled: true } },
);
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(160);
expect(result.current.viewportHeight).toBe(600);
});
const resizeListener = listeners.resize[0];
const scrollListener = listeners.scroll[0];
rerender({ enabled: false });
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(0);
expect(result.current.viewportHeight).toBeNull();
});
expect(mockVV.removeEventListener).toHaveBeenCalledWith("resize", resizeListener);
expect(mockVV.removeEventListener).toHaveBeenCalledWith("scroll", scrollListener);
input.remove();
});
it("uses iOS Safari fallback when innerHeight shrinks with visualViewport", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(0);
expect(result.current.viewportHeight).toBeNull();
});
Object.defineProperty(window, "innerHeight", {
value: 520,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 520,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(324);
expect(result.current.viewportHeight).toBe(520);
});
input.remove();
});
it("reports moderate iOS fallback overlap below 80px", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(0);
});
Object.defineProperty(window, "innerHeight", {
value: 804,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 804,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(40);
expect(result.current.viewportHeight).toBe(804);
});
input.remove();
});
it("uses focused-input fallback for small viewport gaps", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
input.focus();
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(0);
});
Object.defineProperty(window, "innerHeight", {
value: 820,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 820,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(24);
expect(result.current.viewportHeight).toBe(820);
expect(result.current.keyboardOpen).toBe(true);
});
input.remove();
});
it("treats focused input + viewport shrink as keyboard-open even when overlap is 0", async () => {
// iOS last-resort path: chromeOverlap = 0 (innerHeight tracks offsetTop+vv.height),
// gap < 16 (focused-fallback doesn't fire), and viewportShrink >= 16 from the
// baseline so the focused-input shrink heuristic is the only signal left.
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("input");
input.type = "text";
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
});
input.focus();
Object.defineProperty(mockVV, "height", {
value: 824,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "offsetTop", {
value: 5,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: 829,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOverlap).toBe(0);
expect(result.current.viewportHeight).toBe(824);
expect(result.current.keyboardOpen).toBe(true);
});
input.remove();
});
it("reports keyboardOpen=false the instant focus leaves an input even while visualViewport still reports keyboard-up size", async () => {
// Regression for the ChatView "composer crawls down with the keyboard"
// bug: on iOS the visualViewport keeps reporting the small mid-dismiss
// size for hundreds of ms after the user blurs an input. App-level
// layout (mobile nav bar, project-content padding) must flip back to
// no-keyboard mode immediately on blur, not when vv finally settles.
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
// Bring up the keyboard: focus the input, then shrink the viewport.
input.focus();
Object.defineProperty(window, "innerHeight", { value: 520, writable: true, configurable: true });
Object.defineProperty(mockVV, "height", { value: 520, writable: true, configurable: true });
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(true);
});
// Blur, but leave visualViewport still reporting the small mid-dismiss
// size — the dismissal animation takes hundreds of ms on iOS.
input.blur();
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
});
input.remove();
});
// FN-3290 regression: focusout must reset keyboard state when input blurs
describe("FN-3290: focusout resets keyboard state", () => {
it("resets keyboardOpen to false on focusout when viewport returns to baseline", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("textarea");
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
});
// Focus the input and simulate keyboard opening
input.focus();
Object.defineProperty(window, "innerHeight", {
value: 520,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 520,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(true);
expect(result.current.keyboardOverlap).toBe(324);
});
// Blur the input and restore viewport to baseline
input.blur();
Object.defineProperty(window, "innerHeight", {
value: 844,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "height", {
value: 844,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
expect(result.current.keyboardOverlap).toBe(0);
});
input.remove();
});
it("clears keyboardOpen when active input is removed from DOM (simulating modal close)", async () => {
const { listeners, mockVV } = setupMobileVisualViewport({
innerHeight: 844,
vvHeight: 844,
});
const input = document.createElement("input");
input.type = "text";
document.body.appendChild(input);
const { result } = renderHook(() => useMobileKeyboard());
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
});
// Focus input and shrink viewport (keyboard appears)
input.focus();
Object.defineProperty(mockVV, "height", {
value: 824,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "offsetTop", {
value: 5,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: 829,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(true);
});
// Simulate modal close: remove the focused input from DOM and restore viewport
// The focusout event fires when the element is removed
input.remove();
Object.defineProperty(mockVV, "height", {
value: 844,
writable: true,
configurable: true,
});
Object.defineProperty(mockVV, "offsetTop", {
value: 0,
writable: true,
configurable: true,
});
Object.defineProperty(window, "innerHeight", {
value: 844,
writable: true,
configurable: true,
});
act(() => {
for (const cb of listeners.resize) cb();
});
await waitFor(() => {
expect(result.current.keyboardOpen).toBe(false);
expect(result.current.keyboardOverlap).toBe(0);
});
});
});
});