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
544 lines
15 KiB
TypeScript
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);
|
|
});
|
|
});
|
|
});
|
|
});
|